From 4a4662dd9af7f0af10befb2ea6b5406ad26d5ba4 Mon Sep 17 00:00:00 2001 From: Roel de Cort Date: Sat, 6 Jun 2026 21:32:27 +0200 Subject: [PATCH 1/4] feat(security): enforce reference authorization on CR fields Signed-off-by: Roel de Cort --- .../templates/admission/provisioner-rbac.yaml | 2 +- .../admission/validate-openbaocluster.yaml | 163 ++++++++++++++++++ .../admission/validate-openbaorestore.yaml | 26 +++ .../rbac/aggregated-clusterroles.yaml | 32 ++++ .../rbac/single-tenant-clusterrole.yaml | 2 + config/default/kustomization.yaml | 20 +++ .../custom-identity/kustomization.yaml | 20 +++ .../kustomization.yaml | 20 +++ .../single_tenant_clusterrole.yaml | 2 + .../single_tenant_clusterrole.yaml | 2 + .../openbao-restrict-provisioner-rbac.yaml | 2 +- .../openbao-validate-openbaocluster.yaml | 163 ++++++++++++++++++ .../openbao-validate-openbaorestore.yaml | 26 +++ config/rbac/kustomization.yaml | 2 + config/rbac/namespace_scoped_example.yaml | 82 +++++++++ .../openbaocluster_cloud_identity_role.yaml | 23 +++ config/rbac/openbaocluster_restore_role.yaml | 23 +++ config/rbac/single_tenant_clusterrole.yaml | 2 + hack/helmchart/main.go | 4 +- internal/service/provisioner/rbac.go | 2 +- 20 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 config/rbac/openbaocluster_cloud_identity_role.yaml create mode 100644 config/rbac/openbaocluster_restore_role.yaml 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/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/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"} From 1b9071ded43b286443c966943f7f0da295278e10 Mon Sep 17 00:00:00 2001 From: Roel de Cort Date: Sat, 6 Jun 2026 21:32:53 +0200 Subject: [PATCH 2/4] test(security): cover reference authorization contracts Signed-off-by: Roel de Cort --- hack/helmchart/main_test.go | 16 + internal/service/provisioner/rbac_test.go | 2 + test/e2e/Security_Guardrails_test.go | 22 + test/integration/crd_contract_test.go | 711 ++++++++++++++++++ test/integration/kustomize_contract_test.go | 118 ++- .../kustomize_custom_identity_test.go | 30 + .../policy_restrict_provisioner_rbac_test.go | 2 +- 7 files changed, 891 insertions(+), 10 deletions(-) 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_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) { From cb6179d664ae3c88b70b2f281060f454cc5dc9fe Mon Sep 17 00:00:00 2001 From: Roel de Cort Date: Sat, 6 Jun 2026 21:33:10 +0200 Subject: [PATCH 3/4] docs(security): document reference authorization grants Signed-off-by: Roel de Cort --- docs/security/infrastructure/rbac.md | 94 ++++++++++++++++++----- docs/user-guide/openbaorestore/restore.md | 8 +- 2 files changed, 79 insertions(+), 23 deletions(-) diff --git a/docs/security/infrastructure/rbac.md b/docs/security/infrastructure/rbac.md index 70d82d1fe..0ca26468f 100644 --- a/docs/security/infrastructure/rbac.md +++ b/docs/security/infrastructure/rbac.md @@ -192,44 +192,98 @@ 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` is not enough when the manifest asks another +identity or controller to use an external object. Admission checks the requester against every +referenced authority while the field is present. + +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. +- `usecloudidentities` means the requester may attach cloud identity metadata to OpenBao-managed workloads. +- `restore` means the requester may run a destructive restore against the target `OpenBaoCluster`. `'], emphasis: 'recommended', }, { - cells: [ - '`usehelperimages`', - 'Compatibility alias for existing delegated helper-image RBAC.', - 'Existing bindings can remain in place, but new bindings should use `usecustomexecutables`.', - ], + cells: ['`spec.backup.target.credentialsSecretRef`, `spec.backup.tokenSecretRef`', 'Same-namespace Secret payload', '`get` on `secrets/`'], }, { - cells: [ - '`useimagetrustroots`', - 'Custom `spec.imageVerification` or `spec.operatorImageVerification` trust-root material in the `Hardened` profile.', - 'Identities trusted to decide which image signers are acceptable for Hardened clusters.', - ], + 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/`'], }, ]} /> - + -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. +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 `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`. From a378e56c6a951f0b09a664b941850694bd6a0bfb Mon Sep 17 00:00:00 2001 From: Roel de Cort Date: Sat, 6 Jun 2026 21:39:12 +0200 Subject: [PATCH 4/4] docs(security): clarify reference authorization onboarding Signed-off-by: Roel de Cort --- docs/security/infrastructure/rbac.md | 99 ++++++++++++++++++- .../openbaocluster/operations/backups.md | 9 ++ .../operations/production-checklist.md | 7 +- 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/docs/security/infrastructure/rbac.md b/docs/security/infrastructure/rbac.md index 0ca26468f..6ba3be3c2 100644 --- a/docs/security/infrastructure/rbac.md +++ b/docs/security/infrastructure/rbac.md @@ -192,20 +192,109 @@ Kubernetes RBAC controls who can read or mutate the PVC object; it does not prot ## CR author delegation -Editing an `OpenBaoCluster` or `OpenBaoRestore` is not enough when the manifest asks another -identity or controller to use an external object. Admission checks the requester against every -referenced authority while the field is present. +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. +- `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 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`. + + + + + + + + +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