diff --git a/charts/openbao-operator/templates/admission/provisioner-rbac.yaml b/charts/openbao-operator/templates/admission/provisioner-rbac.yaml index 45b662e9b..11a2af0ae 100644 --- a/charts/openbao-operator/templates/admission/provisioner-rbac.yaml +++ b/charts/openbao-operator/templates/admission/provisioner-rbac.yaml @@ -100,6 +100,15 @@ spec: rule.verbs != null && rule.verbs.all(v, v in ['create', 'delete', 'get', 'list', 'patch', 'update', 'watch']) ) || + ( + rule.apiGroups.size() == 1 && + rule.apiGroups[0] == 'openbao.org' && + rule.resources != null && + rule.resources.size() == 1 && + rule.resources[0] == 'openbaoclusters' && + rule.verbs != null && + rule.verbs.all(v, v in ['usecustomexecutables', 'useimagetrustroots']) + ) || ( rule.apiGroups.size() == 1 && rule.apiGroups[0] == 'apps' && diff --git a/charts/openbao-operator/templates/admission/validate-openbaocluster.yaml b/charts/openbao-operator/templates/admission/validate-openbaocluster.yaml index 0e96bcbe3..ee0e55e9d 100644 --- a/charts/openbao-operator/templates/admission/validate-openbaocluster.yaml +++ b/charts/openbao-operator/templates/admission/validate-openbaocluster.yaml @@ -109,6 +109,35 @@ spec: - name: rolling_upgrade_progress_started expression: >- oldObject != null && has(oldObject.status) && has(oldObject.status.upgrade) && (oldObject.status.upgrade.currentPartition < oldObject.spec.replicas || (has(oldObject.status.upgrade.completedPods) && size(oldObject.status.upgrade.completedPods) > 0)) + - name: custom_executables_authorized + expression: >- + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("usecustomexecutables").allowed() || + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("usehelperimages").allowed() + - name: image_trust_roots_authorized + expression: >- + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("useimagetrustroots").allowed() + - name: has_custom_main_image_trust_roots + expression: >- + has(object.spec.imageVerification) && + ( + (has(object.spec.imageVerification.publicKey) && object.spec.imageVerification.publicKey.trim() != "") || + (has(object.spec.imageVerification.issuer) && object.spec.imageVerification.issuer.trim() != "") || + (has(object.spec.imageVerification.subject) && object.spec.imageVerification.subject.trim() != "") || + (has(object.spec.imageVerification.issuerRegExp) && object.spec.imageVerification.issuerRegExp.trim() != "") || + (has(object.spec.imageVerification.subjectRegExp) && object.spec.imageVerification.subjectRegExp.trim() != "") || + (has(object.spec.imageVerification.ignoreTlog) && object.spec.imageVerification.ignoreTlog == true) + ) + - name: has_custom_operator_image_trust_roots + expression: >- + has(object.spec.operatorImageVerification) && + ( + (has(object.spec.operatorImageVerification.publicKey) && object.spec.operatorImageVerification.publicKey.trim() != "") || + (has(object.spec.operatorImageVerification.issuer) && object.spec.operatorImageVerification.issuer.trim() != "") || + (has(object.spec.operatorImageVerification.subject) && object.spec.operatorImageVerification.subject.trim() != "") || + (has(object.spec.operatorImageVerification.issuerRegExp) && object.spec.operatorImageVerification.issuerRegExp.trim() != "") || + (has(object.spec.operatorImageVerification.subjectRegExp) && object.spec.operatorImageVerification.subjectRegExp.trim() != "") || + (has(object.spec.operatorImageVerification.ignoreTlog) && object.spec.operatorImageVerification.ignoreTlog == true) + ) - name: requested_below_current_version expression: >- variables.current_version_valid && @@ -374,18 +403,26 @@ spec: object.spec.profile != "Hardened" || object.spec.replicas >= 3 message: "Hardened profile requires at least 3 replicas for Raft quorum HA. Use Profile=Development for non-HA deployments." - # Confused-deputy protection: custom backup helper images run with backup credentials. + # Confused-deputy protection: CR-selected custom executables run with operator-managed identities, + # OpenBao runtime mounts, or both. Existing usehelperimages grants are accepted as a compatibility alias. - expression: >- - !has(object.spec.backup) || - !has(object.spec.backup.image) || - object.spec.backup.image == "" || - (request.operation == "UPDATE" && - oldObject != null && - has(oldObject.spec.backup) && - has(oldObject.spec.backup.image) && - oldObject.spec.backup.image == object.spec.backup.image) || - authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("usehelperimages").allowed() - message: "Users configuring custom backup helper images must be authorized to use helper image overrides on this OpenBaoCluster." + ( + (!has(object.spec.initContainer) || !has(object.spec.initContainer.image) || object.spec.initContainer.image == "") && + (!has(object.spec.backup) || !has(object.spec.backup.image) || object.spec.backup.image == "") && + (!has(object.spec.upgrade) || !has(object.spec.upgrade.image) || object.spec.upgrade.image == "") && + (!has(object.spec.upgrade) || !has(object.spec.upgrade.blueGreen) || !has(object.spec.upgrade.blueGreen.verification) || !has(object.spec.upgrade.blueGreen.verification.prePromotionHook)) && + (!has(object.spec.plugins) || object.spec.plugins.all(p, (!has(p.image) || p.image == "") && (!has(p.command) || p.command == ""))) + ) || + variables.custom_executables_authorized + message: "Users configuring CR-selected custom executables (custom init, backup, upgrade, blue/green hook, or plugin executable configuration) must be authorized to use custom executables on this OpenBaoCluster." + # Supply-chain protection: Hardened image verification may use official defaults without extra RBAC, + # but CR-selected trust roots require a separate delegation. + - expression: >- + !has(object.spec.profile) || + object.spec.profile != "Hardened" || + !(variables.has_custom_main_image_trust_roots || variables.has_custom_operator_image_trust_roots) || + variables.image_trust_roots_authorized + message: "Users configuring custom image verification trust roots in Hardened profile must be authorized to use image trust roots on this OpenBaoCluster." # Confused-deputy protection: users configuring unseal Secret credentials must be able to read that Secret. - expression: >- !has(object.spec.unseal) || diff --git a/charts/openbao-operator/templates/admission/validate-openbaorestore.yaml b/charts/openbao-operator/templates/admission/validate-openbaorestore.yaml index b1c664dc1..c1efaf255 100644 --- a/charts/openbao-operator/templates/admission/validate-openbaorestore.yaml +++ b/charts/openbao-operator/templates/admission/validate-openbaorestore.yaml @@ -43,6 +43,10 @@ spec: - name: restore_endpoint_is_in_cluster_service expression: >- variables.restore_endpoint_hostname_normalized.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.svc(\\.cluster\\.local)?$") + - name: custom_executables_authorized + expression: >- + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("usecustomexecutables").allowed() || + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("usehelperimages").allowed() validations: # API contract: restore specs are immutable after creation. - expression: >- @@ -86,15 +90,12 @@ spec: (variables.restore_endpoint_scheme == "http" && variables.restore_endpoint_is_in_cluster_service))) message: "Restore endpoint must use HTTPS or S3 scheme, unless it targets an in-cluster Service (*.svc)." # Confused-deputy protection: custom restore helper images run with restore credentials. + # Existing usehelperimages grants are accepted as a compatibility alias. - expression: >- !has(object.spec.image) || object.spec.image == "" || - (request.operation == "UPDATE" && - oldObject != null && - has(oldObject.spec.image) && - oldObject.spec.image == object.spec.image) || - authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("usehelperimages").allowed() - message: "Users configuring custom restore helper images must be authorized to use helper image overrides on the target OpenBaoCluster." + variables.custom_executables_authorized + message: "Users configuring custom restore helper images must be authorized to use custom executables on the target OpenBaoCluster." # Confused-deputy protection: users configuring restore Secret credentials must be able to read those Secrets. - expression: >- (!has(object.spec.source.target.credentialsSecretRef) || diff --git a/charts/openbao-operator/templates/rbac/aggregated-clusterroles.yaml b/charts/openbao-operator/templates/rbac/aggregated-clusterroles.yaml index b8173556b..a2d97e54c 100644 --- a/charts/openbao-operator/templates/rbac/aggregated-clusterroles.yaml +++ b/charts/openbao-operator/templates/rbac/aggregated-clusterroles.yaml @@ -60,9 +60,26 @@ rules: - openbaoclusters verbs: - get + - usecustomexecutables - usehelperimages --- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "openbao-operator.labels" . | nindent 4 }} + name: {{ include "openbao-operator.fullname" . }}-openbaocluster-image-trust-roots +rules: + - apiGroups: + - openbao.org + resources: + - openbaoclusters + verbs: + - get + - useimagetrustroots +--- + apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: diff --git a/charts/openbao-operator/templates/rbac/single-tenant-clusterrole.yaml b/charts/openbao-operator/templates/rbac/single-tenant-clusterrole.yaml index eb6969aa3..232d6c200 100644 --- a/charts/openbao-operator/templates/rbac/single-tenant-clusterrole.yaml +++ b/charts/openbao-operator/templates/rbac/single-tenant-clusterrole.yaml @@ -21,6 +21,13 @@ rules: - patch - update - watch + - apiGroups: + - openbao.org + resources: + - openbaoclusters + verbs: + - usecustomexecutables + - useimagetrustroots - apiGroups: - openbao.org resources: diff --git a/config/overlays/single-tenant-custom-identity/single_tenant_clusterrole.yaml b/config/overlays/single-tenant-custom-identity/single_tenant_clusterrole.yaml index 84462d5ca..1aa982a6e 100644 --- a/config/overlays/single-tenant-custom-identity/single_tenant_clusterrole.yaml +++ b/config/overlays/single-tenant-custom-identity/single_tenant_clusterrole.yaml @@ -24,6 +24,13 @@ rules: - delete - get - patch + - apiGroups: + - openbao.org + resources: + - openbaoclusters + verbs: + - usecustomexecutables + - useimagetrustroots - apiGroups: - openbao.org resources: diff --git a/config/overlays/single-tenant/single_tenant_clusterrole.yaml b/config/overlays/single-tenant/single_tenant_clusterrole.yaml index 84462d5ca..1aa982a6e 100644 --- a/config/overlays/single-tenant/single_tenant_clusterrole.yaml +++ b/config/overlays/single-tenant/single_tenant_clusterrole.yaml @@ -24,6 +24,13 @@ rules: - delete - get - patch + - apiGroups: + - openbao.org + resources: + - openbaoclusters + verbs: + - usecustomexecutables + - useimagetrustroots - apiGroups: - openbao.org resources: diff --git a/config/policy/openbao-restrict-provisioner-rbac.yaml b/config/policy/openbao-restrict-provisioner-rbac.yaml index 2f7498e10..06d73ff5c 100644 --- a/config/policy/openbao-restrict-provisioner-rbac.yaml +++ b/config/policy/openbao-restrict-provisioner-rbac.yaml @@ -97,6 +97,15 @@ spec: rule.verbs != null && rule.verbs.all(v, v in ['create', 'delete', 'get', 'list', 'patch', 'update', 'watch']) ) || + ( + rule.apiGroups.size() == 1 && + rule.apiGroups[0] == 'openbao.org' && + rule.resources != null && + rule.resources.size() == 1 && + rule.resources[0] == 'openbaoclusters' && + rule.verbs != null && + rule.verbs.all(v, v in ['usecustomexecutables', 'useimagetrustroots']) + ) || ( rule.apiGroups.size() == 1 && rule.apiGroups[0] == 'apps' && diff --git a/config/policy/openbao-validate-openbaocluster.yaml b/config/policy/openbao-validate-openbaocluster.yaml index e9bdb4939..1436f8a08 100644 --- a/config/policy/openbao-validate-openbaocluster.yaml +++ b/config/policy/openbao-validate-openbaocluster.yaml @@ -106,6 +106,35 @@ spec: - name: rolling_upgrade_progress_started expression: >- oldObject != null && has(oldObject.status) && has(oldObject.status.upgrade) && (oldObject.status.upgrade.currentPartition < oldObject.spec.replicas || (has(oldObject.status.upgrade.completedPods) && size(oldObject.status.upgrade.completedPods) > 0)) + - name: custom_executables_authorized + expression: >- + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("usecustomexecutables").allowed() || + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("usehelperimages").allowed() + - name: image_trust_roots_authorized + expression: >- + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("useimagetrustroots").allowed() + - name: has_custom_main_image_trust_roots + expression: >- + has(object.spec.imageVerification) && + ( + (has(object.spec.imageVerification.publicKey) && object.spec.imageVerification.publicKey.trim() != "") || + (has(object.spec.imageVerification.issuer) && object.spec.imageVerification.issuer.trim() != "") || + (has(object.spec.imageVerification.subject) && object.spec.imageVerification.subject.trim() != "") || + (has(object.spec.imageVerification.issuerRegExp) && object.spec.imageVerification.issuerRegExp.trim() != "") || + (has(object.spec.imageVerification.subjectRegExp) && object.spec.imageVerification.subjectRegExp.trim() != "") || + (has(object.spec.imageVerification.ignoreTlog) && object.spec.imageVerification.ignoreTlog == true) + ) + - name: has_custom_operator_image_trust_roots + expression: >- + has(object.spec.operatorImageVerification) && + ( + (has(object.spec.operatorImageVerification.publicKey) && object.spec.operatorImageVerification.publicKey.trim() != "") || + (has(object.spec.operatorImageVerification.issuer) && object.spec.operatorImageVerification.issuer.trim() != "") || + (has(object.spec.operatorImageVerification.subject) && object.spec.operatorImageVerification.subject.trim() != "") || + (has(object.spec.operatorImageVerification.issuerRegExp) && object.spec.operatorImageVerification.issuerRegExp.trim() != "") || + (has(object.spec.operatorImageVerification.subjectRegExp) && object.spec.operatorImageVerification.subjectRegExp.trim() != "") || + (has(object.spec.operatorImageVerification.ignoreTlog) && object.spec.operatorImageVerification.ignoreTlog == true) + ) - name: requested_below_current_version expression: >- variables.current_version_valid && @@ -371,18 +400,26 @@ spec: object.spec.profile != "Hardened" || object.spec.replicas >= 3 message: "Hardened profile requires at least 3 replicas for Raft quorum HA. Use Profile=Development for non-HA deployments." - # Confused-deputy protection: custom backup helper images run with backup credentials. + # Confused-deputy protection: CR-selected custom executables run with operator-managed identities, + # OpenBao runtime mounts, or both. Existing usehelperimages grants are accepted as a compatibility alias. - expression: >- - !has(object.spec.backup) || - !has(object.spec.backup.image) || - object.spec.backup.image == "" || - (request.operation == "UPDATE" && - oldObject != null && - has(oldObject.spec.backup) && - has(oldObject.spec.backup.image) && - oldObject.spec.backup.image == object.spec.backup.image) || - authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("usehelperimages").allowed() - message: "Users configuring custom backup helper images must be authorized to use helper image overrides on this OpenBaoCluster." + ( + (!has(object.spec.initContainer) || !has(object.spec.initContainer.image) || object.spec.initContainer.image == "") && + (!has(object.spec.backup) || !has(object.spec.backup.image) || object.spec.backup.image == "") && + (!has(object.spec.upgrade) || !has(object.spec.upgrade.image) || object.spec.upgrade.image == "") && + (!has(object.spec.upgrade) || !has(object.spec.upgrade.blueGreen) || !has(object.spec.upgrade.blueGreen.verification) || !has(object.spec.upgrade.blueGreen.verification.prePromotionHook)) && + (!has(object.spec.plugins) || object.spec.plugins.all(p, (!has(p.image) || p.image == "") && (!has(p.command) || p.command == ""))) + ) || + variables.custom_executables_authorized + message: "Users configuring CR-selected custom executables (custom init, backup, upgrade, blue/green hook, or plugin executable configuration) must be authorized to use custom executables on this OpenBaoCluster." + # Supply-chain protection: Hardened image verification may use official defaults without extra RBAC, + # but CR-selected trust roots require a separate delegation. + - expression: >- + !has(object.spec.profile) || + object.spec.profile != "Hardened" || + !(variables.has_custom_main_image_trust_roots || variables.has_custom_operator_image_trust_roots) || + variables.image_trust_roots_authorized + message: "Users configuring custom image verification trust roots in Hardened profile must be authorized to use image trust roots on this OpenBaoCluster." # Confused-deputy protection: users configuring unseal Secret credentials must be able to read that Secret. - expression: >- !has(object.spec.unseal) || diff --git a/config/policy/openbao-validate-openbaorestore.yaml b/config/policy/openbao-validate-openbaorestore.yaml index c7920afee..b4fafe31d 100644 --- a/config/policy/openbao-validate-openbaorestore.yaml +++ b/config/policy/openbao-validate-openbaorestore.yaml @@ -40,6 +40,10 @@ spec: - name: restore_endpoint_is_in_cluster_service expression: >- variables.restore_endpoint_hostname_normalized.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.svc(\\.cluster\\.local)?$") + - name: custom_executables_authorized + expression: >- + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("usecustomexecutables").allowed() || + authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("usehelperimages").allowed() validations: # API contract: restore specs are immutable after creation. - expression: >- @@ -83,15 +87,12 @@ spec: (variables.restore_endpoint_scheme == "http" && variables.restore_endpoint_is_in_cluster_service))) message: "Restore endpoint must use HTTPS or S3 scheme, unless it targets an in-cluster Service (*.svc)." # Confused-deputy protection: custom restore helper images run with restore credentials. + # Existing usehelperimages grants are accepted as a compatibility alias. - expression: >- !has(object.spec.image) || object.spec.image == "" || - (request.operation == "UPDATE" && - oldObject != null && - has(oldObject.spec.image) && - oldObject.spec.image == object.spec.image) || - authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("usehelperimages").allowed() - message: "Users configuring custom restore helper images must be authorized to use helper image overrides on the target OpenBaoCluster." + variables.custom_executables_authorized + message: "Users configuring custom restore helper images must be authorized to use custom executables on the target OpenBaoCluster." # Confused-deputy protection: users configuring restore Secret credentials must be able to read those Secrets. - expression: >- (!has(object.spec.source.target.credentialsSecretRef) || diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 55909b78e..f8c5562be 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -36,5 +36,6 @@ resources: - openbaocluster_admin_role.yaml - openbaocluster_editor_role.yaml - openbaocluster_helper_image_role.yaml + - openbaocluster_image_trust_roots_role.yaml - openbaocluster_maintenance_role.yaml - openbaocluster_viewer_role.yaml diff --git a/config/rbac/namespace_scoped_example.yaml b/config/rbac/namespace_scoped_example.yaml index 2e03bda47..06d9db8a6 100644 --- a/config/rbac/namespace_scoped_example.yaml +++ b/config/rbac/namespace_scoped_example.yaml @@ -110,15 +110,16 @@ subjects: name: team-a-break-glass # Change to actual group name apiGroup: rbac.authorization.k8s.io --- -# Example: Grant custom backup/restore helper image selection on one cluster +# Example: Grant custom executable selection on one cluster # -# This namespaced Role grants the custom `usehelperimages` verb only for a +# This namespaced Role grants the custom `usecustomexecutables` verb only for a # single OpenBaoCluster object. Grant it only to operators trusted to choose -# executor images, because those images run with backup/restore credentials. +# helper, executor, hook, or plugin executables because they run with +# operator-managed identities, OpenBao runtime mounts, or both. apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: openbaocluster-helper-image-team-a-example + name: openbaocluster-custom-executables-team-a-example namespace: team-a-prod # Change to target namespace labels: app.kubernetes.io/name: openbao-operator @@ -132,12 +133,12 @@ rules: - example-cluster # Change to target OpenBaoCluster name verbs: - get - - usehelperimages + - usecustomexecutables --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: openbaocluster-helper-image-team-a-example + name: openbaocluster-custom-executables-team-a-example namespace: team-a-prod # Change to target namespace labels: app.kubernetes.io/name: openbao-operator @@ -145,7 +146,48 @@ metadata: roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: openbaocluster-helper-image-team-a-example + name: openbaocluster-custom-executables-team-a-example +subjects: + - kind: Group + name: team-a-platform-operators # Change to actual group name + apiGroup: rbac.authorization.k8s.io +--- +# Example: Grant custom Hardened image-verification trust-root selection on one cluster +# +# This namespaced Role grants the custom `useimagetrustroots` verb only for a +# single OpenBaoCluster object. Grant it only to operators trusted to define +# which image signers are acceptable for Hardened clusters. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: openbaocluster-image-trust-roots-team-a-example + namespace: team-a-prod # Change to target namespace + labels: + app.kubernetes.io/name: openbao-operator + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: + - openbao.org + resources: + - openbaoclusters + resourceNames: + - example-cluster # Change to target OpenBaoCluster name + verbs: + - get + - useimagetrustroots +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: openbaocluster-image-trust-roots-team-a-example + namespace: team-a-prod # Change to target namespace + labels: + app.kubernetes.io/name: openbao-operator + app.kubernetes.io/managed-by: kustomize +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: openbaocluster-image-trust-roots-team-a-example subjects: - kind: Group name: team-a-platform-operators # Change to actual group name diff --git a/config/rbac/openbaocluster_helper_image_role.yaml b/config/rbac/openbaocluster_helper_image_role.yaml index b800c6275..fe98a97d5 100644 --- a/config/rbac/openbaocluster_helper_image_role.yaml +++ b/config/rbac/openbaocluster_helper_image_role.yaml @@ -1,11 +1,14 @@ # This rule is not used by the project openbao-operator itself. # It is provided to allow cluster administrators to delegate custom -# backup/restore helper image selection without granting full +# custom executable selection without granting full # OpenBaoCluster admin permissions. # # Multi-tenancy: This ClusterRole can be bound via RoleBinding for # namespace-scoped helper image permissions, or copied into a namespaced # Role with resourceNames for one explicitly trusted OpenBaoCluster. +# +# usehelperimages is retained as a compatibility alias for older Role copies. +# New delegations should use usecustomexecutables. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -21,4 +24,5 @@ rules: - openbaoclusters verbs: - get + - usecustomexecutables - usehelperimages diff --git a/config/rbac/openbaocluster_image_trust_roots_role.yaml b/config/rbac/openbaocluster_image_trust_roots_role.yaml new file mode 100644 index 000000000..0d87791b5 --- /dev/null +++ b/config/rbac/openbaocluster_image_trust_roots_role.yaml @@ -0,0 +1,24 @@ +# This rule is not used by the project openbao-operator itself. +# It is provided to allow cluster administrators to delegate custom +# Hardened image verification trust-root selection without granting full +# OpenBaoCluster admin permissions. +# +# Multi-tenancy: This ClusterRole can be bound via RoleBinding for +# namespace-scoped trust-root permissions, or copied into a namespaced +# Role with resourceNames for one explicitly trusted OpenBaoCluster. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: openbao-operator + app.kubernetes.io/managed-by: kustomize + name: openbaocluster-image-trust-roots-role +rules: + - apiGroups: + - openbao.org + resources: + - openbaoclusters + verbs: + - get + - useimagetrustroots diff --git a/config/rbac/single_tenant_clusterrole.yaml b/config/rbac/single_tenant_clusterrole.yaml index 00eec79a5..2662b620e 100644 --- a/config/rbac/single_tenant_clusterrole.yaml +++ b/config/rbac/single_tenant_clusterrole.yaml @@ -47,6 +47,13 @@ rules: - patch - update - watch + - apiGroups: + - openbao.org + resources: + - openbaoclusters + verbs: + - usecustomexecutables + - useimagetrustroots - apiGroups: - openbao.org resources: diff --git a/config/samples/RBAC/namespace_scoped_openbaocluster-rbac.yaml b/config/samples/RBAC/namespace_scoped_openbaocluster-rbac.yaml index f6088779c..b7cbea56c 100644 --- a/config/samples/RBAC/namespace_scoped_openbaocluster-rbac.yaml +++ b/config/samples/RBAC/namespace_scoped_openbaocluster-rbac.yaml @@ -81,3 +81,87 @@ subjects: - kind: Group name: platform-team # Change to actual group name apiGroup: rbac.authorization.k8s.io +--- +# Example: Grant custom executable selection on one cluster +# +# This Role grants the custom `usecustomexecutables` verb only for one +# OpenBaoCluster object. Grant it only to human or GitOps identities trusted to +# choose helper images, upgrade hooks, plugin executables, or restore images. +# Existing `usehelperimages` grants remain accepted as a compatibility alias, +# but new delegated bindings should use `usecustomexecutables`. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: openbaocluster-custom-executables-team-a + namespace: team-a-prod # Change to target namespace + labels: + app.kubernetes.io/name: openbao-operator + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: + - openbao.org + resources: + - openbaoclusters + resourceNames: + - example-cluster # Change to target OpenBaoCluster name + verbs: + - get + - usecustomexecutables +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: openbaocluster-custom-executables-team-a + namespace: team-a-prod # Change to target namespace + labels: + app.kubernetes.io/name: openbao-operator + app.kubernetes.io/managed-by: kustomize +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: openbaocluster-custom-executables-team-a +subjects: + - kind: Group + name: team-a-platform-operators # Change to actual group name + apiGroup: rbac.authorization.k8s.io +--- +# Example: Grant Hardened image-verification trust-root selection on one cluster +# +# This Role grants the custom `useimagetrustroots` verb only for one +# OpenBaoCluster object. Grant it only to identities trusted to decide which +# image signers are acceptable for Hardened clusters. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: openbaocluster-image-trust-roots-team-a + namespace: team-a-prod # Change to target namespace + labels: + app.kubernetes.io/name: openbao-operator + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: + - openbao.org + resources: + - openbaoclusters + resourceNames: + - example-cluster # Change to target OpenBaoCluster name + verbs: + - get + - useimagetrustroots +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: openbaocluster-image-trust-roots-team-a + namespace: team-a-prod # Change to target namespace + labels: + app.kubernetes.io/name: openbao-operator + app.kubernetes.io/managed-by: kustomize +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: openbaocluster-image-trust-roots-team-a +subjects: + - kind: Group + name: team-a-supply-chain-approvers # Change to actual group name + apiGroup: rbac.authorization.k8s.io diff --git a/config/samples/basics/openbao_v1alpha1_openbaorestore.yaml b/config/samples/basics/openbao_v1alpha1_openbaorestore.yaml index cf9f231c7..779202e36 100644 --- a/config/samples/basics/openbao_v1alpha1_openbaorestore.yaml +++ b/config/samples/basics/openbao_v1alpha1_openbaorestore.yaml @@ -18,4 +18,5 @@ spec: # jwtAuthRole: restore # tokenSecretRef: # name: restore-token + # Custom restore images require `usecustomexecutables` on the target OpenBaoCluster. # image: openbao-backup:0.1.0 diff --git a/config/samples/integrations/openbao_v1alpha1_openbaocluster_gateway.yaml b/config/samples/integrations/openbao_v1alpha1_openbaocluster_gateway.yaml index 459e68be1..63f34a1fa 100644 --- a/config/samples/integrations/openbao_v1alpha1_openbaocluster_gateway.yaml +++ b/config/samples/integrations/openbao_v1alpha1_openbaocluster_gateway.yaml @@ -50,6 +50,7 @@ spec: enabled: true # For k3d clusters, use the k3d registry path: k3d-registry.localhost:5000/openbao-init:dev # For other environments, use a fully qualified image reference + # Custom helper images require `usecustomexecutables` on this OpenBaoCluster. image: "k3d-registry.localhost:5000/openbao-init:dev-0.0.1" tls: enabled: true diff --git a/config/samples/production/openbao_v1alpha1_openbaocluster_acme.yaml b/config/samples/production/openbao_v1alpha1_openbaocluster_acme.yaml index 805a00ff1..40de8529f 100644 --- a/config/samples/production/openbao_v1alpha1_openbaocluster_acme.yaml +++ b/config/samples/production/openbao_v1alpha1_openbaocluster_acme.yaml @@ -116,6 +116,7 @@ spec: capabilities = ["create", "read", "update", "delete", "list", "sudo"] } # Image verification for supply chain security + # Custom Hardened trust roots require `useimagetrustroots` on this OpenBaoCluster. imageVerification: enabled: true issuer: "https://token.actions.githubusercontent.com" @@ -130,6 +131,7 @@ spec: # Scheduled backups with JWT Auth backup: schedule: "0 3 * * *" # Daily at 3 AM + # Custom backup images require `usecustomexecutables` on this OpenBaoCluster. image: "openbao-backup:latest" target: endpoint: "https://s3.amazonaws.com" diff --git a/config/samples/production/openbao_v1alpha1_openbaocluster_backup.yaml b/config/samples/production/openbao_v1alpha1_openbaocluster_backup.yaml index a216835d2..bff97f914 100644 --- a/config/samples/production/openbao_v1alpha1_openbaocluster_backup.yaml +++ b/config/samples/production/openbao_v1alpha1_openbaocluster_backup.yaml @@ -85,6 +85,7 @@ spec: # Scheduled backups configuration backup: schedule: "0 3 * * *" # Daily at 3 AM + # Custom backup images require `usecustomexecutables` on this OpenBaoCluster. image: "openbao-backup:0.1.0" # Default, can be omitted target: # Storage provider: s3 (default), gcs, or azure diff --git a/config/samples/production/openbao_v1alpha1_openbaocluster_full.yaml b/config/samples/production/openbao_v1alpha1_openbaocluster_full.yaml index cc0006614..89b50bab3 100644 --- a/config/samples/production/openbao_v1alpha1_openbaocluster_full.yaml +++ b/config/samples/production/openbao_v1alpha1_openbaocluster_full.yaml @@ -155,6 +155,7 @@ spec: enabled: true # For k3d clusters, use the k3d registry path: k3d-registry.localhost:5000/openbao-init:dev # For other environments, use a fully qualified image reference + # Custom helper images require `usecustomexecutables` on this OpenBaoCluster. image: "k3d-registry.localhost:5000/openbao-init:1.2.4" network: apiServerCIDR: "10.43.0.0/16" @@ -251,6 +252,7 @@ spec: # Backup image built from Dockerfile.backup in this repository. # For k3d clusters, push to the local registry; for other environments, # use a fully qualified image reference. + # Custom backup images require `usecustomexecutables` on this OpenBaoCluster. image: "k3d-registry.localhost:5000/openbao-backup:1.1.0" target: # RustFS S3 API endpoint inside the cluster. diff --git a/config/samples/production/openbao_v1alpha1_openbaocluster_hardened.yaml b/config/samples/production/openbao_v1alpha1_openbaocluster_hardened.yaml index 04daa7366..17479785c 100644 --- a/config/samples/production/openbao_v1alpha1_openbaocluster_hardened.yaml +++ b/config/samples/production/openbao_v1alpha1_openbaocluster_hardened.yaml @@ -104,6 +104,7 @@ spec: capabilities = ["create", "read", "update", "delete", "list", "sudo"] } # Image verification for supply chain security + # Custom Hardened trust roots require `useimagetrustroots` on this OpenBaoCluster. imageVerification: enabled: true issuer: "https://token.actions.githubusercontent.com" @@ -119,6 +120,7 @@ spec: # Scheduled backups with JWT Auth (preferred over static tokens) backup: schedule: "0 3 * * *" # Daily at 3 AM + # Custom backup images require `usecustomexecutables` on this OpenBaoCluster. image: "openbao-backup:latest" target: endpoint: "https://s3.amazonaws.com" diff --git a/docs/security/infrastructure/admission-policies.md b/docs/security/infrastructure/admission-policies.md index 529dd4f6d..b12d9d4ac 100644 --- a/docs/security/infrastructure/admission-policies.md +++ b/docs/security/infrastructure/admission-policies.md @@ -46,7 +46,7 @@ description: How ValidatingAdmissionPolicy guardrails enforce managed-resource o { cells: [ 'Spec validation', - 'Rejects invalid `OpenBaoCluster`, `OpenBaoTenant`, and `OpenBaoRestore` objects before they persist.', + 'Rejects invalid `OpenBaoCluster`, `OpenBaoTenant`, and `OpenBaoRestore` objects before they persist, including CR-selected custom executable and trust-root controls that require delegated RBAC.', '`openbao-validate-openbaocluster`, `openbao-validate-openbao-tenant`, `openbao-validate-openbaorestore`.', ], }, @@ -210,6 +210,7 @@ Admission policy is one of the reasons the operator can separate user intent fro - user-owned surfaces stay in the CR where customization is supported - operator-owned networking, seal, listener identity, and lifecycle wiring stay protected +- CR-selected helper images, hooks, plugin executables, restore images, and Hardened image-verification trust roots require explicit delegated RBAC before they can be persisted - unsafe or drifted changes are rejected before they have to be repaired later +## CR author delegation + +Some `OpenBaoCluster` and `OpenBaoRestore` fields cause the operator to run CR-selected custom executables or accept CR-selected trust roots with operator-managed identities. Tenant editors can still create and update ordinary cluster intent, but these fields need a narrower delegated verb on the target `OpenBaoCluster`. + + + + + +The admission policy checks these delegated verbs on create and update whenever the dangerous field is present, not only when that field changes. For example, a GitOps controller that manages an `OpenBaoCluster` with a custom backup image needs `usecustomexecutables` for future updates to unrelated fields too. + + + ## What the RBAC model guarantees + + +In the `Hardened` profile, official image-verification defaults can be used without extra RBAC. If a CR author sets custom trust material such as `publicKey`, issuer or subject matchers, regexp matchers, or `ignoreTlog`, that identity also needs the `useimagetrustroots` verb on the target `OpenBaoCluster`. + + + ## Verify published release artifacts + + +Setting `spec.initContainer.image`, `spec.backup.image`, or `spec.upgrade.image` chooses executables that run with operator-managed mounts, credentials, or job identities. The human or GitOps identity applying that `OpenBaoCluster` needs the `usecustomexecutables` verb on the cluster. Existing `usehelperimages` grants continue to work as a compatibility alias. + + + ## Override images per cluster + + + +`spec.plugins[].image` and `spec.plugins[].command` select custom executables for the OpenBao runtime. The identity applying an `OpenBaoCluster` with either field needs `usecustomexecutables` on that cluster, even when a later update changes only an unrelated field. + diff --git a/docs/user-guide/openbaocluster/operations/backups.md b/docs/user-guide/openbaocluster/operations/backups.md index 169326017..fed2e1db8 100644 --- a/docs/user-guide/openbaocluster/operations/backups.md +++ b/docs/user-guide/openbaocluster/operations/backups.md @@ -76,6 +76,12 @@ Check `CloudUnsealIdentityReady` for the main Pods and `BackupConfigurationReady + + +Setting `spec.backup.image` selects the executable used by backup Jobs. The identity applying that `OpenBaoCluster` needs `usecustomexecutables` on the cluster; existing `usehelperimages` bindings remain accepted for compatibility. + + + ## First successful backup path + + +Setting `spec.upgrade.image` or a blue-green `prePromotionHook` selects custom executables for the upgrade path. The identity applying that `OpenBaoCluster` needs `usecustomexecutables` on the cluster; existing `usehelperimages` bindings remain accepted for compatibility. + + + ## Use the default rolling path Use `RollingUpdate` when you want the lowest operational complexity and you do not need a second revision running in parallel. diff --git a/docs/user-guide/openbaorestore/restore.md b/docs/user-guide/openbaorestore/restore.md index d783cd7a0..5d4c21378 100644 --- a/docs/user-guide/openbaorestore/restore.md +++ b/docs/user-guide/openbaorestore/restore.md @@ -113,6 +113,12 @@ The `OpenBaoRestore`, the target `OpenBaoCluster`, and any referenced token Secr + + +If `OpenBaoRestore.spec.image` is set, the identity applying the restore request needs `usecustomexecutables` on the target `OpenBaoCluster`. The admission check is tied to the target cluster because the restore image runs with restore credentials for that cluster. + + + If the target cluster already uses `spec.selfInit.oidc.enabled=true`, start with `jwtAuthRole: openbao-operator-restore` and the same object-storage provider shape you already validated in the backup flow. diff --git a/hack/helmchart/main.go b/hack/helmchart/main.go index 65ac52fcb..a188f0ec5 100644 --- a/hack/helmchart/main.go +++ b/hack/helmchart/main.go @@ -664,9 +664,9 @@ subjects: // syncAggregatedRBAC syncs aggregated ClusterRoles. func syncAggregatedRBAC(opts options) error { - parts := make([]string, 0, 5) // 4 cluster roles + 1 tenant role + parts := make([]string, 0, 6) // 5 cluster roles + 1 tenant role - // OpenBaoCluster admin/editor/viewer and helper-image delegation roles. + // OpenBaoCluster admin/editor/viewer and delegated dangerous-control roles. for _, role := range []struct { filename string nameSuffix string @@ -674,6 +674,7 @@ func syncAggregatedRBAC(opts options) error { {filename: "openbaocluster_admin_role.yaml", nameSuffix: "openbaocluster-admin"}, {filename: "openbaocluster_editor_role.yaml", nameSuffix: "openbaocluster-editor"}, {filename: "openbaocluster_helper_image_role.yaml", nameSuffix: "openbaocluster-helper-image"}, + {filename: "openbaocluster_image_trust_roots_role.yaml", nameSuffix: "openbaocluster-image-trust-roots"}, {filename: "openbaocluster_viewer_role.yaml", nameSuffix: "openbaocluster-viewer"}, } { filename := role.filename diff --git a/hack/helmchart/main_test.go b/hack/helmchart/main_test.go index 8ad25be75..988db35be 100644 --- a/hack/helmchart/main_test.go +++ b/hack/helmchart/main_test.go @@ -67,7 +67,7 @@ rules: } } -func TestSyncAggregatedRBAC_IncludesHelperImageDelegationRole(t *testing.T) { +func TestSyncAggregatedRBAC_IncludesDangerousControlDelegationRoles(t *testing.T) { inputDir := t.TempDir() outputDir := t.TempDir() @@ -96,7 +96,19 @@ func TestSyncAggregatedRBAC_IncludesHelperImageDelegationRole(t *testing.T) { writeRole("openbaocluster_admin_role.yaml", "openbaocluster-admin-role", "*") writeRole("openbaocluster_editor_role.yaml", "openbaocluster-editor-role", "create", "update") - writeRole("openbaocluster_helper_image_role.yaml", "openbaocluster-helper-image-role", "get", "usehelperimages") + writeRole( + "openbaocluster_helper_image_role.yaml", + "openbaocluster-helper-image-role", + "get", + "usecustomexecutables", + "usehelperimages", + ) + writeRole( + "openbaocluster_image_trust_roots_role.yaml", + "openbaocluster-image-trust-roots-role", + "get", + "useimagetrustroots", + ) writeRole("openbaocluster_viewer_role.yaml", "openbaocluster-viewer-role", "get", "list") writeRole("openbaotenant_editor_role.yaml", "openbaotenant-editor-role", "create", "update") @@ -111,7 +123,10 @@ func TestSyncAggregatedRBAC_IncludesHelperImageDelegationRole(t *testing.T) { output := string(got) for _, want := range []string{ `{{ include "openbao-operator.fullname" . }}-openbaocluster-helper-image`, + `{{ include "openbao-operator.fullname" . }}-openbaocluster-image-trust-roots`, + "usecustomexecutables", "usehelperimages", + "useimagetrustroots", } { if !strings.Contains(output, want) { t.Fatalf("generated aggregated RBAC missing %q:\n%s", want, output) diff --git a/internal/service/provisioner/rbac.go b/internal/service/provisioner/rbac.go index 4029706cc..13870d1b9 100644 --- a/internal/service/provisioner/rbac.go +++ b/internal/service/provisioner/rbac.go @@ -10,6 +10,7 @@ import ( var ( verbsReadOnly = []string{"get", "list", "watch"} verbsManage = []string{"create", "delete", "get", "list", "patch", "update", "watch"} + verbsClusterDelegation = []string{"usecustomexecutables", "useimagetrustroots"} verbsEventWrite = []string{"create", "patch"} verbsPodManage = []string{"delete", "get", "list", "patch", "update", "watch"} verbsServiceMonitorManage = []string{"create", "delete", "get", "patch"} @@ -56,6 +57,11 @@ func GenerateTenantRole(namespace string) *rbacv1.Role { Resources: []string{"openbaoclusters", "openbaoclusters/status", "openbaoclusters/finalizers"}, Verbs: cloneStrings(verbsManage), }, + { + APIGroups: []string{"openbao.org"}, + Resources: []string{"openbaoclusters"}, + Verbs: cloneStrings(verbsClusterDelegation), + }, { APIGroups: []string{"openbao.org"}, Resources: []string{"openbaorestores", "openbaorestores/status", "openbaorestores/finalizers"}, diff --git a/internal/service/provisioner/rbac_test.go b/internal/service/provisioner/rbac_test.go index e44670c41..1a1e0e3ee 100644 --- a/internal/service/provisioner/rbac_test.go +++ b/internal/service/provisioner/rbac_test.go @@ -20,19 +20,19 @@ func TestGenerateTenantRole(t *testing.T) { name: "default namespace", namespace: "default", wantName: TenantRoleName, - wantRules: 16, // Expected number of PolicyRules + wantRules: 17, // Expected number of PolicyRules }, { name: "custom namespace", namespace: "tenant-1", wantName: TenantRoleName, - wantRules: 16, + wantRules: 17, }, { name: "namespace with special characters", namespace: "my-namespace-123", wantName: TenantRoleName, - wantRules: 16, + wantRules: 17, }, } @@ -71,6 +71,7 @@ func TestGenerateTenantRole(t *testing.T) { // Verify key rules exist hasOpenBaoClusterRule := false + hasOpenBaoClusterDelegationRule := false hasStatefulSetRule := false hasPodRule := false hasEventsK8sRule := false @@ -87,6 +88,14 @@ func TestGenerateTenantRole(t *testing.T) { hasOpenBaoClusterRule = true } + if slices.Contains(rule.APIGroups, "openbao.org") && + len(rule.Resources) == 1 && + slices.Contains(rule.Resources, "openbaoclusters") && + slices.Contains(rule.Verbs, "usecustomexecutables") && + slices.Contains(rule.Verbs, "useimagetrustroots") { + hasOpenBaoClusterDelegationRule = true + } + // Check for StatefulSet rule (uses commonVerbs, not "*") if slices.Contains(rule.APIGroups, "apps") && slices.Contains(rule.Resources, "statefulsets") && @@ -139,6 +148,9 @@ func TestGenerateTenantRole(t *testing.T) { if !hasOpenBaoClusterRule { t.Error("GenerateTenantRole() missing OpenBaoCluster rule") } + if !hasOpenBaoClusterDelegationRule { + t.Error("GenerateTenantRole() missing OpenBaoCluster controller delegation rule") + } if !hasStatefulSetRule { t.Error("GenerateTenantRole() missing StatefulSet rule") } diff --git a/test/e2e/Security_Guardrails_test.go b/test/e2e/Security_Guardrails_test.go index a0cb5bda5..559890c0d 100644 --- a/test/e2e/Security_Guardrails_test.go +++ b/test/e2e/Security_Guardrails_test.go @@ -762,6 +762,19 @@ var _ = Describe("Security Guardrails", Label("security", "critical"), Ordered, Resources: []string{"openbaoclusters", "openbaorestores"}, Verbs: []string{"create"}, }, + { + APIGroups: []string{"openbao.org"}, + Resources: []string{"openbaoclusters"}, + ResourceNames: []string{ + "valid-structured-config", + "invalid-hardened", + "transit-authz-denied", + "transit-authz-allowed", + "backup-authz-denied", + "backup-authz-allowed", + }, + Verbs: []string{"usecustomexecutables"}, + }, }, } diff --git a/test/integration/crd_contract_test.go b/test/integration/crd_contract_test.go index 65de802ce..b1a984d25 100644 --- a/test/integration/crd_contract_test.go +++ b/test/integration/crd_contract_test.go @@ -23,6 +23,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" openbaov1alpha1 "github.com/dc-tec/openbao-operator/api/v1alpha1" + provisionerpkg "github.com/dc-tec/openbao-operator/internal/service/provisioner" ) func requireInvalidRequest(t *testing.T, err error) { @@ -102,7 +103,7 @@ func grantTenantOpenBaoWriteAccess(t *testing.T, namespace, username string) { } } -func grantClusterHelperImageAccess(t *testing.T, namespace, clusterName, username string) { +func grantClusterOpenBaoVerbs(t *testing.T, namespace, clusterName, username, roleName string, verbs ...string) { t.Helper() role := &rbacv1.Role{ @@ -111,7 +112,7 @@ func grantClusterHelperImageAccess(t *testing.T, namespace, clusterName, usernam Kind: "Role", }, ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-helper-image-access", + Name: roleName, Namespace: namespace, }, Rules: []rbacv1.PolicyRule{ @@ -119,12 +120,12 @@ func grantClusterHelperImageAccess(t *testing.T, namespace, clusterName, usernam APIGroups: []string{"openbao.org"}, Resources: []string{"openbaoclusters"}, ResourceNames: []string{clusterName}, - Verbs: []string{"get", "usehelperimages"}, + Verbs: append([]string{"get"}, verbs...), }, }, } if err := k8sClient.Create(ctx, role); err != nil { - t.Fatalf("create helper image role: %v", err) + t.Fatalf("create delegated OpenBao role: %v", err) } binding := &rbacv1.RoleBinding{ @@ -133,7 +134,7 @@ func grantClusterHelperImageAccess(t *testing.T, namespace, clusterName, usernam Kind: "RoleBinding", }, ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-helper-image-access-binding", + Name: roleName + "-binding", Namespace: namespace, }, RoleRef: rbacv1.RoleRef{ @@ -150,10 +151,25 @@ func grantClusterHelperImageAccess(t *testing.T, namespace, clusterName, usernam }, } if err := k8sClient.Create(ctx, binding); err != nil { - t.Fatalf("create helper image rolebinding: %v", err) + t.Fatalf("create delegated OpenBao rolebinding: %v", err) } } +func grantClusterHelperImageAccess(t *testing.T, namespace, clusterName, username string) { + t.Helper() + grantClusterOpenBaoVerbs(t, namespace, clusterName, username, "cluster-helper-image-access", "usehelperimages") +} + +func grantClusterCustomExecutablesAccess(t *testing.T, namespace, clusterName, username string) { + t.Helper() + grantClusterOpenBaoVerbs(t, namespace, clusterName, username, "cluster-custom-executables-access", "usecustomexecutables") +} + +func grantClusterImageTrustRootsAccess(t *testing.T, namespace, clusterName, username string) { + t.Helper() + grantClusterOpenBaoVerbs(t, namespace, clusterName, username, "cluster-image-trust-roots-access", "useimagetrustroots") +} + func waitForOpenBaoRestoreAdmissionPolicies(t *testing.T, namespace string) { t.Helper() @@ -394,7 +410,7 @@ func TestVAP_OpenBaoCluster_RequiresTrustedIngressPeersForManagedIngress(t *test } } -func TestVAP_OpenBaoCluster_DeniesCustomBackupImageWithoutHelperImageVerb(t *testing.T) { +func TestVAP_OpenBaoCluster_DeniesCustomBackupImageWithoutCustomExecutablesVerb(t *testing.T) { namespace := newTestNamespace(t) waitForOpenBaoClusterAdmissionPolicies(t, namespace) @@ -403,6 +419,7 @@ func TestVAP_OpenBaoCluster_DeniesCustomBackupImageWithoutHelperImageVerb(t *tes tenantClient := newImpersonatedClient(t, username) cluster := newMinimalClusterObj(namespace, "cluster-custom-backup-image-denied") + cluster.Spec.InitContainer = nil cluster.Spec.Backup = &openbaov1alpha1.BackupSchedule{ Schedule: "0 0 * * *", Image: "ghcr.io/attacker/backup-exfil:latest", @@ -416,16 +433,17 @@ func TestVAP_OpenBaoCluster_DeniesCustomBackupImageWithoutHelperImageVerb(t *tes err := tenantClient.Create(ctx, cluster) requireAdmissionDenied(t, err) - if !strings.Contains(err.Error(), "custom backup helper images") { + if !strings.Contains(err.Error(), "CR-selected custom executables") { t.Fatalf("unexpected error message: %v", err) } } -func TestVAP_OpenBaoCluster_DeniesBackupImageChangeWithoutHelperImageVerb(t *testing.T) { +func TestVAP_OpenBaoCluster_DeniesBackupImageChangeWithoutCustomExecutablesVerb(t *testing.T) { namespace := newTestNamespace(t) waitForOpenBaoClusterAdmissionPolicies(t, namespace) cluster := newMinimalClusterObj(namespace, "cluster-custom-backup-image-update-denied") + cluster.Spec.InitContainer = nil if err := k8sClient.Create(ctx, cluster); err != nil { t.Fatalf("create OpenBaoCluster: %v", err) } @@ -453,7 +471,7 @@ func TestVAP_OpenBaoCluster_DeniesBackupImageChangeWithoutHelperImageVerb(t *tes err := tenantClient.Patch(ctx, &latest, client.MergeFrom(original)) requireAdmissionDenied(t, err) - if !strings.Contains(err.Error(), "custom backup helper images") { + if !strings.Contains(err.Error(), "CR-selected custom executables") { t.Fatalf("unexpected error message: %v", err) } } @@ -469,6 +487,7 @@ func TestVAP_OpenBaoCluster_AllowsCustomBackupImageWithHelperImageVerb(t *testin tenantClient := newImpersonatedClient(t, username) cluster := newMinimalClusterObj(namespace, clusterName) + cluster.Spec.InitContainer = nil cluster.Spec.Backup = &openbaov1alpha1.BackupSchedule{ Schedule: "0 0 * * *", Image: "ghcr.io/platform/backup-helper:1.2.3", @@ -485,11 +504,12 @@ func TestVAP_OpenBaoCluster_AllowsCustomBackupImageWithHelperImageVerb(t *testin } } -func TestVAP_OpenBaoCluster_AllowsUnchangedCustomBackupImageWithoutHelperImageVerb(t *testing.T) { +func TestVAP_OpenBaoCluster_DeniesUnchangedCustomBackupImageWithoutCustomExecutablesVerb(t *testing.T) { namespace := newTestNamespace(t) waitForOpenBaoClusterAdmissionPolicies(t, namespace) cluster := newMinimalClusterObj(namespace, "cluster-custom-backup-image-unchanged") + cluster.Spec.InitContainer = nil cluster.Spec.Backup = &openbaov1alpha1.BackupSchedule{ Schedule: "0 0 * * *", Image: "ghcr.io/platform/backup-helper:1.2.3", @@ -516,8 +536,194 @@ func TestVAP_OpenBaoCluster_AllowsUnchangedCustomBackupImageWithoutHelperImageVe original := latest.DeepCopy() latest.Spec.Backup.Schedule = "0 1 * * *" - if err := tenantClient.Patch(ctx, &latest, client.MergeFrom(original)); err != nil { - t.Fatalf("expected unchanged custom backup helper image update to succeed, got: %v", err) + err := tenantClient.Patch(ctx, &latest, client.MergeFrom(original)) + requireAdmissionDenied(t, err) + if !strings.Contains(err.Error(), "CR-selected custom executables") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestVAP_OpenBaoCluster_DeniesCustomExecutableFieldsWithoutDelegatedVerb(t *testing.T) { + namespace := newTestNamespace(t) + waitForOpenBaoClusterAdmissionPolicies(t, namespace) + + username := "custom-executables-editor" + grantTenantOpenBaoWriteAccess(t, namespace, username) + tenantClient := newImpersonatedClient(t, username) + + tests := []struct { + name string + configure func(*openbaov1alpha1.OpenBaoCluster) + }{ + { + name: "custom-init-image", + configure: func(cluster *openbaov1alpha1.OpenBaoCluster) { + cluster.Spec.InitContainer = &openbaov1alpha1.InitContainerConfig{ + Enabled: true, + Image: "ghcr.io/attacker/openbao-init:latest", + } + }, + }, + { + name: "custom-upgrade-image", + configure: func(cluster *openbaov1alpha1.OpenBaoCluster) { + cluster.Spec.Upgrade = &openbaov1alpha1.UpgradeConfig{ + Image: "ghcr.io/attacker/openbao-upgrade:latest", + } + }, + }, + { + name: "bluegreen-hook", + configure: func(cluster *openbaov1alpha1.OpenBaoCluster) { + cluster.Spec.Upgrade = &openbaov1alpha1.UpgradeConfig{ + Strategy: openbaov1alpha1.UpdateStrategyBlueGreen, + BlueGreen: &openbaov1alpha1.BlueGreenConfig{ + Verification: &openbaov1alpha1.VerificationConfig{ + PrePromotionHook: &openbaov1alpha1.ValidationHookConfig{ + Image: "ghcr.io/attacker/validation-hook:latest", + }, + }, + }, + } + }, + }, + { + name: "plugin-image", + configure: func(cluster *openbaov1alpha1.OpenBaoCluster) { + cluster.Spec.Plugins = []openbaov1alpha1.Plugin{ + newTestPluginWithImage("ghcr.io/attacker/openbao-plugin:latest"), + } + }, + }, + { + name: "plugin-command", + configure: func(cluster *openbaov1alpha1.OpenBaoCluster) { + plugin := newTestPluginWithImage("") + plugin.Command = "attacker-plugin" + cluster.Spec.Plugins = []openbaov1alpha1.Plugin{plugin} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cluster := newMinimalClusterObj(namespace, "cluster-custom-executables-"+tt.name) + cluster.Spec.InitContainer = nil + tt.configure(cluster) + + err := tenantClient.Create(ctx, cluster) + requireAdmissionDenied(t, err) + if !strings.Contains(err.Error(), "CR-selected custom executables") { + t.Fatalf("unexpected error message: %v", err) + } + }) + } +} + +func TestVAP_OpenBaoCluster_AllowsCustomExecutableFieldsWithDelegatedVerb(t *testing.T) { + namespace := newTestNamespace(t) + waitForOpenBaoClusterAdmissionPolicies(t, namespace) + + username := "custom-executables-delegate" + clusterName := "cluster-custom-executables-allowed" + grantTenantOpenBaoWriteAccess(t, namespace, username) + grantClusterCustomExecutablesAccess(t, namespace, clusterName, username) + tenantClient := newImpersonatedClient(t, username) + + cluster := newMinimalClusterObj(namespace, clusterName) + cluster.Spec.InitContainer = &openbaov1alpha1.InitContainerConfig{ + Enabled: true, + Image: "ghcr.io/platform/openbao-init:1.2.3", + } + cluster.Spec.Backup = &openbaov1alpha1.BackupSchedule{ + Schedule: "0 0 * * *", + Image: "ghcr.io/platform/openbao-backup:1.2.3", + JWTAuthRole: "backup-role", + Target: openbaov1alpha1.BackupTarget{ + Provider: "s3", + Endpoint: "https://objectstore.example.com", + Bucket: testBackupBucket, + }, + } + cluster.Spec.Upgrade = &openbaov1alpha1.UpgradeConfig{ + Image: "ghcr.io/platform/openbao-upgrade:1.2.3", + Strategy: openbaov1alpha1.UpdateStrategyBlueGreen, + BlueGreen: &openbaov1alpha1.BlueGreenConfig{ + Verification: &openbaov1alpha1.VerificationConfig{ + PrePromotionHook: &openbaov1alpha1.ValidationHookConfig{ + Image: "ghcr.io/platform/openbao-validation-hook:1.2.3", + }, + }, + }, + } + cluster.Spec.Plugins = []openbaov1alpha1.Plugin{ + newTestPluginWithImage("ghcr.io/platform/openbao-plugin:1.2.3"), + } + + if err := tenantClient.Create(ctx, cluster); err != nil { + t.Fatalf("expected custom-executables-authorized OpenBaoCluster create to succeed, got: %v", err) + } +} + +func TestVAP_OpenBaoCluster_AllowsControllerMetadataPatchWithTenantDelegation(t *testing.T) { + namespace := newTestNamespace(t) + ensureProvisionerRBACApplied(t) + waitForOpenBaoClusterAdmissionPolicies(t, namespace) + + provisionerClient := newImpersonatedClient(t, provisionerUsername) + applyClientObject(t, provisionerClient, provisionerpkg.GenerateTenantRole(namespace)) + applyClientObject(t, provisionerClient, provisionerpkg.GenerateTenantRoleBinding( + namespace, + provisionerpkg.OperatorServiceAccount{ + Name: testControllerSAName, + Namespace: testDefaultOperatorNS, + }, + )) + + clusterName := "cluster-controller-metadata-patch" + setupUsername := "controller-metadata-setup" + grantTenantOpenBaoWriteAccess(t, namespace, setupUsername) + grantClusterCustomExecutablesAccess(t, namespace, clusterName, setupUsername) + grantClusterImageTrustRootsAccess(t, namespace, clusterName, setupUsername) + setupClient := newImpersonatedClient(t, setupUsername) + + cluster := newValidHardenedAdmissionCluster(namespace, clusterName) + cluster.Spec.InitContainer = &openbaov1alpha1.InitContainerConfig{ + Enabled: true, + Image: "ghcr.io/platform/openbao-init:1.2.3", + } + cluster.Spec.ImageVerification = &openbaov1alpha1.ImageVerificationConfig{ + Enabled: true, + FailurePolicy: "Block", + IssuerRegExp: "^https://issuer.example.com$", + SubjectRegExp: "^https://github.com/example/repo/.github/workflows/release.yml@refs/tags/.+$", + } + + if err := setupClient.Create(ctx, cluster); err != nil { + t.Fatalf("create setup OpenBaoCluster: %v", err) + } + + controllerClient := newImpersonatedClient(t, controllerUsername) + var latest openbaov1alpha1.OpenBaoCluster + if err := controllerClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: clusterName}, &latest); err != nil { + t.Fatalf("controller get OpenBaoCluster: %v", err) + } + original := latest.DeepCopy() + latest.Finalizers = append(latest.Finalizers, openbaov1alpha1.OpenBaoClusterFinalizer) + + if err := controllerClient.Patch(ctx, &latest, client.MergeFrom(original)); err != nil { + t.Fatalf("controller metadata patch with generated tenant delegation should succeed, got: %v", err) + } +} + +func newTestPluginWithImage(image string) openbaov1alpha1.Plugin { + return openbaov1alpha1.Plugin{ + Type: "secret", + Name: "test-plugin", + Image: image, + Version: "1.2.3", + BinaryName: "test-plugin", + SHA256Sum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } } @@ -715,6 +921,54 @@ func TestVAP_OpenBaoCluster_AllowsHardenedOfficialImageVerificationDefaults(t *t } } +func TestVAP_OpenBaoCluster_DeniesHardenedCustomImageTrustRootsWithoutDelegatedVerb(t *testing.T) { + namespace := newTestNamespace(t) + waitForOpenBaoClusterAdmissionPolicies(t, namespace) + + username := "image-trust-root-editor" + grantTenantOpenBaoWriteAccess(t, namespace, username) + tenantClient := newImpersonatedClient(t, username) + + cluster := newValidHardenedAdmissionCluster(namespace, "cluster-hardened-custom-trust-root-denied") + cluster.Spec.InitContainer = nil + cluster.Spec.ImageVerification = &openbaov1alpha1.ImageVerificationConfig{ + Enabled: true, + FailurePolicy: "Block", + IssuerRegExp: "^https://issuer.example.com$", + SubjectRegExp: "^https://github.com/example/repo/.github/workflows/release.yml@refs/tags/.+$", + IgnoreTlog: true, + } + + err := tenantClient.Create(ctx, cluster) + requireAdmissionDenied(t, err) + if !strings.Contains(err.Error(), "custom image verification trust roots") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestVAP_OpenBaoCluster_AllowsHardenedCustomImageTrustRootsWithDelegatedVerb(t *testing.T) { + namespace := newTestNamespace(t) + waitForOpenBaoClusterAdmissionPolicies(t, namespace) + + username := "image-trust-root-delegate" + clusterName := "cluster-hardened-custom-trust-root-allowed" + grantTenantOpenBaoWriteAccess(t, namespace, username) + grantClusterImageTrustRootsAccess(t, namespace, clusterName, username) + tenantClient := newImpersonatedClient(t, username) + + cluster := newValidHardenedAdmissionCluster(namespace, clusterName) + cluster.Spec.InitContainer = nil + cluster.Spec.OperatorImageVerification = &openbaov1alpha1.ImageVerificationConfig{ + Enabled: true, + FailurePolicy: "Block", + PublicKey: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n-----END PUBLIC KEY-----", + } + + if err := tenantClient.Create(ctx, cluster); err != nil { + t.Fatalf("expected image-trust-root-authorized OpenBaoCluster create to succeed, got: %v", err) + } +} + func TestVAP_OpenBaoCluster_RejectsHardenedWeakeningSecurityContext(t *testing.T) { namespace := newTestNamespace(t) waitForOpenBaoClusterAdmissionPolicies(t, namespace) @@ -1130,7 +1384,7 @@ func TestVAP_OpenBaoRestore_AllowsCustomImageWithHelperImageVerb(t *testing.T) { } } -func TestVAP_OpenBaoRestore_AllowsUnchangedCustomImageUpdateWithoutHelperImageVerb(t *testing.T) { +func TestVAP_OpenBaoRestore_DeniesUnchangedCustomImageUpdateWithoutCustomExecutablesVerb(t *testing.T) { namespace := newTestNamespace(t) waitForOpenBaoRestoreAdmissionPolicies(t, namespace) @@ -1171,8 +1425,10 @@ func TestVAP_OpenBaoRestore_AllowsUnchangedCustomImageUpdateWithoutHelperImageVe original := latest.DeepCopy() latest.Annotations = map[string]string{"openbao.org/test": "metadata-update"} - if err := tenantClient.Patch(ctx, &latest, client.MergeFrom(original)); err != nil { - t.Fatalf("expected unchanged custom restore helper image update to succeed, got: %v", err) + err := tenantClient.Patch(ctx, &latest, client.MergeFrom(original)) + requireAdmissionDenied(t, err) + if !strings.Contains(err.Error(), "custom restore helper images") { + t.Fatalf("unexpected error message: %v", err) } } diff --git a/test/integration/kustomize_contract_test.go b/test/integration/kustomize_contract_test.go index 2c8c56bf9..b9c5d41cc 100644 --- a/test/integration/kustomize_contract_test.go +++ b/test/integration/kustomize_contract_test.go @@ -378,7 +378,8 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T var foundSecretAuthorizer bool var foundBackupSecretAuthorizer bool var foundServiceMonitorSecretAuthorizer bool - var foundBackupHelperImageAuthorizer bool + var foundCustomExecutablesAuthorizer bool + var foundImageTrustRootsAuthorizer bool var foundSystemSecretBlock bool for _, validation := range validations { validationMap, ok := validation.(map[string]any) @@ -415,13 +416,19 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T strings.Contains(expression, `check("get")`) && strings.Contains(expression, `object.spec.observability.metrics.serviceMonitor.authorization.credentialsSecret`): foundServiceMonitorSecretAuthorizer = true - case strings.Contains(message, "custom backup helper images") && - strings.Contains(expression, `authorizer.group("openbao.org")`) && - strings.Contains(expression, `resource("openbaoclusters")`) && + case strings.Contains(message, "CR-selected custom executables") && + strings.Contains(expression, `variables.custom_executables_authorized`) && + strings.Contains(expression, `object.spec.initContainer.image`) && strings.Contains(expression, `object.spec.backup.image`) && - strings.Contains(expression, `oldObject.spec.backup.image`) && - strings.Contains(expression, `check("usehelperimages")`): - foundBackupHelperImageAuthorizer = true + strings.Contains(expression, `object.spec.upgrade.image`) && + strings.Contains(expression, `object.spec.upgrade.blueGreen.verification.prePromotionHook`) && + strings.Contains(expression, `object.spec.plugins.all`): + foundCustomExecutablesAuthorizer = true + case strings.Contains(message, "custom image verification trust roots") && + strings.Contains(expression, `variables.has_custom_main_image_trust_roots`) && + strings.Contains(expression, `variables.has_custom_operator_image_trust_roots`) && + strings.Contains(expression, `variables.image_trust_roots_authorized`): + foundImageTrustRootsAuthorizer = true case strings.Contains(message, "system secrets") && strings.Contains(expression, "object.spec.unseal.credentialsSecretRef") && strings.Contains(expression, "object.spec.observability.metrics.serviceMonitor.authorization.credentialsSecret") && @@ -435,19 +442,50 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T !foundSecretAuthorizer || !foundBackupSecretAuthorizer || !foundServiceMonitorSecretAuthorizer || - !foundBackupHelperImageAuthorizer || + !foundCustomExecutablesAuthorizer || + !foundImageTrustRootsAuthorizer || !foundSystemSecretBlock { t.Fatalf( - "openbao-validate-openbaocluster protections missing: https=%v unsafeURL=%v transitAuthorizer=%v backupAuthorizer=%v serviceMonitorAuthorizer=%v backupHelperImageAuthorizer=%v systemSecret=%v", + "openbao-validate-openbaocluster protections missing: https=%v unsafeURL=%v transitAuthorizer=%v backupAuthorizer=%v serviceMonitorAuthorizer=%v executableCodeAuthorizer=%v imageTrustRootsAuthorizer=%v systemSecret=%v", foundHTTPS, foundUnsafeURLComponents, foundSecretAuthorizer, foundBackupSecretAuthorizer, foundServiceMonitorSecretAuthorizer, - foundBackupHelperImageAuthorizer, + foundCustomExecutablesAuthorizer, + foundImageTrustRootsAuthorizer, foundSystemSecretBlock, ) } + + variables, found, err := unstructured.NestedSlice(objs[0].Object, "spec", "variables") + if err != nil || !found { + t.Fatalf("read policy variables: found=%v err=%v", found, err) + } + var foundCustomExecutablesVariable bool + var foundImageTrustRootsVariable bool + for _, variable := range variables { + variableMap, ok := variable.(map[string]any) + if !ok { + continue + } + name, _ := variableMap["name"].(string) + expression, _ := variableMap["expression"].(string) + switch name { + case "custom_executables_authorized": + foundCustomExecutablesVariable = strings.Contains(expression, `check("usecustomexecutables")`) && + strings.Contains(expression, `check("usehelperimages")`) + case "image_trust_roots_authorized": + foundImageTrustRootsVariable = strings.Contains(expression, `check("useimagetrustroots")`) + } + } + if !foundCustomExecutablesVariable || !foundImageTrustRootsVariable { + t.Fatalf( + "openbao-validate-openbaocluster delegation variables missing: customExecutables=%v trustRoots=%v", + foundCustomExecutablesVariable, + foundImageTrustRootsVariable, + ) + } } func TestKustomizeDefault_OpenBaoRestorePolicyProtectsSecretRefs(t *testing.T) { @@ -487,12 +525,8 @@ func TestKustomizeDefault_OpenBaoRestorePolicyProtectsSecretRefs(t *testing.T) { strings.Contains(expression, `object.spec.tokenSecretRef`): foundRestoreSecretAuthorizer = true case strings.Contains(message, "custom restore helper images") && - strings.Contains(expression, `authorizer.group("openbao.org")`) && - strings.Contains(expression, `resource("openbaoclusters")`) && strings.Contains(expression, `object.spec.image`) && - strings.Contains(expression, `oldObject.spec.image`) && - strings.Contains(expression, `object.spec.cluster`) && - strings.Contains(expression, `check("usehelperimages")`): + strings.Contains(expression, `variables.custom_executables_authorized`): foundRestoreHelperImageAuthorizer = true case strings.Contains(message, "system secrets") && strings.Contains(expression, "object.spec.source.target.credentialsSecretRef") && @@ -510,6 +544,29 @@ func TestKustomizeDefault_OpenBaoRestorePolicyProtectsSecretRefs(t *testing.T) { foundSystemSecretBlock, ) } + + variables, found, err := unstructured.NestedSlice(objs[0].Object, "spec", "variables") + if err != nil || !found { + t.Fatalf("read policy variables: found=%v err=%v", found, err) + } + var foundCustomExecutablesVariable bool + for _, variable := range variables { + variableMap, ok := variable.(map[string]any) + if !ok { + continue + } + name, _ := variableMap["name"].(string) + expression, _ := variableMap["expression"].(string) + if name == "custom_executables_authorized" { + foundCustomExecutablesVariable = strings.Contains(expression, `object.spec.cluster`) && + strings.Contains(expression, `check("usecustomexecutables")`) && + strings.Contains(expression, `check("usehelperimages")`) + break + } + } + if !foundCustomExecutablesVariable { + t.Fatalf("openbao-validate-openbaorestore custom executables delegation variable missing") + } } func TestKustomizeDefault_OpenBaoClusterCRDRejectsUpgradeStrategySwitches(t *testing.T) { @@ -785,6 +842,13 @@ func TestKustomizeSingleTenantOverlay_BakesInNamespaceScopeAndRemovesProvisioner "servicemonitors", []string{"create", "delete", "get", "patch"}, ) + assertClusterRoleHasResourceRule( + t, + singleTenantRole, + "openbao.org", + "openbaoclusters", + []string{"usecustomexecutables", "useimagetrustroots"}, + ) subjects, found, err := unstructured.NestedSlice(singleTenantBinding.Object, "subjects") if err != nil || !found || len(subjects) != 1 { @@ -816,6 +880,8 @@ func assertClusterRoleHasResourceRule( t.Fatalf("read %s rules: found=%v err=%v", role.GetName(), found, err) } + foundResource := false + var seenVerbs []any for _, rule := range rules { ruleMap, ok := rule.(map[string]any) if !ok { @@ -827,17 +893,27 @@ func assertClusterRoleHasResourceRule( if !containsAny(apiGroups, apiGroup) || !containsAny(resources, resource) { continue } + foundResource = true + seenVerbs = ruleVerbs if len(ruleVerbs) != len(verbs) { - t.Fatalf("%s rule for %s/%s verbs = %#v, want exactly %#v", role.GetName(), apiGroup, resource, ruleVerbs, verbs) + continue } + missingVerb := false for _, verb := range verbs { if !containsAny(ruleVerbs, verb) { - t.Fatalf("%s rule for %s/%s missing verb %q: %#v", role.GetName(), apiGroup, resource, verb, ruleMap) + missingVerb = true + break } } + if missingVerb { + continue + } return } + if foundResource { + t.Fatalf("%s rule for %s/%s verbs = %#v, want exactly %#v", role.GetName(), apiGroup, resource, seenVerbs, verbs) + } t.Fatalf("%s missing rule for %s/%s", role.GetName(), apiGroup, resource) } diff --git a/test/utils/policy_restrict_provisioner_rbac_test.go b/test/utils/policy_restrict_provisioner_rbac_test.go index d770dd2ee..f04d86392 100644 --- a/test/utils/policy_restrict_provisioner_rbac_test.go +++ b/test/utils/policy_restrict_provisioner_rbac_test.go @@ -71,6 +71,26 @@ func TestRestrictProvisionerRBACPolicyAllowsTenantServiceMonitorRule(t *testing. } } +func TestRestrictProvisionerRBACPolicyAllowsControllerDelegationRule(t *testing.T) { + const policyPath = "../../config/policy/openbao-restrict-provisioner-rbac.yaml" + + data, err := os.ReadFile(policyPath) + if err != nil { + t.Fatalf("failed to read policy %s: %v", policyPath, err) + } + + policy := string(data) + required := []string{ + "rule.resources[0] == 'openbaoclusters'", + "rule.verbs.all(v, v in ['usecustomexecutables', 'useimagetrustroots'])", + } + for _, needle := range required { + if !containsString(policy, needle) { + t.Fatalf("policy %s does not allow the controller delegation rule; missing %q", policyPath, needle) + } + } +} + // containsString performs a simple substring check without introducing extra // dependencies. Kept private and local to avoid over-abstracting. func containsString(haystack, needle string) bool {