diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b173e117..e5096119 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -151,6 +151,8 @@ jobs: with: go-version: ${{ env.GO_VERSION }} + - uses: azure/setup-helm@v4 + - name: Run tests run: make test diff --git a/charts/openclaw-operator/templates/_helpers.tpl b/charts/openclaw-operator/templates/_helpers.tpl index dc92a770..941a0d8a 100644 --- a/charts/openclaw-operator/templates/_helpers.tpl +++ b/charts/openclaw-operator/templates/_helpers.tpl @@ -88,10 +88,6 @@ kubebuilder-generated config/rbac/role.yaml. - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] -# Read the instance namespace's Pod Security labels (storage.fixOwnership default) -- apiGroups: [""] - resources: ["namespaces"] - verbs: ["get"] # Apps API - apiGroups: ["apps"] resources: ["statefulsets"] @@ -163,6 +159,13 @@ hack/check-helm-rbac-sync.sh parses this define alongside managerRules so the union is asserted to be a superset of config/rbac/role.yaml. */}} {{- define "openclaw-operator.clusterScopedRules" -}} +# Read the instance namespace's Pod Security labels (storage.fixOwnership default) +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get"] +{{- if .Values.watchNamespaces }} + resourceNames: {{ .Values.watchNamespaces | toJson }} +{{- end }} # OpenClawClusterDefaults singleton (#457) - apiGroups: ["openclaw.rocks"] resources: ["openclawclusterdefaults"] diff --git a/hack/check-helm-rbac-sync.sh b/hack/check-helm-rbac-sync.sh index 92e84b2d..8b691e42 100755 --- a/hack/check-helm-rbac-sync.sh +++ b/hack/check-helm-rbac-sync.sh @@ -72,7 +72,8 @@ parse_generated() { parse_helm() { awk ' /\{\{-? *define .openclaw-operator\.(managerRules|clusterScopedRules). *-?\}\}/ { in_rules = 1; next } - in_rules && /\{\{-? *end *-?\}\}/ { in_rules = 0; next } + in_rules && /\{\{-? *end *-?\}\}/ { if (depth > 0) { depth--; next }; in_rules = 0; next } + in_rules && /\{\{-? *if / { depth++; next } !in_rules { next } /^\s*#/ { next } diff --git a/test/helm/namespace_rbac_test.go b/test/helm/namespace_rbac_test.go new file mode 100644 index 00000000..5039522c --- /dev/null +++ b/test/helm/namespace_rbac_test.go @@ -0,0 +1,138 @@ +/* +Copyright 2026 Paperclip Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helm + +import ( + "bytes" + "context" + "errors" + "io" + "os/exec" + "testing" + "time" + + authorizationv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +// Exercise actual authorization with the rendered chart's bindings. Merely +// finding a namespaces rule in a Role misses that Namespace is cluster-scoped. +func TestNamespaceReadAuthorization(t *testing.T) { + if _, err := exec.LookPath("helm"); err != nil { + t.Skip("helm is required (installed in CI)") + } + env := &envtest.Environment{} + env.ControlPlane.GetAPIServer().Configure().Set("authorization-mode", "RBAC") + cfg, err := env.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if stopErr := env.Stop(); stopErr != nil { + t.Error(stopErr) + } + }) + admin, err := client.New(cfg, client.Options{}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + for _, name := range []string{"operator-system", "tenant-a", "tenant-b", "unwatched"} { + if err := admin.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}}); err != nil { + t.Fatal(err) + } + } + for _, scoped := range []bool{true, false} { + name := "cluster" + if scoped { + name = "scoped" + } + t.Run(name, func(t *testing.T) { + args := []string{"template", name, "../../charts/openclaw-operator", "--namespace", "operator-system"} + if scoped { + args = append(args, "--set", "watchNamespaces={tenant-a,tenant-b}") + } + rendered, err := exec.CommandContext(ctx, "helm", args...).Output() + if err != nil { + t.Fatal(err) + } + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(rendered), 4096) + for { + obj := &unstructured.Unstructured{} + decodeErr := decoder.Decode(obj) + if errors.Is(decodeErr, io.EOF) { + break + } + if decodeErr != nil { + t.Fatal(decodeErr) + } + switch obj.GetKind() { + case "Role", "RoleBinding", "ServiceAccount": + if obj.GetNamespace() == "" { + obj.SetNamespace("operator-system") + } + case "ClusterRole", "ClusterRoleBinding": + default: + continue + } + if createErr := admin.Create(ctx, obj); createErr != nil { + t.Fatal(createErr) + } + } + user, err := env.AddUser(envtest.User{Name: "system:serviceaccount:operator-system:" + name + "-openclaw-operator", Groups: []string{"system:authenticated"}}, cfg) + if err != nil { + t.Fatal(err) + } + api, err := kubernetes.NewForConfig(user.Config()) + if err != nil { + t.Fatal(err) + } + for _, check := range []struct { + verb, name string + allowed bool + }{ + {"get", "tenant-a", true}, {"get", "tenant-b", true}, + {"get", "unwatched", !scoped}, {"get", "operator-system", !scoped}, + {"list", "", false}, {"watch", "", false}, {"patch", "tenant-a", false}, + } { + review := &authorizationv1.SelfSubjectAccessReview{Spec: authorizationv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authorizationv1.ResourceAttributes{Verb: check.verb, Resource: "namespaces", Name: check.name}}} + // RBAC authorizer informers observe newly-created bindings asynchronously. + deadline := time.Now().Add(5 * time.Second) + for { + result, err := api.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, review, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if result.Status.Allowed == check.allowed { + break + } + if time.Now().After(deadline) { + t.Fatalf("%s namespace %q: allowed=%t, want %t (%s)", check.verb, check.name, result.Status.Allowed, check.allowed, result.Status.Reason) + } + time.Sleep(50 * time.Millisecond) + } + } + }) + } +}