diff --git a/charts/openbao-operator/templates/admission/provisioner-rbac.yaml b/charts/openbao-operator/templates/admission/provisioner-rbac.yaml
index 11a2af0ae..a8ad79f33 100644
--- a/charts/openbao-operator/templates/admission/provisioner-rbac.yaml
+++ b/charts/openbao-operator/templates/admission/provisioner-rbac.yaml
@@ -107,7 +107,7 @@ spec:
rule.resources.size() == 1 &&
rule.resources[0] == 'openbaoclusters' &&
rule.verbs != null &&
- rule.verbs.all(v, v in ['usecustomexecutables', 'useimagetrustroots'])
+ rule.verbs.all(v, v in ['restore', 'usecloudidentities', 'usecustomexecutables', 'useimagetrustroots'])
) ||
(
rule.apiGroups.size() == 1 &&
diff --git a/charts/openbao-operator/templates/admission/validate-openbaocluster.yaml b/charts/openbao-operator/templates/admission/validate-openbaocluster.yaml
index ee0e55e9d..d6e4627b9 100644
--- a/charts/openbao-operator/templates/admission/validate-openbaocluster.yaml
+++ b/charts/openbao-operator/templates/admission/validate-openbaocluster.yaml
@@ -16,6 +16,18 @@ spec:
variables:
- name: has_backup
expression: has(object.spec.backup)
+ - name: operator_namespace
+ expression: >-
+ '{{ .Release.Namespace }}'
+ - name: controller_serviceaccount_name
+ expression: >-
+ '{{ include "openbao-operator.controllerServiceAccountName" . }}'
+ - name: controller_principal
+ expression: >-
+ 'system:serviceaccount:' + variables.operator_namespace + ':' + variables.controller_serviceaccount_name
+ - name: is_operator_controller
+ expression: >-
+ request.userInfo.username == variables.controller_principal
- name: requested_upgrade_strategy
expression: >-
has(object.spec.upgrade) && has(object.spec.upgrade.strategy) && object.spec.upgrade.strategy != "" ? object.spec.upgrade.strategy : "RollingUpdate"
@@ -116,6 +128,44 @@ spec:
- name: image_trust_roots_authorized
expression: >-
authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("useimagetrustroots").allowed()
+ - name: cloud_identities_authorized
+ expression: >-
+ authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("usecloudidentities").allowed()
+ - name: has_main_workload_cloud_identity_metadata
+ expression: >-
+ (has(object.spec.serviceAccount) &&
+ has(object.spec.serviceAccount.annotations) &&
+ size(object.spec.serviceAccount.annotations) > 0) ||
+ (has(object.spec.podMetadata) &&
+ (
+ (has(object.spec.podMetadata.annotations) &&
+ object.spec.podMetadata.annotations.exists(key,
+ key.startsWith("eks.amazonaws.com/") ||
+ key.startsWith("iam.amazonaws.com/") ||
+ key.startsWith("iam.gke.io/") ||
+ key.startsWith("azure.workload.identity/"))) ||
+ (has(object.spec.podMetadata.labels) &&
+ object.spec.podMetadata.labels.exists(key,
+ key.startsWith("azure.workload.identity/") ||
+ key == "aadpodidbinding"))
+ ))
+ - name: has_backup_cloud_identity_metadata
+ expression: >-
+ has(object.spec.backup) &&
+ (
+ (has(object.spec.backup.target.roleArn) && object.spec.backup.target.roleArn.trim() != "") ||
+ (has(object.spec.backup.target.workloadIdentity) &&
+ (
+ (has(object.spec.backup.target.workloadIdentity.serviceAccountAnnotations) &&
+ size(object.spec.backup.target.workloadIdentity.serviceAccountAnnotations) > 0) ||
+ (has(object.spec.backup.target.workloadIdentity.podLabels) &&
+ size(object.spec.backup.target.workloadIdentity.podLabels) > 0)
+ ))
+ )
+ - name: has_cloud_identity_metadata
+ expression: >-
+ variables.has_main_workload_cloud_identity_metadata ||
+ variables.has_backup_cloud_identity_metadata
- name: has_custom_main_image_trust_roots
expression: >-
has(object.spec.imageVerification) &&
@@ -423,6 +473,119 @@ spec:
!(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."
+ # Reference authorization: CR authors must be authorized for every external authority
+ # their manifest asks the operator, kubelet, ingress, gateway, or monitoring stack to use.
+ - expression: >-
+ variables.is_operator_controller ||
+ !variables.has_cloud_identity_metadata ||
+ variables.cloud_identities_authorized
+ message: "Users configuring workload identity annotations, labels, roleArn, or workloadIdentity metadata must be authorized to use cloud identities on this OpenBaoCluster."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.serviceAccount) ||
+ !has(object.spec.serviceAccount.name) ||
+ object.spec.serviceAccount.name.trim() == "" ||
+ authorizer.group("").resource("serviceaccounts").namespace(request.namespace).name(object.spec.serviceAccount.name).check("use").allowed()
+ message: "Users configuring spec.serviceAccount.name must be authorized to use that ServiceAccount."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.imagePullSecrets) ||
+ object.spec.imagePullSecrets.all(secretRef,
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(secretRef.name).check("use").allowed() ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(secretRef.name).check("get").allowed())
+ message: "Users configuring spec.imagePullSecrets must be authorized to use referenced image pull Secrets, or get those Secrets."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.ingress) ||
+ object.spec.ingress.enabled != true ||
+ !has(object.spec.ingress.className) ||
+ object.spec.ingress.className.trim() == "" ||
+ authorizer.group("networking.k8s.io").resource("ingressclasses").name(object.spec.ingress.className).check("use").allowed()
+ message: "Users configuring spec.ingress.className must be authorized to use that IngressClass."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.ingress) ||
+ object.spec.ingress.enabled != true ||
+ !has(object.spec.ingress.tlsSecretName) ||
+ object.spec.ingress.tlsSecretName.trim() == "" ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(object.spec.ingress.tlsSecretName).check("use").allowed() ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(object.spec.ingress.tlsSecretName).check("get").allowed()
+ message: "Users configuring spec.ingress.tlsSecretName must be authorized to use the referenced TLS Secret, or get that Secret."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.gateway) ||
+ object.spec.gateway.enabled != true ||
+ object.spec.gateway.gatewayRef.name.trim() == "" ||
+ authorizer.group("gateway.networking.k8s.io").resource("gateways").
+ namespace(has(object.spec.gateway.gatewayRef.namespace) && object.spec.gateway.gatewayRef.namespace.trim() != "" ? object.spec.gateway.gatewayRef.namespace : request.namespace).
+ name(object.spec.gateway.gatewayRef.name).check("use").allowed()
+ message: "Users configuring spec.gateway.gatewayRef must be authorized to use the referenced Gateway."
+ - expression: >-
+ variables.is_operator_controller ||
+ (
+ (!has(object.spec.tls.acme) ||
+ !has(object.spec.tls.acme.sharedCache) ||
+ !has(object.spec.tls.acme.sharedCache.existingClaimName) ||
+ object.spec.tls.acme.sharedCache.existingClaimName.trim() == "" ||
+ authorizer.group("").resource("persistentvolumeclaims").namespace(request.namespace).name(object.spec.tls.acme.sharedCache.existingClaimName).check("use").allowed()) &&
+ (!has(object.spec.auditFileStorage) ||
+ !has(object.spec.auditFileStorage.existingClaimName) ||
+ object.spec.auditFileStorage.existingClaimName.trim() == "" ||
+ authorizer.group("").resource("persistentvolumeclaims").namespace(request.namespace).name(object.spec.auditFileStorage.existingClaimName).check("use").allowed())
+ )
+ message: "Users configuring existing PVC references must be authorized to use the referenced PersistentVolumeClaims."
+ - expression: >-
+ variables.is_operator_controller ||
+ (
+ (!has(object.spec.storage.storageClassName) ||
+ object.spec.storage.storageClassName.trim() == "" ||
+ authorizer.group("storage.k8s.io").resource("storageclasses").name(object.spec.storage.storageClassName).check("use").allowed()) &&
+ (!has(object.spec.readReplicas) ||
+ !has(object.spec.readReplicas.storage) ||
+ !has(object.spec.readReplicas.storage.storageClassName) ||
+ object.spec.readReplicas.storage.storageClassName.trim() == "" ||
+ authorizer.group("storage.k8s.io").resource("storageclasses").name(object.spec.readReplicas.storage.storageClassName).check("use").allowed()) &&
+ (!has(object.spec.tls.acme) ||
+ !has(object.spec.tls.acme.sharedCache) ||
+ !has(object.spec.tls.acme.sharedCache.storageClassName) ||
+ object.spec.tls.acme.sharedCache.storageClassName.trim() == "" ||
+ authorizer.group("storage.k8s.io").resource("storageclasses").name(object.spec.tls.acme.sharedCache.storageClassName).check("use").allowed()) &&
+ (!has(object.spec.auditFileStorage) ||
+ !has(object.spec.auditFileStorage.storageClassName) ||
+ object.spec.auditFileStorage.storageClassName.trim() == "" ||
+ authorizer.group("storage.k8s.io").resource("storageclasses").name(object.spec.auditFileStorage.storageClassName).check("use").allowed())
+ )
+ message: "Users configuring StorageClass references must be authorized to use the referenced StorageClasses."
+ - expression: >-
+ variables.is_operator_controller ||
+ (
+ (!has(object.spec.imageVerification) ||
+ object.spec.imageVerification.enabled != true ||
+ !has(object.spec.imageVerification.imagePullSecrets) ||
+ object.spec.imageVerification.imagePullSecrets.all(secretRef,
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(secretRef.name).check("get").allowed())) &&
+ (!has(object.spec.operatorImageVerification) ||
+ object.spec.operatorImageVerification.enabled != true ||
+ !has(object.spec.operatorImageVerification.imagePullSecrets) ||
+ object.spec.operatorImageVerification.imagePullSecrets.all(secretRef,
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(secretRef.name).check("get").allowed()))
+ )
+ message: "Users configuring image verification pull Secrets must be authorized to get those Secrets."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.observability) ||
+ !has(object.spec.observability.metrics) ||
+ !has(object.spec.observability.metrics.serviceMonitor) ||
+ !has(object.spec.observability.metrics.serviceMonitor.tlsConfig) ||
+ (
+ (!has(object.spec.observability.metrics.serviceMonitor.tlsConfig.caSecret) ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(object.spec.observability.metrics.serviceMonitor.tlsConfig.caSecret.name).check("use").allowed() ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(object.spec.observability.metrics.serviceMonitor.tlsConfig.caSecret.name).check("get").allowed()) &&
+ (!has(object.spec.observability.metrics.serviceMonitor.tlsConfig.caConfigMap) ||
+ authorizer.group("").resource("configmaps").namespace(request.namespace).name(object.spec.observability.metrics.serviceMonitor.tlsConfig.caConfigMap.name).check("use").allowed() ||
+ authorizer.group("").resource("configmaps").namespace(request.namespace).name(object.spec.observability.metrics.serviceMonitor.tlsConfig.caConfigMap.name).check("get").allowed())
+ )
+ message: "Users configuring ServiceMonitor TLS references must be authorized to use the referenced Secret or ConfigMap, or get it."
# 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 c1efaf255..a27bcf821 100644
--- a/charts/openbao-operator/templates/admission/validate-openbaorestore.yaml
+++ b/charts/openbao-operator/templates/admission/validate-openbaorestore.yaml
@@ -47,11 +47,37 @@ spec:
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()
+ - name: cloud_identities_authorized
+ expression: >-
+ authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("usecloudidentities").allowed()
+ - name: restore_authorized
+ expression: >-
+ authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("restore").allowed()
+ - name: has_restore_cloud_identity_metadata
+ expression: >-
+ (has(object.spec.source.target.roleArn) && object.spec.source.target.roleArn.trim() != "") ||
+ (has(object.spec.source.target.workloadIdentity) &&
+ (
+ (has(object.spec.source.target.workloadIdentity.serviceAccountAnnotations) &&
+ size(object.spec.source.target.workloadIdentity.serviceAccountAnnotations) > 0) ||
+ (has(object.spec.source.target.workloadIdentity.podLabels) &&
+ size(object.spec.source.target.workloadIdentity.podLabels) > 0)
+ ))
validations:
# API contract: restore specs are immutable after creation.
- expression: >-
oldObject == null || object.spec == oldObject.spec
message: "spec is immutable; create a new OpenBaoRestore for changed restore parameters."
+ # Destructive restore target authorization: restore can replace data, policies, and keys.
+ - expression: >-
+ variables.restore_authorized
+ message: "Users creating or updating an OpenBaoRestore must be authorized to restore the target OpenBaoCluster."
+ # Reference authorization: restore cloud identity metadata delegates cloud authority
+ # to the generated restore ServiceAccount or Job pod.
+ - expression: >-
+ !variables.has_restore_cloud_identity_metadata ||
+ variables.cloud_identities_authorized
+ message: "Users configuring restore roleArn or workloadIdentity metadata must be authorized to use cloud identities on the target OpenBaoCluster."
# Safety: overrideOperationLock requires force=true (break-glass is explicit).
- expression: >-
!has(object.spec.overrideOperationLock) ||
diff --git a/charts/openbao-operator/templates/rbac/aggregated-clusterroles.yaml b/charts/openbao-operator/templates/rbac/aggregated-clusterroles.yaml
index a2d97e54c..a16df597c 100644
--- a/charts/openbao-operator/templates/rbac/aggregated-clusterroles.yaml
+++ b/charts/openbao-operator/templates/rbac/aggregated-clusterroles.yaml
@@ -47,6 +47,22 @@ rules:
- get
---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ {{- include "openbao-operator.labels" . | nindent 4 }}
+ name: {{ include "openbao-operator.fullname" . }}-openbaocluster-cloud-identity
+rules:
+ - apiGroups:
+ - openbao.org
+ resources:
+ - openbaoclusters
+ verbs:
+ - get
+ - usecloudidentities
+---
+
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
@@ -80,6 +96,22 @@ rules:
- useimagetrustroots
---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ {{- include "openbao-operator.labels" . | nindent 4 }}
+ name: {{ include "openbao-operator.fullname" . }}-openbaocluster-restore
+rules:
+ - apiGroups:
+ - openbao.org
+ resources:
+ - openbaoclusters
+ verbs:
+ - get
+ - restore
+---
+
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 232d6c200..4c554f274 100644
--- a/charts/openbao-operator/templates/rbac/single-tenant-clusterrole.yaml
+++ b/charts/openbao-operator/templates/rbac/single-tenant-clusterrole.yaml
@@ -26,6 +26,8 @@ rules:
resources:
- openbaoclusters
verbs:
+ - restore
+ - usecloudidentities
- usecustomexecutables
- useimagetrustroots
- apiGroups:
diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml
index ebfbf1569..592fc9a40 100644
--- a/config/default/kustomization.yaml
+++ b/config/default/kustomization.yaml
@@ -301,6 +301,16 @@ replacements:
options:
delimiter: "'"
index: 1
+ - select:
+ group: admissionregistration.k8s.io
+ version: v1
+ kind: ValidatingAdmissionPolicy
+ name: openbao-operator-openbao-validate-openbaocluster
+ fieldPaths:
+ - spec.variables.[name=controller_serviceaccount_name].expression
+ options:
+ delimiter: "'"
+ index: 1
- source:
version: v1
kind: ServiceAccount
@@ -397,6 +407,16 @@ replacements:
options:
delimiter: "'"
index: 1
+ - select:
+ group: admissionregistration.k8s.io
+ version: v1
+ kind: ValidatingAdmissionPolicy
+ name: openbao-operator-openbao-validate-openbaocluster
+ fieldPaths:
+ - spec.variables.[name=operator_namespace].expression
+ options:
+ delimiter: "'"
+ index: 1
- source:
version: v1
kind: ServiceAccount
diff --git a/config/overlays/custom-identity/kustomization.yaml b/config/overlays/custom-identity/kustomization.yaml
index c4009bf68..bd15dabb0 100644
--- a/config/overlays/custom-identity/kustomization.yaml
+++ b/config/overlays/custom-identity/kustomization.yaml
@@ -88,6 +88,16 @@ replacements:
options:
delimiter: "'"
index: 1
+ - select:
+ group: admissionregistration.k8s.io
+ version: v1
+ kind: ValidatingAdmissionPolicy
+ name: openbao-operator-openbao-validate-openbaocluster
+ fieldPaths:
+ - spec.variables.[name=controller_serviceaccount_name].expression
+ options:
+ delimiter: "'"
+ index: 1
- source:
version: v1
kind: ServiceAccount
@@ -184,6 +194,16 @@ replacements:
options:
delimiter: "'"
index: 1
+ - select:
+ group: admissionregistration.k8s.io
+ version: v1
+ kind: ValidatingAdmissionPolicy
+ name: openbao-operator-openbao-validate-openbaocluster
+ fieldPaths:
+ - spec.variables.[name=operator_namespace].expression
+ options:
+ delimiter: "'"
+ index: 1
- source:
version: v1
kind: ServiceAccount
diff --git a/config/overlays/single-tenant-custom-identity/kustomization.yaml b/config/overlays/single-tenant-custom-identity/kustomization.yaml
index f14980354..ab190c8fe 100644
--- a/config/overlays/single-tenant-custom-identity/kustomization.yaml
+++ b/config/overlays/single-tenant-custom-identity/kustomization.yaml
@@ -183,6 +183,16 @@ replacements:
options:
delimiter: "'"
index: 1
+ - select:
+ group: admissionregistration.k8s.io
+ version: v1
+ kind: ValidatingAdmissionPolicy
+ name: openbao-operator-openbao-validate-openbaocluster
+ fieldPaths:
+ - spec.variables.[name=controller_serviceaccount_name].expression
+ options:
+ delimiter: "'"
+ index: 1
- source:
version: v1
kind: ServiceAccount
@@ -286,3 +296,13 @@ replacements:
options:
delimiter: "'"
index: 1
+ - select:
+ group: admissionregistration.k8s.io
+ version: v1
+ kind: ValidatingAdmissionPolicy
+ name: openbao-operator-openbao-validate-openbaocluster
+ fieldPaths:
+ - spec.variables.[name=operator_namespace].expression
+ options:
+ delimiter: "'"
+ index: 1
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 1aa982a6e..a06916dd9 100644
--- a/config/overlays/single-tenant-custom-identity/single_tenant_clusterrole.yaml
+++ b/config/overlays/single-tenant-custom-identity/single_tenant_clusterrole.yaml
@@ -29,6 +29,8 @@ rules:
resources:
- openbaoclusters
verbs:
+ - restore
+ - usecloudidentities
- usecustomexecutables
- useimagetrustroots
- apiGroups:
diff --git a/config/overlays/single-tenant/single_tenant_clusterrole.yaml b/config/overlays/single-tenant/single_tenant_clusterrole.yaml
index 1aa982a6e..a06916dd9 100644
--- a/config/overlays/single-tenant/single_tenant_clusterrole.yaml
+++ b/config/overlays/single-tenant/single_tenant_clusterrole.yaml
@@ -29,6 +29,8 @@ rules:
resources:
- openbaoclusters
verbs:
+ - restore
+ - usecloudidentities
- usecustomexecutables
- useimagetrustroots
- apiGroups:
diff --git a/config/policy/openbao-restrict-provisioner-rbac.yaml b/config/policy/openbao-restrict-provisioner-rbac.yaml
index 06d73ff5c..b550efe45 100644
--- a/config/policy/openbao-restrict-provisioner-rbac.yaml
+++ b/config/policy/openbao-restrict-provisioner-rbac.yaml
@@ -104,7 +104,7 @@ spec:
rule.resources.size() == 1 &&
rule.resources[0] == 'openbaoclusters' &&
rule.verbs != null &&
- rule.verbs.all(v, v in ['usecustomexecutables', 'useimagetrustroots'])
+ rule.verbs.all(v, v in ['restore', 'usecloudidentities', 'usecustomexecutables', 'useimagetrustroots'])
) ||
(
rule.apiGroups.size() == 1 &&
diff --git a/config/policy/openbao-validate-openbaocluster.yaml b/config/policy/openbao-validate-openbaocluster.yaml
index 1436f8a08..41d72d2ae 100644
--- a/config/policy/openbao-validate-openbaocluster.yaml
+++ b/config/policy/openbao-validate-openbaocluster.yaml
@@ -13,6 +13,18 @@ spec:
variables:
- name: has_backup
expression: has(object.spec.backup)
+ - name: operator_namespace
+ expression: >-
+ 'openbao-operator-system'
+ - name: controller_serviceaccount_name
+ expression: >-
+ 'openbao-operator-controller'
+ - name: controller_principal
+ expression: >-
+ 'system:serviceaccount:' + variables.operator_namespace + ':' + variables.controller_serviceaccount_name
+ - name: is_operator_controller
+ expression: >-
+ request.userInfo.username == variables.controller_principal
- name: requested_upgrade_strategy
expression: >-
has(object.spec.upgrade) && has(object.spec.upgrade.strategy) && object.spec.upgrade.strategy != "" ? object.spec.upgrade.strategy : "RollingUpdate"
@@ -113,6 +125,44 @@ spec:
- name: image_trust_roots_authorized
expression: >-
authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("useimagetrustroots").allowed()
+ - name: cloud_identities_authorized
+ expression: >-
+ authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.metadata.name).check("usecloudidentities").allowed()
+ - name: has_main_workload_cloud_identity_metadata
+ expression: >-
+ (has(object.spec.serviceAccount) &&
+ has(object.spec.serviceAccount.annotations) &&
+ size(object.spec.serviceAccount.annotations) > 0) ||
+ (has(object.spec.podMetadata) &&
+ (
+ (has(object.spec.podMetadata.annotations) &&
+ object.spec.podMetadata.annotations.exists(key,
+ key.startsWith("eks.amazonaws.com/") ||
+ key.startsWith("iam.amazonaws.com/") ||
+ key.startsWith("iam.gke.io/") ||
+ key.startsWith("azure.workload.identity/"))) ||
+ (has(object.spec.podMetadata.labels) &&
+ object.spec.podMetadata.labels.exists(key,
+ key.startsWith("azure.workload.identity/") ||
+ key == "aadpodidbinding"))
+ ))
+ - name: has_backup_cloud_identity_metadata
+ expression: >-
+ has(object.spec.backup) &&
+ (
+ (has(object.spec.backup.target.roleArn) && object.spec.backup.target.roleArn.trim() != "") ||
+ (has(object.spec.backup.target.workloadIdentity) &&
+ (
+ (has(object.spec.backup.target.workloadIdentity.serviceAccountAnnotations) &&
+ size(object.spec.backup.target.workloadIdentity.serviceAccountAnnotations) > 0) ||
+ (has(object.spec.backup.target.workloadIdentity.podLabels) &&
+ size(object.spec.backup.target.workloadIdentity.podLabels) > 0)
+ ))
+ )
+ - name: has_cloud_identity_metadata
+ expression: >-
+ variables.has_main_workload_cloud_identity_metadata ||
+ variables.has_backup_cloud_identity_metadata
- name: has_custom_main_image_trust_roots
expression: >-
has(object.spec.imageVerification) &&
@@ -420,6 +470,119 @@ spec:
!(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."
+ # Reference authorization: CR authors must be authorized for every external authority
+ # their manifest asks the operator, kubelet, ingress, gateway, or monitoring stack to use.
+ - expression: >-
+ variables.is_operator_controller ||
+ !variables.has_cloud_identity_metadata ||
+ variables.cloud_identities_authorized
+ message: "Users configuring workload identity annotations, labels, roleArn, or workloadIdentity metadata must be authorized to use cloud identities on this OpenBaoCluster."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.serviceAccount) ||
+ !has(object.spec.serviceAccount.name) ||
+ object.spec.serviceAccount.name.trim() == "" ||
+ authorizer.group("").resource("serviceaccounts").namespace(request.namespace).name(object.spec.serviceAccount.name).check("use").allowed()
+ message: "Users configuring spec.serviceAccount.name must be authorized to use that ServiceAccount."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.imagePullSecrets) ||
+ object.spec.imagePullSecrets.all(secretRef,
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(secretRef.name).check("use").allowed() ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(secretRef.name).check("get").allowed())
+ message: "Users configuring spec.imagePullSecrets must be authorized to use referenced image pull Secrets, or get those Secrets."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.ingress) ||
+ object.spec.ingress.enabled != true ||
+ !has(object.spec.ingress.className) ||
+ object.spec.ingress.className.trim() == "" ||
+ authorizer.group("networking.k8s.io").resource("ingressclasses").name(object.spec.ingress.className).check("use").allowed()
+ message: "Users configuring spec.ingress.className must be authorized to use that IngressClass."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.ingress) ||
+ object.spec.ingress.enabled != true ||
+ !has(object.spec.ingress.tlsSecretName) ||
+ object.spec.ingress.tlsSecretName.trim() == "" ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(object.spec.ingress.tlsSecretName).check("use").allowed() ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(object.spec.ingress.tlsSecretName).check("get").allowed()
+ message: "Users configuring spec.ingress.tlsSecretName must be authorized to use the referenced TLS Secret, or get that Secret."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.gateway) ||
+ object.spec.gateway.enabled != true ||
+ object.spec.gateway.gatewayRef.name.trim() == "" ||
+ authorizer.group("gateway.networking.k8s.io").resource("gateways").
+ namespace(has(object.spec.gateway.gatewayRef.namespace) && object.spec.gateway.gatewayRef.namespace.trim() != "" ? object.spec.gateway.gatewayRef.namespace : request.namespace).
+ name(object.spec.gateway.gatewayRef.name).check("use").allowed()
+ message: "Users configuring spec.gateway.gatewayRef must be authorized to use the referenced Gateway."
+ - expression: >-
+ variables.is_operator_controller ||
+ (
+ (!has(object.spec.tls.acme) ||
+ !has(object.spec.tls.acme.sharedCache) ||
+ !has(object.spec.tls.acme.sharedCache.existingClaimName) ||
+ object.spec.tls.acme.sharedCache.existingClaimName.trim() == "" ||
+ authorizer.group("").resource("persistentvolumeclaims").namespace(request.namespace).name(object.spec.tls.acme.sharedCache.existingClaimName).check("use").allowed()) &&
+ (!has(object.spec.auditFileStorage) ||
+ !has(object.spec.auditFileStorage.existingClaimName) ||
+ object.spec.auditFileStorage.existingClaimName.trim() == "" ||
+ authorizer.group("").resource("persistentvolumeclaims").namespace(request.namespace).name(object.spec.auditFileStorage.existingClaimName).check("use").allowed())
+ )
+ message: "Users configuring existing PVC references must be authorized to use the referenced PersistentVolumeClaims."
+ - expression: >-
+ variables.is_operator_controller ||
+ (
+ (!has(object.spec.storage.storageClassName) ||
+ object.spec.storage.storageClassName.trim() == "" ||
+ authorizer.group("storage.k8s.io").resource("storageclasses").name(object.spec.storage.storageClassName).check("use").allowed()) &&
+ (!has(object.spec.readReplicas) ||
+ !has(object.spec.readReplicas.storage) ||
+ !has(object.spec.readReplicas.storage.storageClassName) ||
+ object.spec.readReplicas.storage.storageClassName.trim() == "" ||
+ authorizer.group("storage.k8s.io").resource("storageclasses").name(object.spec.readReplicas.storage.storageClassName).check("use").allowed()) &&
+ (!has(object.spec.tls.acme) ||
+ !has(object.spec.tls.acme.sharedCache) ||
+ !has(object.spec.tls.acme.sharedCache.storageClassName) ||
+ object.spec.tls.acme.sharedCache.storageClassName.trim() == "" ||
+ authorizer.group("storage.k8s.io").resource("storageclasses").name(object.spec.tls.acme.sharedCache.storageClassName).check("use").allowed()) &&
+ (!has(object.spec.auditFileStorage) ||
+ !has(object.spec.auditFileStorage.storageClassName) ||
+ object.spec.auditFileStorage.storageClassName.trim() == "" ||
+ authorizer.group("storage.k8s.io").resource("storageclasses").name(object.spec.auditFileStorage.storageClassName).check("use").allowed())
+ )
+ message: "Users configuring StorageClass references must be authorized to use the referenced StorageClasses."
+ - expression: >-
+ variables.is_operator_controller ||
+ (
+ (!has(object.spec.imageVerification) ||
+ object.spec.imageVerification.enabled != true ||
+ !has(object.spec.imageVerification.imagePullSecrets) ||
+ object.spec.imageVerification.imagePullSecrets.all(secretRef,
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(secretRef.name).check("get").allowed())) &&
+ (!has(object.spec.operatorImageVerification) ||
+ object.spec.operatorImageVerification.enabled != true ||
+ !has(object.spec.operatorImageVerification.imagePullSecrets) ||
+ object.spec.operatorImageVerification.imagePullSecrets.all(secretRef,
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(secretRef.name).check("get").allowed()))
+ )
+ message: "Users configuring image verification pull Secrets must be authorized to get those Secrets."
+ - expression: >-
+ variables.is_operator_controller ||
+ !has(object.spec.observability) ||
+ !has(object.spec.observability.metrics) ||
+ !has(object.spec.observability.metrics.serviceMonitor) ||
+ !has(object.spec.observability.metrics.serviceMonitor.tlsConfig) ||
+ (
+ (!has(object.spec.observability.metrics.serviceMonitor.tlsConfig.caSecret) ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(object.spec.observability.metrics.serviceMonitor.tlsConfig.caSecret.name).check("use").allowed() ||
+ authorizer.group("").resource("secrets").namespace(request.namespace).name(object.spec.observability.metrics.serviceMonitor.tlsConfig.caSecret.name).check("get").allowed()) &&
+ (!has(object.spec.observability.metrics.serviceMonitor.tlsConfig.caConfigMap) ||
+ authorizer.group("").resource("configmaps").namespace(request.namespace).name(object.spec.observability.metrics.serviceMonitor.tlsConfig.caConfigMap.name).check("use").allowed() ||
+ authorizer.group("").resource("configmaps").namespace(request.namespace).name(object.spec.observability.metrics.serviceMonitor.tlsConfig.caConfigMap.name).check("get").allowed())
+ )
+ message: "Users configuring ServiceMonitor TLS references must be authorized to use the referenced Secret or ConfigMap, or get it."
# 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 b4fafe31d..379209362 100644
--- a/config/policy/openbao-validate-openbaorestore.yaml
+++ b/config/policy/openbao-validate-openbaorestore.yaml
@@ -44,11 +44,37 @@ spec:
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()
+ - name: cloud_identities_authorized
+ expression: >-
+ authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("usecloudidentities").allowed()
+ - name: restore_authorized
+ expression: >-
+ authorizer.group("openbao.org").resource("openbaoclusters").namespace(request.namespace).name(object.spec.cluster).check("restore").allowed()
+ - name: has_restore_cloud_identity_metadata
+ expression: >-
+ (has(object.spec.source.target.roleArn) && object.spec.source.target.roleArn.trim() != "") ||
+ (has(object.spec.source.target.workloadIdentity) &&
+ (
+ (has(object.spec.source.target.workloadIdentity.serviceAccountAnnotations) &&
+ size(object.spec.source.target.workloadIdentity.serviceAccountAnnotations) > 0) ||
+ (has(object.spec.source.target.workloadIdentity.podLabels) &&
+ size(object.spec.source.target.workloadIdentity.podLabels) > 0)
+ ))
validations:
# API contract: restore specs are immutable after creation.
- expression: >-
oldObject == null || object.spec == oldObject.spec
message: "spec is immutable; create a new OpenBaoRestore for changed restore parameters."
+ # Destructive restore target authorization: restore can replace data, policies, and keys.
+ - expression: >-
+ variables.restore_authorized
+ message: "Users creating or updating an OpenBaoRestore must be authorized to restore the target OpenBaoCluster."
+ # Reference authorization: restore cloud identity metadata delegates cloud authority
+ # to the generated restore ServiceAccount or Job pod.
+ - expression: >-
+ !variables.has_restore_cloud_identity_metadata ||
+ variables.cloud_identities_authorized
+ message: "Users configuring restore roleArn or workloadIdentity metadata must be authorized to use cloud identities on the target OpenBaoCluster."
# Safety: overrideOperationLock requires force=true (break-glass is explicit).
- expression: >-
!has(object.spec.overrideOperationLock) ||
diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml
index f8c5562be..0d1368bc1 100644
--- a/config/rbac/kustomization.yaml
+++ b/config/rbac/kustomization.yaml
@@ -36,6 +36,8 @@ resources:
- openbaocluster_admin_role.yaml
- openbaocluster_editor_role.yaml
- openbaocluster_helper_image_role.yaml
+ - openbaocluster_cloud_identity_role.yaml
- openbaocluster_image_trust_roots_role.yaml
- openbaocluster_maintenance_role.yaml
+ - openbaocluster_restore_role.yaml
- openbaocluster_viewer_role.yaml
diff --git a/config/rbac/namespace_scoped_example.yaml b/config/rbac/namespace_scoped_example.yaml
index 06d9db8a6..06df9a91d 100644
--- a/config/rbac/namespace_scoped_example.yaml
+++ b/config/rbac/namespace_scoped_example.yaml
@@ -193,6 +193,88 @@ subjects:
name: team-a-platform-operators # Change to actual group name
apiGroup: rbac.authorization.k8s.io
---
+# Example: Grant cloud workload identity selection on one cluster
+#
+# This namespaced Role grants the custom `usecloudidentities` verb only for a
+# single OpenBaoCluster object. Grant it only to operators trusted to attach
+# cloud role or workload identity metadata to OpenBao-managed workloads.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: openbaocluster-cloud-identities-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
+ - usecloudidentities
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: openbaocluster-cloud-identities-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-cloud-identities-team-a-example
+subjects:
+ - kind: Group
+ name: team-a-platform-operators # Change to actual group name
+ apiGroup: rbac.authorization.k8s.io
+---
+# Example: Grant destructive restore authorization on one cluster
+#
+# This namespaced Role grants the custom `restore` verb only for a single
+# OpenBaoCluster object. Grant it only to operators trusted to replace cluster
+# data, policies, and keys from an OpenBaoRestore snapshot source.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: openbaocluster-restore-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
+ - restore
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: openbaocluster-restore-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-restore-team-a-example
+subjects:
+ - kind: Group
+ name: team-a-restore-operators # Change to actual group name
+ apiGroup: rbac.authorization.k8s.io
+---
# Example: Grant admin permissions to platform team (cluster-wide)
#
# This ClusterRoleBinding grants platform administrators full access
diff --git a/config/rbac/openbaocluster_cloud_identity_role.yaml b/config/rbac/openbaocluster_cloud_identity_role.yaml
new file mode 100644
index 000000000..b3624240c
--- /dev/null
+++ b/config/rbac/openbaocluster_cloud_identity_role.yaml
@@ -0,0 +1,23 @@
+# This rule is not used by the project openbao-operator itself.
+# It is provided to allow cluster administrators to delegate cloud workload
+# identity selection without granting full OpenBaoCluster admin permissions.
+#
+# Multi-tenancy: This ClusterRole can be bound via RoleBinding for
+# namespace-scoped cloud identity 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-cloud-identity-role
+rules:
+ - apiGroups:
+ - openbao.org
+ resources:
+ - openbaoclusters
+ verbs:
+ - get
+ - usecloudidentities
diff --git a/config/rbac/openbaocluster_restore_role.yaml b/config/rbac/openbaocluster_restore_role.yaml
new file mode 100644
index 000000000..619e38377
--- /dev/null
+++ b/config/rbac/openbaocluster_restore_role.yaml
@@ -0,0 +1,23 @@
+# This rule is not used by the project openbao-operator itself.
+# It is provided to allow cluster administrators to delegate destructive
+# restore authorization without granting full OpenBaoCluster admin permissions.
+#
+# Multi-tenancy: This ClusterRole can be bound via RoleBinding for
+# namespace-scoped restore 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-restore-role
+rules:
+ - apiGroups:
+ - openbao.org
+ resources:
+ - openbaoclusters
+ verbs:
+ - get
+ - restore
diff --git a/config/rbac/single_tenant_clusterrole.yaml b/config/rbac/single_tenant_clusterrole.yaml
index 2662b620e..ad316b8b2 100644
--- a/config/rbac/single_tenant_clusterrole.yaml
+++ b/config/rbac/single_tenant_clusterrole.yaml
@@ -52,6 +52,8 @@ rules:
resources:
- openbaoclusters
verbs:
+ - restore
+ - usecloudidentities
- usecustomexecutables
- useimagetrustroots
- apiGroups:
diff --git a/docs/security/infrastructure/rbac.md b/docs/security/infrastructure/rbac.md
index 70d82d1fe..6ba3be3c2 100644
--- a/docs/security/infrastructure/rbac.md
+++ b/docs/security/infrastructure/rbac.md
@@ -192,44 +192,187 @@ Kubernetes RBAC controls who can read or mutate the PVC object; it does not prot
## 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`.
+Editing an `OpenBaoCluster` or `OpenBaoRestore` grants control over operator intent. It does not
+automatically grant control over every Kubernetes object, cloud identity, image trust root, or restore
+target that the manifest references.
+
+That makes tenant onboarding stricter than a plain CRD editor role. A user or GitOps identity can
+create ordinary cluster intent with the editor role, but feature-specific references need matching
+RBAC before admission accepts the manifest. This is deliberate: the API boundary should distinguish
+"may edit this OpenBao resource" from "may cause another identity or controller to consume that
+external authority."
+
+The verbs follow common Kubernetes RBAC patterns:
+
+- `get` means the requester may read object payload, especially Secret data.
+- `use` means the requester may cause another workload or controller to consume the object. OpenBao
+ Operator checks this verb in admission; Kubernetes does not enforce it for these references by
+ itself.
+- `usecloudidentities` means the requester may attach cloud identity metadata to OpenBao-managed workloads.
+- `usecustomexecutables` means the requester may choose helper, hook, plugin, backup, upgrade, or
+ restore executables that run with operator-managed identities or mounted data.
+- `useimagetrustroots` means the requester may choose custom image-verification trust roots in the
+ `Hardened` profile.
+- `restore` means the requester may run a destructive restore against 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.
+The validation messages name the missing authority. For example, an error mentioning
+`spec.gateway.gatewayRef` points to `use` on the referenced Gateway, and an error mentioning
+cloud identities points to `usecloudidentities` on the target `OpenBaoCluster`.
+
+
+`'],
+ emphasis: 'recommended',
+ },
+ {
+ cells: ['`spec.backup.target.credentialsSecretRef`, `spec.backup.tokenSecretRef`', 'Same-namespace Secret payload', '`get` on `secrets/`'],
+ },
+ {
+ cells: ['`OpenBaoRestore.spec.source.target.credentialsSecretRef`, `spec.tokenSecretRef`', 'Same-namespace Secret payload', '`get` on `secrets/`'],
+ },
+ {
+ cells: ['`spec.observability.metrics.serviceMonitor.authorization.credentialsSecret`', 'Same-namespace Secret payload', '`get` on `secrets/`'],
+ },
+ {
+ cells: ['`spec.imagePullSecrets[]`', 'Same-namespace image pull Secret consumed by kubelet', '`use` on `secrets/`; `get` is also accepted'],
+ },
+ {
+ cells: ['`spec.imageVerification.imagePullSecrets[]`, `spec.operatorImageVerification.imagePullSecrets[]`', 'Registry Secret payload read by image verification', '`get` on `secrets/` when verification is enabled'],
+ },
+ {
+ cells: ['`spec.ingress.tlsSecretName`', 'Same-namespace TLS Secret consumed by the ingress controller', '`use` on `secrets/`; `get` is also accepted'],
+ },
+ {
+ cells: ['`spec.observability.metrics.serviceMonitor.tlsConfig.caSecret`', 'Same-namespace Secret consumed by Prometheus', '`use` on `secrets/`; `get` is also accepted'],
+ },
+ {
+ cells: ['`spec.observability.metrics.serviceMonitor.tlsConfig.caConfigMap`', 'Same-namespace ConfigMap consumed by Prometheus', '`use` on `configmaps/`; `get` is also accepted'],
+ },
+ {
+ cells: ['`spec.serviceAccount.name`', 'Same-namespace ServiceAccount selected for OpenBao pods', '`use` on `serviceaccounts/`'],
+ },
+ {
+ cells: ['`spec.tls.acme.sharedCache.existingClaimName`, `spec.auditFileStorage.existingClaimName`', 'Same-namespace PersistentVolumeClaim mounted into OpenBao pods', '`use` on `persistentvolumeclaims/`'],
+ },
+ {
+ cells: ['`spec.storage.storageClassName`, read-replica storage, ACME cache storage, and audit-file storage class fields', 'Cluster-scoped StorageClass', '`use` on `storageclasses/`'],
+ },
+ {
+ cells: ['`spec.ingress.className`', 'Cluster-scoped IngressClass', '`use` on `ingressclasses/`'],
+ },
+ {
+ cells: ['`spec.gateway.gatewayRef`', 'Referenced Gateway in `gatewayRef.namespace` or the cluster namespace', '`use` on `gateways/` in the Gateway namespace'],
+ },
+ {
+ cells: ['Any `spec.serviceAccount.annotations`, plus identity-selector keys in `spec.podMetadata.annotations` or `spec.podMetadata.labels`', 'Main workload cloud identity metadata', '`usecloudidentities` on `openbaoclusters/`'],
+ },
+ {
+ cells: ['`spec.backup.target.roleArn`, `spec.backup.target.workloadIdentity.*`', 'Backup Job cloud identity metadata', '`usecloudidentities` on `openbaoclusters/`'],
+ },
+ {
+ cells: ['`OpenBaoRestore.spec.source.target.roleArn`, `spec.source.target.workloadIdentity.*`', 'Restore Job cloud identity metadata', '`usecloudidentities` on `openbaoclusters/`'],
+ },
+ {
+ cells: ['`OpenBaoRestore.spec.cluster`', 'Destructive restore target OpenBaoCluster', '`restore` on `openbaoclusters/`'],
+ emphasis: 'caution',
+ },
+ {
+ cells: ['Custom executables', '`spec.initContainer.image`, backup, upgrade, restore, blue-green hook, or plugin executable fields', '`usecustomexecutables`; `usehelperimages` remains a compatibility alias'],
+ },
+ {
+ cells: ['Custom Hardened image-verification trust roots', '`spec.imageVerification` or `spec.operatorImageVerification` trust-root fields', '`useimagetrustroots` on `openbaoclusters/`'],
+ },
+ ]}
+/>
+
+
+
+Admission checks these permissions on create and update whenever the field is present, not only when
+that field changes. A Flux or Argo ServiceAccount that manages an `OpenBaoCluster` with a Gateway,
+custom StorageClass, image pull Secret, cloud identity annotation, or restore request needs the
+matching `use`, `get`, `usecloudidentities`, or `restore` grants before unrelated GitOps updates will
+continue to apply.
+
+
+
+
## What the RBAC model guarantees
+
+
+If the backup target uses `credentialsSecretRef` or `tokenSecretRef`, the identity applying the
+`OpenBaoCluster` needs `get` on those Secrets. If the backup target sets `roleArn` or
+`workloadIdentity`, that identity needs `usecloudidentities` on the `OpenBaoCluster`. These checks
+also apply to GitOps updates while the fields remain present.
+
+
+
## First successful backup path
-
-
-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.
+
+The identity applying an `OpenBaoRestore` needs `restore` on the target `OpenBaoCluster`; this is the
+Kubernetes-side authorization for a destructive restore target. If `OpenBaoRestore.spec.image` is set,
+the same identity also needs `usecustomexecutables`. If the restore source sets `roleArn` or
+`workloadIdentity`, it needs `usecloudidentities`.
diff --git a/hack/helmchart/main.go b/hack/helmchart/main.go
index a188f0ec5..9bc09adb2 100644
--- a/hack/helmchart/main.go
+++ b/hack/helmchart/main.go
@@ -664,7 +664,7 @@ subjects:
// syncAggregatedRBAC syncs aggregated ClusterRoles.
func syncAggregatedRBAC(opts options) error {
- parts := make([]string, 0, 6) // 5 cluster roles + 1 tenant role
+ parts := make([]string, 0, 8) // 7 cluster roles + 1 tenant role
// OpenBaoCluster admin/editor/viewer and delegated dangerous-control roles.
for _, role := range []struct {
@@ -673,8 +673,10 @@ func syncAggregatedRBAC(opts options) error {
}{
{filename: "openbaocluster_admin_role.yaml", nameSuffix: "openbaocluster-admin"},
{filename: "openbaocluster_editor_role.yaml", nameSuffix: "openbaocluster-editor"},
+ {filename: "openbaocluster_cloud_identity_role.yaml", nameSuffix: "openbaocluster-cloud-identity"},
{filename: "openbaocluster_helper_image_role.yaml", nameSuffix: "openbaocluster-helper-image"},
{filename: "openbaocluster_image_trust_roots_role.yaml", nameSuffix: "openbaocluster-image-trust-roots"},
+ {filename: "openbaocluster_restore_role.yaml", nameSuffix: "openbaocluster-restore"},
{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 988db35be..fd83bc3cf 100644
--- a/hack/helmchart/main_test.go
+++ b/hack/helmchart/main_test.go
@@ -96,6 +96,12 @@ func TestSyncAggregatedRBAC_IncludesDangerousControlDelegationRoles(t *testing.T
writeRole("openbaocluster_admin_role.yaml", "openbaocluster-admin-role", "*")
writeRole("openbaocluster_editor_role.yaml", "openbaocluster-editor-role", "create", "update")
+ writeRole(
+ "openbaocluster_cloud_identity_role.yaml",
+ "openbaocluster-cloud-identity-role",
+ "get",
+ "usecloudidentities",
+ )
writeRole(
"openbaocluster_helper_image_role.yaml",
"openbaocluster-helper-image-role",
@@ -109,6 +115,12 @@ func TestSyncAggregatedRBAC_IncludesDangerousControlDelegationRoles(t *testing.T
"get",
"useimagetrustroots",
)
+ writeRole(
+ "openbaocluster_restore_role.yaml",
+ "openbaocluster-restore-role",
+ "get",
+ "restore",
+ )
writeRole("openbaocluster_viewer_role.yaml", "openbaocluster-viewer-role", "get", "list")
writeRole("openbaotenant_editor_role.yaml", "openbaotenant-editor-role", "create", "update")
@@ -124,9 +136,13 @@ func TestSyncAggregatedRBAC_IncludesDangerousControlDelegationRoles(t *testing.T
for _, want := range []string{
`{{ include "openbao-operator.fullname" . }}-openbaocluster-helper-image`,
`{{ include "openbao-operator.fullname" . }}-openbaocluster-image-trust-roots`,
+ `{{ include "openbao-operator.fullname" . }}-openbaocluster-cloud-identity`,
"usecustomexecutables",
+ `{{ include "openbao-operator.fullname" . }}-openbaocluster-restore`,
"usehelperimages",
"useimagetrustroots",
+ "usecloudidentities",
+ "restore",
} {
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 13870d1b9..cdd9bfde8 100644
--- a/internal/service/provisioner/rbac.go
+++ b/internal/service/provisioner/rbac.go
@@ -10,7 +10,7 @@ import (
var (
verbsReadOnly = []string{"get", "list", "watch"}
verbsManage = []string{"create", "delete", "get", "list", "patch", "update", "watch"}
- verbsClusterDelegation = []string{"usecustomexecutables", "useimagetrustroots"}
+ verbsClusterDelegation = []string{"restore", "usecloudidentities", "usecustomexecutables", "useimagetrustroots"}
verbsEventWrite = []string{"create", "patch"}
verbsPodManage = []string{"delete", "get", "list", "patch", "update", "watch"}
verbsServiceMonitorManage = []string{"create", "delete", "get", "patch"}
diff --git a/internal/service/provisioner/rbac_test.go b/internal/service/provisioner/rbac_test.go
index 1a1e0e3ee..052f5b8f4 100644
--- a/internal/service/provisioner/rbac_test.go
+++ b/internal/service/provisioner/rbac_test.go
@@ -91,6 +91,8 @@ func TestGenerateTenantRole(t *testing.T) {
if slices.Contains(rule.APIGroups, "openbao.org") &&
len(rule.Resources) == 1 &&
slices.Contains(rule.Resources, "openbaoclusters") &&
+ slices.Contains(rule.Verbs, "restore") &&
+ slices.Contains(rule.Verbs, "usecloudidentities") &&
slices.Contains(rule.Verbs, "usecustomexecutables") &&
slices.Contains(rule.Verbs, "useimagetrustroots") {
hasOpenBaoClusterDelegationRule = true
diff --git a/test/e2e/Security_Guardrails_test.go b/test/e2e/Security_Guardrails_test.go
index 559890c0d..815b0a4ff 100644
--- a/test/e2e/Security_Guardrails_test.go
+++ b/test/e2e/Security_Guardrails_test.go
@@ -1065,6 +1065,28 @@ var _ = Describe("Security Guardrails", Label("security", "critical"), Ordered,
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Users configuring backup credentials"))
+ err = runAsE2EGroupMember(ctx, cfg, scheme, impersonatedUser, func(c client.Client) error {
+ return c.Create(ctx, newRestore("restore-target-authz-denied"), client.DryRunAll)
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("must be authorized to restore the target OpenBaoCluster"))
+
+ restoreRole := &rbacv1.Role{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "e2e-restore-target-access",
+ Namespace: guardrailsNamespace,
+ },
+ Rules: []rbacv1.PolicyRule{
+ {
+ APIGroups: []string{"openbao.org"},
+ Resources: []string{"openbaoclusters"},
+ ResourceNames: []string{"valid-structured-config"},
+ Verbs: []string{"get", "restore"},
+ },
+ },
+ }
+ createRoleBindingForGroup(ctx, admin, guardrailsNamespace, restoreRole)
+
err = runAsE2EGroupMember(ctx, cfg, scheme, impersonatedUser, func(c client.Client) error {
return c.Create(ctx, newRestore("restore-authz-denied"), client.DryRunAll)
})
diff --git a/test/integration/crd_contract_test.go b/test/integration/crd_contract_test.go
index b1a984d25..494fe63ac 100644
--- a/test/integration/crd_contract_test.go
+++ b/test/integration/crd_contract_test.go
@@ -170,6 +170,118 @@ func grantClusterImageTrustRootsAccess(t *testing.T, namespace, clusterName, use
grantClusterOpenBaoVerbs(t, namespace, clusterName, username, "cluster-image-trust-roots-access", "useimagetrustroots")
}
+func grantClusterRestoreAccess(t *testing.T, namespace, clusterName, username string) {
+ t.Helper()
+ grantClusterOpenBaoVerbs(t, namespace, clusterName, username, "cluster-restore-access", "restore")
+}
+
+func grantClusterCloudIdentitiesAccess(t *testing.T, namespace, clusterName, username string) {
+ t.Helper()
+ grantClusterOpenBaoVerbs(t, namespace, clusterName, username, "cluster-cloud-identities-access", "usecloudidentities")
+}
+
+func grantNamespacedResourceVerbs(t *testing.T, namespace, username, roleName, apiGroup, resourceName string, resourceNames []string, verbs ...string) {
+ t.Helper()
+
+ role := &rbacv1.Role{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: "rbac.authorization.k8s.io/v1",
+ Kind: "Role",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: roleName,
+ Namespace: namespace,
+ },
+ Rules: []rbacv1.PolicyRule{
+ {
+ APIGroups: []string{apiGroup},
+ Resources: []string{resourceName},
+ ResourceNames: resourceNames,
+ Verbs: verbs,
+ },
+ },
+ }
+ if err := k8sClient.Create(ctx, role); err != nil {
+ t.Fatalf("create delegated reference role: %v", err)
+ }
+
+ binding := &rbacv1.RoleBinding{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: "rbac.authorization.k8s.io/v1",
+ Kind: "RoleBinding",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: roleName + "-binding",
+ Namespace: namespace,
+ },
+ RoleRef: rbacv1.RoleRef{
+ APIGroup: "rbac.authorization.k8s.io",
+ Kind: "Role",
+ Name: role.Name,
+ },
+ Subjects: []rbacv1.Subject{
+ {
+ Kind: "User",
+ Name: username,
+ APIGroup: "rbac.authorization.k8s.io",
+ },
+ },
+ }
+ if err := k8sClient.Create(ctx, binding); err != nil {
+ t.Fatalf("create delegated reference rolebinding: %v", err)
+ }
+}
+
+func grantClusterScopedResourceVerbs(t *testing.T, username, roleName, apiGroup, resourceName string, resourceNames []string, verbs ...string) {
+ t.Helper()
+
+ role := &rbacv1.ClusterRole{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: "rbac.authorization.k8s.io/v1",
+ Kind: "ClusterRole",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: roleName,
+ },
+ Rules: []rbacv1.PolicyRule{
+ {
+ APIGroups: []string{apiGroup},
+ Resources: []string{resourceName},
+ ResourceNames: resourceNames,
+ Verbs: verbs,
+ },
+ },
+ }
+ if err := k8sClient.Create(ctx, role); err != nil {
+ t.Fatalf("create delegated cluster reference role: %v", err)
+ }
+
+ binding := &rbacv1.ClusterRoleBinding{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: "rbac.authorization.k8s.io/v1",
+ Kind: "ClusterRoleBinding",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: roleName + "-binding",
+ },
+ RoleRef: rbacv1.RoleRef{
+ APIGroup: "rbac.authorization.k8s.io",
+ Kind: "ClusterRole",
+ Name: role.Name,
+ },
+ Subjects: []rbacv1.Subject{
+ {
+ Kind: "User",
+ Name: username,
+ APIGroup: "rbac.authorization.k8s.io",
+ },
+ },
+ }
+ if err := k8sClient.Create(ctx, binding); err != nil {
+ t.Fatalf("create delegated cluster reference rolebinding: %v", err)
+ }
+}
+
func waitForOpenBaoRestoreAdmissionPolicies(t *testing.T, namespace string) {
t.Helper()
@@ -352,6 +464,466 @@ func TestVAP_OpenBaoCluster_AllowsDefaultInitContainer(t *testing.T) {
}
}
+func TestVAP_OpenBaoCluster_RequiresReferenceUseAuthorization(t *testing.T) {
+ namespace := newTestNamespace(t)
+ waitForOpenBaoClusterAdmissionPolicies(t, namespace)
+
+ username := "reference-use-editor"
+ tenantClient := newImpersonatedClient(t, username)
+ grantTenantOpenBaoWriteAccess(t, namespace, username)
+
+ configureTrustedIngressPeers := func(cluster *openbaov1alpha1.OpenBaoCluster) {
+ cluster.Spec.Network = &openbaov1alpha1.NetworkConfig{
+ TrustedIngressPeers: []networkingv1.NetworkPolicyPeer{
+ {
+ NamespaceSelector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "kubernetes.io/metadata.name": "ingress-system",
+ },
+ },
+ },
+ },
+ }
+ }
+
+ t.Run("service-account-use", func(t *testing.T) {
+ const serviceAccountName = "custom-openbao-sa"
+
+ denied := newMinimalClusterObj(namespace, "cluster-serviceaccount-use-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.ServiceAccount = &openbaov1alpha1.ServiceAccountConfig{Name: serviceAccountName}
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "spec.serviceAccount.name") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "serviceaccount-use-access",
+ "",
+ "serviceaccounts",
+ []string{serviceAccountName},
+ "use",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-serviceaccount-use-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.ServiceAccount = &openbaov1alpha1.ServiceAccountConfig{Name: serviceAccountName}
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected service-account-use-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("image-pull-secret-use", func(t *testing.T) {
+ const secretName = "tenant-pull-secret"
+
+ denied := newMinimalClusterObj(namespace, "cluster-image-pull-secret-use-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: secretName}}
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "spec.imagePullSecrets") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "image-pull-secret-use-access",
+ "",
+ "secrets",
+ []string{secretName},
+ "use",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-image-pull-secret-use-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: secretName}}
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected image-pull-secret-use-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("ingress-class-use", func(t *testing.T) {
+ const className = "tenant-ingress-class"
+
+ denied := newMinimalClusterObj(namespace, "cluster-ingress-class-use-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.Ingress = &openbaov1alpha1.IngressConfig{
+ Enabled: true,
+ ClassName: ptr.To(className),
+ Host: "bao.example.com",
+ }
+ configureTrustedIngressPeers(denied)
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "spec.ingress.className") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantClusterScopedResourceVerbs(
+ t,
+ username,
+ "ingressclass-use-"+username,
+ "networking.k8s.io",
+ "ingressclasses",
+ []string{className},
+ "use",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-ingress-class-use-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.Ingress = &openbaov1alpha1.IngressConfig{
+ Enabled: true,
+ ClassName: ptr.To(className),
+ Host: "bao.example.com",
+ }
+ configureTrustedIngressPeers(allowed)
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected ingress-class-use-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("ingress-tls-secret-use", func(t *testing.T) {
+ const secretName = "tenant-ingress-tls"
+
+ denied := newMinimalClusterObj(namespace, "cluster-ingress-tls-secret-use-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.Ingress = &openbaov1alpha1.IngressConfig{
+ Enabled: true,
+ Host: "bao.example.com",
+ TLSSecretName: secretName,
+ }
+ configureTrustedIngressPeers(denied)
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "spec.ingress.tlsSecretName") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "ingress-tls-secret-use-access",
+ "",
+ "secrets",
+ []string{secretName},
+ "use",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-ingress-tls-secret-use-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.Ingress = &openbaov1alpha1.IngressConfig{
+ Enabled: true,
+ Host: "bao.example.com",
+ TLSSecretName: secretName,
+ }
+ configureTrustedIngressPeers(allowed)
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected ingress-tls-secret-use-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("gateway-use", func(t *testing.T) {
+ const gatewayName = "tenant-gateway"
+
+ denied := newMinimalClusterObj(namespace, "cluster-gateway-use-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.Gateway = &openbaov1alpha1.GatewayConfig{
+ Enabled: true,
+ GatewayRef: openbaov1alpha1.GatewayReference{
+ Name: gatewayName,
+ },
+ Hostname: "bao.example.com",
+ }
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "spec.gateway.gatewayRef") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "gateway-use-access",
+ "gateway.networking.k8s.io",
+ "gateways",
+ []string{gatewayName},
+ "use",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-gateway-use-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.Gateway = &openbaov1alpha1.GatewayConfig{
+ Enabled: true,
+ GatewayRef: openbaov1alpha1.GatewayReference{
+ Name: gatewayName,
+ },
+ Hostname: "bao.example.com",
+ }
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected gateway-use-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("existing-pvc-use", func(t *testing.T) {
+ const pvcName = "tenant-audit-pvc"
+
+ denied := newMinimalClusterObj(namespace, "cluster-existing-pvc-use-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.AuditFileStorage = &openbaov1alpha1.AuditFileStorageConfig{
+ Mode: openbaov1alpha1.AuditFileStorageModeExistingPVC,
+ ExistingClaimName: pvcName,
+ }
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "existing PVC references") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "existing-pvc-use-access",
+ "",
+ "persistentvolumeclaims",
+ []string{pvcName},
+ "use",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-existing-pvc-use-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.AuditFileStorage = &openbaov1alpha1.AuditFileStorageConfig{
+ Mode: openbaov1alpha1.AuditFileStorageModeExistingPVC,
+ ExistingClaimName: pvcName,
+ }
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected existing-pvc-use-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("storage-class-use", func(t *testing.T) {
+ const storageClassName = "tenant-fast-storage"
+
+ denied := newMinimalClusterObj(namespace, "cluster-storageclass-use-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.Storage.StorageClassName = ptr.To(storageClassName)
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "StorageClass references") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantClusterScopedResourceVerbs(
+ t,
+ username,
+ "storageclass-use-"+username,
+ "storage.k8s.io",
+ "storageclasses",
+ []string{storageClassName},
+ "use",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-storageclass-use-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.Storage.StorageClassName = ptr.To(storageClassName)
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected storage-class-use-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("image-verification-pull-secret-get", func(t *testing.T) {
+ const secretName = "tenant-verification-pull-secret"
+
+ denied := newMinimalClusterObj(namespace, "cluster-image-verification-pull-secret-get-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.ImageVerification = &openbaov1alpha1.ImageVerificationConfig{
+ Enabled: true,
+ FailurePolicy: "Block",
+ ImagePullSecrets: []corev1.LocalObjectReference{{Name: secretName}},
+ }
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "image verification pull Secrets") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "image-verification-pull-secret-get-access",
+ "",
+ "secrets",
+ []string{secretName},
+ "get",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-image-verification-pull-secret-get-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.ImageVerification = &openbaov1alpha1.ImageVerificationConfig{
+ Enabled: true,
+ FailurePolicy: "Block",
+ ImagePullSecrets: []corev1.LocalObjectReference{{Name: secretName}},
+ }
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected image-verification-pull-secret-get-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("servicemonitor-tls-secret-use", func(t *testing.T) {
+ const secretName = "tenant-servicemonitor-ca"
+
+ denied := newMinimalClusterObj(namespace, "cluster-servicemonitor-tls-secret-use-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.Observability = &openbaov1alpha1.ObservabilityConfig{
+ Metrics: &openbaov1alpha1.MetricsConfig{
+ Enabled: true,
+ ServiceMonitor: &openbaov1alpha1.ServiceMonitorConfig{
+ Enabled: true,
+ TLSConfig: &openbaov1alpha1.ServiceMonitorTLSConfig{
+ CASecret: &openbaov1alpha1.ServiceMonitorKeySelector{
+ Name: secretName,
+ Key: "ca.crt",
+ },
+ },
+ },
+ },
+ }
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "ServiceMonitor TLS references") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "servicemonitor-tls-secret-use-access",
+ "",
+ "secrets",
+ []string{secretName},
+ "use",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-servicemonitor-tls-secret-use-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.Observability = &openbaov1alpha1.ObservabilityConfig{
+ Metrics: &openbaov1alpha1.MetricsConfig{
+ Enabled: true,
+ ServiceMonitor: &openbaov1alpha1.ServiceMonitorConfig{
+ Enabled: true,
+ TLSConfig: &openbaov1alpha1.ServiceMonitorTLSConfig{
+ CASecret: &openbaov1alpha1.ServiceMonitorKeySelector{
+ Name: secretName,
+ Key: "ca.crt",
+ },
+ },
+ },
+ },
+ }
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected servicemonitor-tls-secret-use-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("backup-credentials-secret-get", func(t *testing.T) {
+ const secretName = "tenant-backup-creds"
+
+ denied := newMinimalClusterObj(namespace, "cluster-backup-credentials-secret-get-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.Backup = &openbaov1alpha1.BackupSchedule{
+ Schedule: "0 0 * * *",
+ JWTAuthRole: "backup-role",
+ Target: openbaov1alpha1.BackupTarget{
+ Provider: "s3",
+ Endpoint: "https://objectstore.example.com",
+ Bucket: testBackupBucket,
+ CredentialsSecretRef: &corev1.LocalObjectReference{
+ Name: secretName,
+ },
+ },
+ }
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "backup credentials") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "backup-credentials-secret-get-access",
+ "",
+ "secrets",
+ []string{secretName},
+ "get",
+ )
+
+ allowed := newMinimalClusterObj(namespace, "cluster-backup-credentials-secret-get-allowed")
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.Backup = &openbaov1alpha1.BackupSchedule{
+ Schedule: "0 0 * * *",
+ JWTAuthRole: "backup-role",
+ Target: openbaov1alpha1.BackupTarget{
+ Provider: "s3",
+ Endpoint: "https://objectstore.example.com",
+ Bucket: testBackupBucket,
+ CredentialsSecretRef: &corev1.LocalObjectReference{
+ Name: secretName,
+ },
+ },
+ }
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected backup-credentials-secret-get-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+ })
+}
+
+func TestVAP_OpenBaoCluster_RequiresCloudIdentityAuthorization(t *testing.T) {
+ namespace := newTestNamespace(t)
+ waitForOpenBaoClusterAdmissionPolicies(t, namespace)
+
+ username := "cloud-identity-editor"
+ tenantClient := newImpersonatedClient(t, username)
+ grantTenantOpenBaoWriteAccess(t, namespace, username)
+
+ denied := newMinimalClusterObj(namespace, "cluster-cloud-identity-denied")
+ denied.Spec.InitContainer = nil
+ denied.Spec.ServiceAccount = &openbaov1alpha1.ServiceAccountConfig{
+ Annotations: map[string]string{
+ "iam.amazonaws.com/role": "openbao-runtime",
+ },
+ }
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "use cloud identities") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ clusterName := "cluster-cloud-identity-allowed"
+ grantClusterCloudIdentitiesAccess(t, namespace, clusterName, username)
+ allowed := newMinimalClusterObj(namespace, clusterName)
+ allowed.Spec.InitContainer = nil
+ allowed.Spec.ServiceAccount = &openbaov1alpha1.ServiceAccountConfig{
+ Annotations: map[string]string{
+ "iam.amazonaws.com/role": "openbao-runtime",
+ },
+ }
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected cloud-identity-authorized OpenBaoCluster create to succeed, got: %v", err)
+ }
+}
+
func TestVAP_OpenBaoCluster_RejectsOIDCBootstrapWithoutSelfInitEnabled(t *testing.T) {
namespace := newTestNamespace(t)
waitForOpenBaoClusterAdmissionPolicies(t, namespace)
@@ -1312,12 +1884,149 @@ func TestVAP_OpenBaoCluster_RejectsBackupEndpointSSRFBypasses(t *testing.T) {
}
}
+func TestVAP_OpenBaoRestore_DeniesRestoreWithoutTargetClusterRestoreVerb(t *testing.T) {
+ namespace := newTestNamespace(t)
+ waitForOpenBaoRestoreAdmissionPolicies(t, namespace)
+
+ username := "restore-target-editor"
+ grantTenantOpenBaoWriteAccess(t, namespace, username)
+ tenantClient := newImpersonatedClient(t, username)
+
+ restore := &openbaov1alpha1.OpenBaoRestore{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "restore-target-denied",
+ Namespace: namespace,
+ },
+ Spec: openbaov1alpha1.OpenBaoRestoreSpec{
+ Cluster: "target-cluster",
+ Source: openbaov1alpha1.RestoreSource{
+ Target: openbaov1alpha1.BackupTarget{
+ Provider: "s3",
+ Endpoint: "https://objectstore.example.com",
+ Bucket: testBackupBucket,
+ },
+ Key: "clusters/prod/snapshot.snap",
+ },
+ JWTAuthRole: "restore-role",
+ Force: true,
+ },
+ }
+
+ err := tenantClient.Create(ctx, restore)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "must be authorized to restore the target OpenBaoCluster") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+}
+
+func TestVAP_OpenBaoRestore_RequiresReferenceAuthorization(t *testing.T) {
+ namespace := newTestNamespace(t)
+ waitForOpenBaoRestoreAdmissionPolicies(t, namespace)
+
+ username := "restore-reference-editor"
+ clusterName := "target-cluster"
+ grantTenantOpenBaoWriteAccess(t, namespace, username)
+ grantClusterRestoreAccess(t, namespace, clusterName, username)
+ tenantClient := newImpersonatedClient(t, username)
+
+ t.Run("restore-credentials-secret-get", func(t *testing.T) {
+ const credentialsSecretName = "tenant-restore-creds"
+ const tokenSecretName = "tenant-restore-token"
+
+ denied := &openbaov1alpha1.OpenBaoRestore{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "restore-credentials-secret-get-denied",
+ Namespace: namespace,
+ },
+ Spec: openbaov1alpha1.OpenBaoRestoreSpec{
+ Cluster: clusterName,
+ Source: openbaov1alpha1.RestoreSource{
+ Target: openbaov1alpha1.BackupTarget{
+ Provider: "s3",
+ Endpoint: "https://objectstore.example.com",
+ Bucket: testBackupBucket,
+ CredentialsSecretRef: &corev1.LocalObjectReference{
+ Name: credentialsSecretName,
+ },
+ },
+ Key: "clusters/prod/snapshot.snap",
+ },
+ TokenSecretRef: &corev1.LocalObjectReference{
+ Name: tokenSecretName,
+ },
+ Force: true,
+ },
+ }
+
+ err := tenantClient.Create(ctx, denied, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "restore credentials") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantNamespacedResourceVerbs(
+ t,
+ namespace,
+ username,
+ "restore-credentials-secret-get-access",
+ "",
+ "secrets",
+ []string{credentialsSecretName, tokenSecretName},
+ "get",
+ )
+
+ allowed := denied.DeepCopy()
+ allowed.Name = "restore-credentials-secret-get-allowed"
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected restore-credentials-secret-get-authorized OpenBaoRestore create to succeed, got: %v", err)
+ }
+ })
+
+ t.Run("restore-cloud-identity", func(t *testing.T) {
+ restore := &openbaov1alpha1.OpenBaoRestore{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "restore-cloud-identity-denied",
+ Namespace: namespace,
+ },
+ Spec: openbaov1alpha1.OpenBaoRestoreSpec{
+ Cluster: clusterName,
+ Source: openbaov1alpha1.RestoreSource{
+ Target: openbaov1alpha1.BackupTarget{
+ Provider: "s3",
+ Endpoint: "https://objectstore.example.com",
+ Bucket: testBackupBucket,
+ RoleARN: "arn:aws:iam::123456789012:role/openbao-restore",
+ },
+ Key: "clusters/prod/snapshot.snap",
+ },
+ JWTAuthRole: "restore-role",
+ Force: true,
+ },
+ }
+
+ err := tenantClient.Create(ctx, restore, client.DryRunAll)
+ requireAdmissionDenied(t, err)
+ if !strings.Contains(err.Error(), "use cloud identities") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+
+ grantClusterCloudIdentitiesAccess(t, namespace, clusterName, username)
+
+ allowed := restore.DeepCopy()
+ allowed.Name = "restore-cloud-identity-allowed"
+ if err := tenantClient.Create(ctx, allowed, client.DryRunAll); err != nil {
+ t.Fatalf("expected restore-cloud-identity-authorized OpenBaoRestore create to succeed, got: %v", err)
+ }
+ })
+}
+
func TestVAP_OpenBaoRestore_DeniesCustomImageWithoutHelperImageVerb(t *testing.T) {
namespace := newTestNamespace(t)
waitForOpenBaoRestoreAdmissionPolicies(t, namespace)
username := "restore-image-editor"
grantTenantOpenBaoWriteAccess(t, namespace, username)
+ grantClusterRestoreAccess(t, namespace, "target-cluster", username)
tenantClient := newImpersonatedClient(t, username)
restore := &openbaov1alpha1.OpenBaoRestore{
@@ -1356,6 +2065,7 @@ func TestVAP_OpenBaoRestore_AllowsCustomImageWithHelperImageVerb(t *testing.T) {
clusterName := "target-cluster"
grantTenantOpenBaoWriteAccess(t, namespace, username)
grantClusterHelperImageAccess(t, namespace, clusterName, username)
+ grantClusterRestoreAccess(t, namespace, clusterName, username)
tenantClient := newImpersonatedClient(t, username)
restore := &openbaov1alpha1.OpenBaoRestore{
@@ -1415,6 +2125,7 @@ func TestVAP_OpenBaoRestore_DeniesUnchangedCustomImageUpdateWithoutCustomExecuta
username := "restore-image-standard-editor"
grantTenantOpenBaoWriteAccess(t, namespace, username)
+ grantClusterRestoreAccess(t, namespace, clusterName, username)
tenantClient := newImpersonatedClient(t, username)
var latest openbaov1alpha1.OpenBaoRestore
diff --git a/test/integration/kustomize_contract_test.go b/test/integration/kustomize_contract_test.go
index b9c5d41cc..fa3e1a010 100644
--- a/test/integration/kustomize_contract_test.go
+++ b/test/integration/kustomize_contract_test.go
@@ -380,6 +380,15 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T
var foundServiceMonitorSecretAuthorizer bool
var foundCustomExecutablesAuthorizer bool
var foundImageTrustRootsAuthorizer bool
+ var foundCloudIdentityAuthorizer bool
+ var foundServiceAccountUseAuthorizer bool
+ var foundImagePullSecretUseAuthorizer bool
+ var foundIngressTLSSecretAuthorizer bool
+ var foundGatewayUseAuthorizer bool
+ var foundPVCUseAuthorizer bool
+ var foundStorageClassUseAuthorizer bool
+ var foundImageVerificationPullSecretAuthorizer bool
+ var foundServiceMonitorTLSReferenceAuthorizer bool
var foundSystemSecretBlock bool
for _, validation := range validations {
validationMap, ok := validation.(map[string]any)
@@ -429,6 +438,46 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T
strings.Contains(expression, `variables.has_custom_operator_image_trust_roots`) &&
strings.Contains(expression, `variables.image_trust_roots_authorized`):
foundImageTrustRootsAuthorizer = true
+ case strings.Contains(message, "use cloud identities") &&
+ strings.Contains(expression, `variables.has_cloud_identity_metadata`) &&
+ strings.Contains(expression, `variables.cloud_identities_authorized`):
+ foundCloudIdentityAuthorizer = true
+ case strings.Contains(message, "spec.serviceAccount.name") &&
+ strings.Contains(expression, `resource("serviceaccounts")`) &&
+ strings.Contains(expression, `check("use")`):
+ foundServiceAccountUseAuthorizer = true
+ case strings.Contains(message, "spec.imagePullSecrets") &&
+ strings.Contains(expression, `resource("secrets")`) &&
+ strings.Contains(expression, `check("use")`) &&
+ strings.Contains(expression, `check("get")`):
+ foundImagePullSecretUseAuthorizer = true
+ case strings.Contains(message, "spec.ingress.tlsSecretName") &&
+ strings.Contains(expression, `resource("secrets")`) &&
+ strings.Contains(expression, `check("use")`) &&
+ strings.Contains(expression, `check("get")`):
+ foundIngressTLSSecretAuthorizer = true
+ case strings.Contains(message, "spec.gateway.gatewayRef") &&
+ strings.Contains(expression, `resource("gateways")`) &&
+ strings.Contains(expression, `check("use")`):
+ foundGatewayUseAuthorizer = true
+ case strings.Contains(message, "existing PVC references") &&
+ strings.Contains(expression, `resource("persistentvolumeclaims")`) &&
+ strings.Contains(expression, `check("use")`):
+ foundPVCUseAuthorizer = true
+ case strings.Contains(message, "StorageClass references") &&
+ strings.Contains(expression, `resource("storageclasses")`) &&
+ strings.Contains(expression, `check("use")`):
+ foundStorageClassUseAuthorizer = true
+ case strings.Contains(message, "image verification pull Secrets") &&
+ strings.Contains(expression, `resource("secrets")`) &&
+ strings.Contains(expression, `check("get")`):
+ foundImageVerificationPullSecretAuthorizer = true
+ case strings.Contains(message, "ServiceMonitor TLS references") &&
+ strings.Contains(expression, `resource("secrets")`) &&
+ strings.Contains(expression, `resource("configmaps")`) &&
+ strings.Contains(expression, `check("use")`) &&
+ strings.Contains(expression, `check("get")`):
+ foundServiceMonitorTLSReferenceAuthorizer = true
case strings.Contains(message, "system secrets") &&
strings.Contains(expression, "object.spec.unseal.credentialsSecretRef") &&
strings.Contains(expression, "object.spec.observability.metrics.serviceMonitor.authorization.credentialsSecret") &&
@@ -444,9 +493,18 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T
!foundServiceMonitorSecretAuthorizer ||
!foundCustomExecutablesAuthorizer ||
!foundImageTrustRootsAuthorizer ||
+ !foundCloudIdentityAuthorizer ||
+ !foundServiceAccountUseAuthorizer ||
+ !foundImagePullSecretUseAuthorizer ||
+ !foundIngressTLSSecretAuthorizer ||
+ !foundGatewayUseAuthorizer ||
+ !foundPVCUseAuthorizer ||
+ !foundStorageClassUseAuthorizer ||
+ !foundImageVerificationPullSecretAuthorizer ||
+ !foundServiceMonitorTLSReferenceAuthorizer ||
!foundSystemSecretBlock {
t.Fatalf(
- "openbao-validate-openbaocluster protections missing: https=%v unsafeURL=%v transitAuthorizer=%v backupAuthorizer=%v serviceMonitorAuthorizer=%v executableCodeAuthorizer=%v imageTrustRootsAuthorizer=%v systemSecret=%v",
+ "openbao-validate-openbaocluster protections missing: https=%v unsafeURL=%v transitAuthorizer=%v backupAuthorizer=%v serviceMonitorAuthorizer=%v executableCodeAuthorizer=%v imageTrustRootsAuthorizer=%v cloudIdentityAuthorizer=%v serviceAccountUseAuthorizer=%v imagePullSecretUseAuthorizer=%v ingressTLSSecretAuthorizer=%v gatewayUseAuthorizer=%v pvcUseAuthorizer=%v storageClassUseAuthorizer=%v imageVerificationPullSecretAuthorizer=%v serviceMonitorTLSReferenceAuthorizer=%v systemSecret=%v",
foundHTTPS,
foundUnsafeURLComponents,
foundSecretAuthorizer,
@@ -454,6 +512,15 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T
foundServiceMonitorSecretAuthorizer,
foundCustomExecutablesAuthorizer,
foundImageTrustRootsAuthorizer,
+ foundCloudIdentityAuthorizer,
+ foundServiceAccountUseAuthorizer,
+ foundImagePullSecretUseAuthorizer,
+ foundIngressTLSSecretAuthorizer,
+ foundGatewayUseAuthorizer,
+ foundPVCUseAuthorizer,
+ foundStorageClassUseAuthorizer,
+ foundImageVerificationPullSecretAuthorizer,
+ foundServiceMonitorTLSReferenceAuthorizer,
foundSystemSecretBlock,
)
}
@@ -464,6 +531,7 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T
}
var foundCustomExecutablesVariable bool
var foundImageTrustRootsVariable bool
+ var foundCloudIdentitiesVariable bool
for _, variable := range variables {
variableMap, ok := variable.(map[string]any)
if !ok {
@@ -477,13 +545,16 @@ func TestKustomizeDefault_OpenBaoClusterPolicyProtectsTransitUnseal(t *testing.T
strings.Contains(expression, `check("usehelperimages")`)
case "image_trust_roots_authorized":
foundImageTrustRootsVariable = strings.Contains(expression, `check("useimagetrustroots")`)
+ case "cloud_identities_authorized":
+ foundCloudIdentitiesVariable = strings.Contains(expression, `check("usecloudidentities")`)
}
}
- if !foundCustomExecutablesVariable || !foundImageTrustRootsVariable {
+ if !foundCustomExecutablesVariable || !foundImageTrustRootsVariable || !foundCloudIdentitiesVariable {
t.Fatalf(
- "openbao-validate-openbaocluster delegation variables missing: customExecutables=%v trustRoots=%v",
+ "openbao-validate-openbaocluster delegation variables missing: customExecutables=%v trustRoots=%v cloudIdentities=%v",
foundCustomExecutablesVariable,
foundImageTrustRootsVariable,
+ foundCloudIdentitiesVariable,
)
}
}
@@ -508,6 +579,8 @@ func TestKustomizeDefault_OpenBaoRestorePolicyProtectsSecretRefs(t *testing.T) {
var foundRestoreSecretAuthorizer bool
var foundRestoreHelperImageAuthorizer bool
+ var foundRestoreTargetAuthorizer bool
+ var foundRestoreCloudIdentityAuthorizer bool
var foundSystemSecretBlock bool
for _, validation := range validations {
validationMap, ok := validation.(map[string]any)
@@ -528,6 +601,13 @@ func TestKustomizeDefault_OpenBaoRestorePolicyProtectsSecretRefs(t *testing.T) {
strings.Contains(expression, `object.spec.image`) &&
strings.Contains(expression, `variables.custom_executables_authorized`):
foundRestoreHelperImageAuthorizer = true
+ case strings.Contains(message, "must be authorized to restore the target OpenBaoCluster") &&
+ strings.Contains(expression, `variables.restore_authorized`):
+ foundRestoreTargetAuthorizer = true
+ case strings.Contains(message, "restore roleArn or workloadIdentity metadata") &&
+ strings.Contains(expression, `variables.has_restore_cloud_identity_metadata`) &&
+ strings.Contains(expression, `variables.cloud_identities_authorized`):
+ foundRestoreCloudIdentityAuthorizer = true
case strings.Contains(message, "system secrets") &&
strings.Contains(expression, "object.spec.source.target.credentialsSecretRef") &&
strings.Contains(expression, "object.spec.tokenSecretRef") &&
@@ -536,11 +616,17 @@ func TestKustomizeDefault_OpenBaoRestorePolicyProtectsSecretRefs(t *testing.T) {
}
}
- if !foundRestoreSecretAuthorizer || !foundRestoreHelperImageAuthorizer || !foundSystemSecretBlock {
+ if !foundRestoreSecretAuthorizer ||
+ !foundRestoreHelperImageAuthorizer ||
+ !foundRestoreTargetAuthorizer ||
+ !foundRestoreCloudIdentityAuthorizer ||
+ !foundSystemSecretBlock {
t.Fatalf(
- "openbao-validate-openbaorestore protections missing: authorizer=%v helperImageAuthorizer=%v systemSecret=%v",
+ "openbao-validate-openbaorestore protections missing: authorizer=%v helperImageAuthorizer=%v restoreTargetAuthorizer=%v restoreCloudIdentityAuthorizer=%v systemSecret=%v",
foundRestoreSecretAuthorizer,
foundRestoreHelperImageAuthorizer,
+ foundRestoreTargetAuthorizer,
+ foundRestoreCloudIdentityAuthorizer,
foundSystemSecretBlock,
)
}
@@ -550,6 +636,8 @@ func TestKustomizeDefault_OpenBaoRestorePolicyProtectsSecretRefs(t *testing.T) {
t.Fatalf("read policy variables: found=%v err=%v", found, err)
}
var foundCustomExecutablesVariable bool
+ var foundCloudIdentitiesVariable bool
+ var foundRestoreVariable bool
for _, variable := range variables {
variableMap, ok := variable.(map[string]any)
if !ok {
@@ -561,11 +649,23 @@ func TestKustomizeDefault_OpenBaoRestorePolicyProtectsSecretRefs(t *testing.T) {
foundCustomExecutablesVariable = strings.Contains(expression, `object.spec.cluster`) &&
strings.Contains(expression, `check("usecustomexecutables")`) &&
strings.Contains(expression, `check("usehelperimages")`)
- break
+ }
+ if name == "cloud_identities_authorized" {
+ foundCloudIdentitiesVariable = strings.Contains(expression, `object.spec.cluster`) &&
+ strings.Contains(expression, `check("usecloudidentities")`)
+ }
+ if name == "restore_authorized" {
+ foundRestoreVariable = strings.Contains(expression, `object.spec.cluster`) &&
+ strings.Contains(expression, `check("restore")`)
}
}
- if !foundCustomExecutablesVariable {
- t.Fatalf("openbao-validate-openbaorestore custom executables delegation variable missing")
+ if !foundCustomExecutablesVariable || !foundCloudIdentitiesVariable || !foundRestoreVariable {
+ t.Fatalf(
+ "openbao-validate-openbaorestore delegation variables missing: customExecutables=%v cloudIdentities=%v restore=%v",
+ foundCustomExecutablesVariable,
+ foundCloudIdentitiesVariable,
+ foundRestoreVariable,
+ )
}
}
@@ -847,7 +947,7 @@ func TestKustomizeSingleTenantOverlay_BakesInNamespaceScopeAndRemovesProvisioner
singleTenantRole,
"openbao.org",
"openbaoclusters",
- []string{"usecustomexecutables", "useimagetrustroots"},
+ []string{"restore", "usecloudidentities", "usecustomexecutables", "useimagetrustroots"},
)
subjects, found, err := unstructured.NestedSlice(singleTenantBinding.Object, "subjects")
diff --git a/test/integration/kustomize_custom_identity_test.go b/test/integration/kustomize_custom_identity_test.go
index 304ffc0ae..08868f4d1 100644
--- a/test/integration/kustomize_custom_identity_test.go
+++ b/test/integration/kustomize_custom_identity_test.go
@@ -66,6 +66,7 @@ func TestKustomizeCustomIdentityOverlay_RewritesOperatorIdentityFields(t *testin
"demo-openbao-operator-openbao-restrict-provisioner-tenant-governance",
)
tenantPolicy := mustFindPolicy(t, objs, "demo-openbao-operator-openbao-validate-openbao-tenant")
+ openBaoClusterPolicy := mustFindPolicy(t, objs, "demo-openbao-operator-openbao-validate-openbaocluster")
if got := envVarValue(t, controller, "OPERATOR_SERVICE_ACCOUNT_NAME"); got != testPrefixedControllerSA {
t.Fatalf("controller OPERATOR_SERVICE_ACCOUNT_NAME=%q, want %q", got, testPrefixedControllerSA)
@@ -151,6 +152,20 @@ func TestKustomizeCustomIdentityOverlay_RewritesOperatorIdentityFields(t *testin
if got := policyVariableExpression(t, tenantPolicy, "operator_namespace"); got != testQuotedCustomOperatorNS {
t.Fatalf("tenant policy operator_namespace=%q, want %q", got, testQuotedCustomOperatorNS)
}
+ if got := policyVariableExpression(
+ t,
+ openBaoClusterPolicy,
+ "operator_namespace",
+ ); got != testQuotedCustomOperatorNS {
+ t.Fatalf("openbaocluster policy operator_namespace=%q, want %q", got, testQuotedCustomOperatorNS)
+ }
+ if got := policyVariableExpression(
+ t,
+ openBaoClusterPolicy,
+ "controller_serviceaccount_name",
+ ); got != testQuotedPrefixedCtrlSA {
+ t.Fatalf("openbaocluster policy controller_serviceaccount_name=%q, want %q", got, testQuotedPrefixedCtrlSA)
+ }
}
func TestKustomizeSingleTenantOverlay_CustomOperatorAndTargetNamespace(t *testing.T) {
@@ -291,6 +306,7 @@ func TestKustomizeSingleTenantCustomIdentityOverlay_RewritesControllerIdentityAn
objs,
"demo-openbao-operator-openbao-restrict-controller-serviceaccounts",
)
+ openBaoClusterPolicy := mustFindPolicy(t, objs, "demo-openbao-operator-openbao-validate-openbaocluster")
operatorNS := mustFindObject(t, objs, "v1", "Namespace", testCustomOperatorNS)
if operatorNS == nil {
@@ -333,6 +349,20 @@ func TestKustomizeSingleTenantCustomIdentityOverlay_RewritesControllerIdentityAn
testQuotedPrefixedCtrlSA,
)
}
+ if got := policyVariableExpression(
+ t,
+ openBaoClusterPolicy,
+ "operator_namespace",
+ ); got != testQuotedCustomOperatorNS {
+ t.Fatalf("openbaocluster policy operator_namespace=%q, want %q", got, testQuotedCustomOperatorNS)
+ }
+ if got := policyVariableExpression(
+ t,
+ openBaoClusterPolicy,
+ "controller_serviceaccount_name",
+ ); got != testQuotedPrefixedCtrlSA {
+ t.Fatalf("openbaocluster policy controller_serviceaccount_name=%q, want %q", got, testQuotedPrefixedCtrlSA)
+ }
subjects, found, err := unstructured.NestedSlice(roleBinding.Object, "subjects")
if err != nil || !found || len(subjects) != 1 {
diff --git a/test/utils/policy_restrict_provisioner_rbac_test.go b/test/utils/policy_restrict_provisioner_rbac_test.go
index f04d86392..e4a05bd5f 100644
--- a/test/utils/policy_restrict_provisioner_rbac_test.go
+++ b/test/utils/policy_restrict_provisioner_rbac_test.go
@@ -82,7 +82,7 @@ func TestRestrictProvisionerRBACPolicyAllowsControllerDelegationRule(t *testing.
policy := string(data)
required := []string{
"rule.resources[0] == 'openbaoclusters'",
- "rule.verbs.all(v, v in ['usecustomexecutables', 'useimagetrustroots'])",
+ "rule.verbs.all(v, v in ['restore', 'usecloudidentities', 'usecustomexecutables', 'useimagetrustroots'])",
}
for _, needle := range required {
if !containsString(policy, needle) {