diff --git a/platform/crossplane-claims/blueprint.yaml b/platform/crossplane-claims/blueprint.yaml index acc41d30..8c169c2d 100644 --- a/platform/crossplane-claims/blueprint.yaml +++ b/platform/crossplane-claims/blueprint.yaml @@ -5,7 +5,7 @@ metadata: labels: catalyst.openova.io/section: pts-3-2-gitops-and-iac spec: - version: 1.0.0 + version: 1.1.0 card: title: crossplane-claims summary: | diff --git a/platform/crossplane-claims/chart/Chart.yaml b/platform/crossplane-claims/chart/Chart.yaml index 95ed9d78..37c1aecd 100644 --- a/platform/crossplane-claims/chart/Chart.yaml +++ b/platform/crossplane-claims/chart/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: bp-crossplane-claims -version: 1.0.0 +version: 1.1.0 description: | Catalyst Crossplane XRDs + Compositions Blueprint. Carries ONLY the apiextensions.crossplane.io/v1 CompositeResourceDefinition and diff --git a/platform/crossplane-claims/chart/templates/clusterroles.yaml b/platform/crossplane-claims/chart/templates/clusterroles.yaml new file mode 100644 index 00000000..3817a979 --- /dev/null +++ b/platform/crossplane-claims/chart/templates/clusterroles.yaml @@ -0,0 +1,152 @@ +{{- if .Values.userAccess.enabled }} +# Canonical Sovereign-IAM ClusterRoles — openova:application-{admin,editor,viewer}. +# +# Every Sovereign cluster receives these three ClusterRoles via +# bp-crossplane-claims. The UserAccess Composition's RoleBinding roleRef +# resolves to one of them. +# +# Per docs/INVIOLABLE-PRINCIPLES.md: +# #3 ClusterRoles are Helm-installed (Flux reconciles bp-crossplane-claims), +# never `kubectl apply`-ed directly. +# #4 Verb lists are explicit — no `*` on secrets even for admin +# (admin can manage Secrets, but the verb list spells out the verbs +# so the audit trail is grep-able). +# +# Role boundary contract: +# admin — full namespace control + RBAC management within the namespace +# (create/get/list/watch/update/patch on roles + rolebindings). +# Can read/write Secrets — required for any team that ships +# Apps using imagePullSecrets, TLS, or DB creds. +# editor — workloads + observability (pods, deployments, jobs, services, +# configmaps, ingresses, networkpolicies). Can exec/log into +# pods. CANNOT read/write Secrets — that gate is what separates +# editor from admin. +# viewer — get/list/watch only across the same resource families as +# editor. CANNOT read Secrets. +# +# These are the three roles the UserAccess Composition can bind. Anything +# narrower (e.g. read-only-of-one-deployment) is out of scope — the model +# is intentionally coarse; finer slicing belongs to the Application's own +# RBAC payload, not to Sovereign-wide IAM. + +# ── openova:application-admin ──────────────────────────────────────────── +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: openova:application-admin + labels: + catalyst.openova.io/component: crossplane-claims + catalyst.openova.io/sovereign-iam-role: admin +rules: + # Core workload + observability resources, full CRUD. + - apiGroups: [""] + resources: + - pods + - pods/exec + - pods/log + - pods/portforward + - services + - configmaps + - persistentvolumeclaims + - serviceaccounts + - events + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + # Secrets — admin only. Editor + viewer roles deliberately omit secrets. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + # apps/* — deployments, statefulsets, daemonsets, replicasets. + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets", "daemonsets", "replicasets"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments/scale", "statefulsets/scale"] + verbs: ["get", "patch", "update"] + # batch/* — jobs, cronjobs. + - apiGroups: ["batch"] + resources: ["jobs", "cronjobs"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + # networking — ingresses, networkpolicies. + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses", "networkpolicies"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + # RBAC — admin can manage roles/rolebindings within the namespace + # (NOT clusterroles/clusterrolebindings — those stay platform-only). + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["roles", "rolebindings"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + +--- +# ── openova:application-editor ─────────────────────────────────────────── +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: openova:application-editor + labels: + catalyst.openova.io/component: crossplane-claims + catalyst.openova.io/sovereign-iam-role: editor +rules: + # Core workload + observability — same as admin EXCEPT secrets. + - apiGroups: [""] + resources: + - pods + - pods/exec + - pods/log + - pods/portforward + - services + - configmaps + - persistentvolumeclaims + - serviceaccounts + - events + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + # Secrets: explicitly omitted. Editors cannot read or write secrets. + # An editor that needs a credential must ask an admin to install it. + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets", "daemonsets", "replicasets"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments/scale", "statefulsets/scale"] + verbs: ["get", "patch", "update"] + - apiGroups: ["batch"] + resources: ["jobs", "cronjobs"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses", "networkpolicies"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + # RBAC: editors cannot manage RBAC. roles/rolebindings are admin-only. + +--- +# ── openova:application-viewer ─────────────────────────────────────────── +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: openova:application-viewer + labels: + catalyst.openova.io/component: crossplane-claims + catalyst.openova.io/sovereign-iam-role: viewer +rules: + # Read-only across the same resource families as editor — minus secrets, + # minus exec/portforward (a viewer can read logs, but cannot exec into a + # pod or open a port-forward; both of those are write-equivalent verbs + # against the workload). + - apiGroups: [""] + resources: + - pods + - pods/log + - services + - configmaps + - persistentvolumeclaims + - serviceaccounts + - events + verbs: ["get", "list", "watch"] + # Secrets: explicitly omitted. Viewers cannot read secrets. + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets", "daemonsets", "replicasets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["batch"] + resources: ["jobs", "cronjobs"] + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses", "networkpolicies"] + verbs: ["get", "list", "watch"] +{{- end }} diff --git a/platform/crossplane-claims/chart/templates/compositions/useraccess.yaml b/platform/crossplane-claims/chart/templates/compositions/useraccess.yaml new file mode 100644 index 00000000..ef806795 --- /dev/null +++ b/platform/crossplane-claims/chart/templates/compositions/useraccess.yaml @@ -0,0 +1,176 @@ +{{- if .Values.userAccess.enabled }} +# Composition: useraccess.compose.openova.io — default realization for +# XUserAccess. +# +# Translates a UserAccess Claim into a single provider-kubernetes Object +# whose embedded manifest is a RoleBinding referencing one of the three +# canonical openova:application-{admin,editor,viewer} ClusterRoles. The +# Object lands on the per-Sovereign target cluster selected by +# `sovereign-` ProviderConfig. +# +# This Composition is intentionally narrow: it materialises ONE grant +# per Claim — the (application, role) shape carried in +# spec.applications[0]. Multi-app / multi-namespace grants are expanded +# by catalyst-api into N UserAccess Claims (one per application × +# namespace × role) BEFORE writing them. That keeps the Composition +# stateless and avoids the for-loop-in-Composition trap (Crossplane v1 +# Compositions cannot iterate over array fields without +# composition-functions, which we deliberately do not introduce here per +# the canonical-seam map). +# +# Per docs/INVIOLABLE-PRINCIPLES.md: +# #3 RoleBindings on Sovereign clusters are provider-kubernetes +# Objects, never raw kubectl apply. +# #4 Role-name suffix (admin/editor/viewer) is a schema enum; the +# canonical ClusterRole name is composed via fmt transform — +# never hardcoded. +# +# UPDATE flow: +# - patch spec.applications[0].role → ClusterRole reference rotates +# in place (provider-kubernetes patches the live RoleBinding). +# - patch spec.user.keycloakSubject → subject rewrites. +# +# DELETE flow: +# - delete the UserAccess → composite controller deletes the Object → +# provider-kubernetes deletes the RoleBinding on the Sovereign. +# - hand-edited RoleBinding gets reverted within Crossplane's poll +# interval (default 60s). + +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: useraccess.compose.openova.io + labels: + catalyst.openova.io/component: crossplane + catalyst.openova.io/composition-family: iam + catalyst.openova.io/day2-crud: "true" +spec: + compositeTypeRef: + apiVersion: access.openova.io/v1alpha1 + kind: XUserAccess + + writeConnectionSecretsToNamespace: crossplane-system + + resources: + # ── 1. RoleBinding (provider-kubernetes Object) ─────────────────────── + # The catalyst-api expands multi-app/multi-namespace UserAccess Claims + # into N single-app-single-namespace Claims before submitting; this + # resource block consumes spec.applications[0] only. The + # spec.applications[0].namespaces[0] field MUST be set by the api + # (the schema doesn't enforce it because the bare-Claim shape that + # an operator might author is also valid input — the api expands). + - name: rolebinding + base: + apiVersion: kubernetes.crossplane.io/v1alpha2 + kind: Object + spec: + forProvider: + manifest: + apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + labels: + catalyst.openova.io/managed-by: crossplane + catalyst.openova.io/composition-family: iam + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "" + subjects: [] + providerConfigRef: + name: "" + deletionPolicy: Delete + patches: + # Object name: --- + - fromFieldPath: metadata.name + toFieldPath: metadata.name + transforms: + - type: string + string: + fmt: "useraccess-%s" + + # Inner RoleBinding name: same shape, prefix with useraccess- + - fromFieldPath: metadata.name + toFieldPath: spec.forProvider.manifest.metadata.name + transforms: + - type: string + string: + fmt: "useraccess-%s" + + # Target namespace: the api MUST have expanded the Claim to a + # single-namespace shape before submitting. This patch reads + # applications[0].namespaces[0] verbatim. + - fromFieldPath: spec.applications[0].namespaces[0] + toFieldPath: spec.forProvider.manifest.metadata.namespace + + # roleRef.name: rewrite role suffix to the canonical ClusterRole + # via fmt transform. spec.applications[0].role is enum + # {admin, editor, viewer}; output is openova:application-. + - fromFieldPath: spec.applications[0].role + toFieldPath: spec.forProvider.manifest.roleRef.name + transforms: + - type: string + string: + fmt: "openova:application-%s" + + # subjects[0]: User identity from spec.user.keycloakSubject. + # The User-vs-Group decision is encoded by the api at expansion + # time: a Claim authored against a Group is rewritten to populate + # spec.user.keycloakSubject="" — but the simpler + # contract for the v1alpha1 Composition is: keycloakSubject is + # always an OIDC `sub`, group-binding is a separate Claim shape + # with subjects[].kind=Group (not modelled in this Composition; + # tracked for v1alpha2 once group-mode usage is observed). + - fromFieldPath: spec.user.keycloakSubject + toFieldPath: spec.forProvider.manifest.subjects[0].kind + transforms: + - type: match + match: + patterns: + - type: regexp + regexp: '.+' + result: 'User' + fallbackTo: Value + fallbackValue: 'User' + - fromFieldPath: spec.user.keycloakSubject + toFieldPath: spec.forProvider.manifest.subjects[0].apiGroup + transforms: + - type: match + match: + patterns: + - type: regexp + regexp: '.+' + result: 'rbac.authorization.k8s.io' + fallbackTo: Value + fallbackValue: 'rbac.authorization.k8s.io' + - fromFieldPath: spec.user.keycloakSubject + toFieldPath: spec.forProvider.manifest.subjects[0].name + transforms: + - type: string + string: + fmt: "oidc:%s" + + # ProviderConfig: per-Sovereign provider-kubernetes config named + # sovereign-. catalyst-api creates the + # ProviderConfig as part of Sovereign provisioning (#326). + - fromFieldPath: spec.sovereignRef + toFieldPath: spec.providerConfigRef.name + transforms: + - type: string + string: + fmt: "sovereign-%s" + + # Sovereign label on the RoleBinding — for ops/audit queries. + - fromFieldPath: spec.sovereignRef + toFieldPath: spec.forProvider.manifest.metadata.labels[catalyst.openova.io/sovereign] + + # Bubble grant-count to status (single grant per Composition pass; + # multi-grant Claims are expanded api-side before submission). + - type: ToCompositeFieldPath + fromFieldPath: spec.sovereignRef + toFieldPath: status.providerConfigRef + transforms: + - type: string + string: + fmt: "sovereign-%s" +{{- end }} diff --git a/platform/crossplane-claims/chart/templates/xrds/useraccess.yaml b/platform/crossplane-claims/chart/templates/xrds/useraccess.yaml new file mode 100644 index 00000000..106870bb --- /dev/null +++ b/platform/crossplane-claims/chart/templates/xrds/useraccess.yaml @@ -0,0 +1,181 @@ +{{- if .Values.userAccess.enabled }} +# XRD: XUserAccess — Catalyst Sovereign IAM intent. +# +# A UserAccess Claim is the source-of-truth declaration of "user X +# (identified by Keycloak subject or group) has role Y on Sovereign Z's +# Application A in namespace(s) N". The Composition (useraccess.compose +# .openova.io) materialises the intent into one K8s RoleBinding per +# (application × namespace × role) tuple on the target Sovereign cluster +# via provider-kubernetes. +# +# Per docs/INVIOLABLE-PRINCIPLES.md: +# #3 Crossplane is the day-2 IaC for IAM grants too — never let +# catalyst-api `kubectl apply` RoleBindings directly. catalyst-api +# writes the UserAccess Claim; Crossplane reconciles the RBAC. +# #4 Role names are the three canonical openova:application-* ClusterRoles +# (admin/editor/viewer); the role string in spec.applications[].role +# is one of {admin, editor, viewer} and the Composition rewrites it +# to the fully-qualified ClusterRole name. +# +# Cluster-scoped: a UserAccess spans namespaces by design (one user can +# hold grants on many tenants), so the Claim itself is cluster-scoped. +# Composed RoleBindings are namespaced per spec.applications[].namespaces. + +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xuseraccesses.access.openova.io + labels: + catalyst.openova.io/component: crossplane + catalyst.openova.io/composition-family: iam + catalyst.openova.io/day2-crud: "true" +spec: + group: access.openova.io + names: + kind: XUserAccess + plural: xuseraccesses + claimNames: + kind: UserAccess + plural: useraccesses + defaultCompositionRef: + name: useraccess.compose.openova.io + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + spec: + type: object + required: [user, sovereignRef, applications] + properties: + user: + type: object + description: | + Identity of the principal receiving access. Either + keycloakSubject (an OIDC `sub` claim, binding a single + user) or keycloakGroups (a list of group names, binding + every member of those groups) or both. The Composition + emits one RoleBinding subject per identity. + properties: + keycloakSubject: + type: string + description: | + OIDC `sub` claim — the Keycloak user's stable id. + Renders as a RoleBinding subject of kind=User, + name="oidc:" (matches the api-server + --oidc-username-prefix convention). + keycloakGroups: + type: array + items: + type: string + description: | + Keycloak group names. Each renders as an additional + RoleBinding subject of kind=Group, + name="oidc:" (matches --oidc-groups-prefix). + sovereignRef: + type: string + description: | + Sovereign identifier — used to select the per-Sovereign + provider-kubernetes ProviderConfig (named + `sovereign-`) that the Composition writes + RoleBindings against. + pattern: '^[a-z0-9][a-z0-9-]{0,62}$' + applications: + type: array + description: | + One entry per (Application × role) grant. An entry may + name explicit namespaces, vClusters (rendered as + additional namespaces with the vCluster prefix), or + omit both — in which case the catalyst-api expands the + Claim against Application.k8s_namespaces before + applying. The Composition itself only renders explicit + namespaces; expansion is the api's job. + minItems: 1 + items: + type: object + required: [app, role] + properties: + app: + type: string + description: | + Application slug as registered in catalyst-api + (e.g. helmwatch, ai-inference). + pattern: '^[a-z0-9][a-z0-9-]{0,62}$' + role: + type: string + description: | + One of admin | editor | viewer. The Composition + rewrites this to the canonical ClusterRole name + openova:application-. + enum: [admin, editor, viewer] + namespaces: + type: array + items: + type: string + description: | + Explicit namespace list for this grant. When + omitted, catalyst-api resolves it from the + Application's k8s_namespaces field before writing + the Claim. The Composition treats an empty list + as "no RoleBindings for this application" (the + api MUST expand before submitting). + vClusters: + type: array + items: + type: string + description: | + vCluster names — rendered as additional + namespaces using the catalyst convention + `vcluster-`. + providerConfigRef: + type: object + description: | + Override for the Composition's per-Sovereign + provider-kubernetes ProviderConfig. Defaults to + `sovereign-` (resolved via Composition + patch). Most callers leave this unset. + properties: + name: + type: string + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + type: { type: string } + status: { type: string } + reason: { type: string } + message: { type: string } + lastTransitionTime: { type: string, format: date-time } + rolebindingsCreated: + type: integer + description: | + Count of RoleBinding Objects this composite owns. + Surfaced to the catalyst-api for the user-access editor + UI's " grants active" badge. + providerConfigRef: + type: string + description: | + Resolved ProviderConfig name — useful for ops debugging + when a grant fails to land. + additionalPrinterColumns: + - name: SOVEREIGN + type: string + jsonPath: .spec.sovereignRef + - name: USER + type: string + jsonPath: .spec.user.keycloakSubject + - name: GRANTS + type: integer + jsonPath: .status.rolebindingsCreated + - name: AGE + type: date + jsonPath: .metadata.creationTimestamp +{{- end }} diff --git a/platform/crossplane-claims/chart/tests/composition-validate.sh b/platform/crossplane-claims/chart/tests/composition-validate.sh index 76d0056f..d9395a98 100755 --- a/platform/crossplane-claims/chart/tests/composition-validate.sh +++ b/platform/crossplane-claims/chart/tests/composition-validate.sh @@ -3,9 +3,10 @@ # # This is the chart-level lint+template+kubectl-dry-run pass that runs # against every render of bp-crossplane's templates/xrds + templates/compositions -# directory tree. The 6 XRDs and 6 Compositions composed here back the +# directory tree. The 7 XRDs and 7 Compositions composed here back the # catalyst-api Day-2 CRUD endpoints (RegionClaim, ClusterClaim, -# NodePoolClaim, LoadBalancerClaim, PeeringClaim, NodeActionClaim). +# NodePoolClaim, LoadBalancerClaim, PeeringClaim, NodeActionClaim) plus +# the Sovereign IAM access plane (UserAccess — issue #322). # # Verifies, in order: # 1. `helm template` renders without error (no Go-template breakage). @@ -53,23 +54,31 @@ helm template smoke-cp . > "$TMP/render.yaml" 2> "$TMP/render.err" || { } echo " PASS" -echo "[composition-validate] Case 2: render contains 6 XRDs" +echo "[composition-validate] Case 2: render contains 7 XRDs" XRD_COUNT="$(grep -c '^kind: CompositeResourceDefinition$' "$TMP/render.yaml" || true)" -if [ "$XRD_COUNT" -ne 6 ]; then - echo "FAIL: expected 6 XRDs, found $XRD_COUNT" >&2 +if [ "$XRD_COUNT" -ne 7 ]; then + echo "FAIL: expected 7 XRDs, found $XRD_COUNT" >&2 grep -E '^(kind| name): ' "$TMP/render.yaml" | head -40 >&2 exit 1 fi echo " PASS ($XRD_COUNT XRDs)" -echo "[composition-validate] Case 3: render contains ≥ 6 Compositions" +echo "[composition-validate] Case 3: render contains ≥ 7 Compositions" COMPOSITION_COUNT="$(grep -c '^kind: Composition$' "$TMP/render.yaml" || true)" -if [ "$COMPOSITION_COUNT" -lt 6 ]; then - echo "FAIL: expected ≥ 6 Compositions, found $COMPOSITION_COUNT" >&2 +if [ "$COMPOSITION_COUNT" -lt 7 ]; then + echo "FAIL: expected ≥ 7 Compositions, found $COMPOSITION_COUNT" >&2 exit 1 fi echo " PASS ($COMPOSITION_COUNT Compositions)" +echo "[composition-validate] Case 3b: render contains 3 ClusterRoles (Sovereign IAM)" +CLUSTERROLE_COUNT="$(grep -c '^kind: ClusterRole$' "$TMP/render.yaml" || true)" +if [ "$CLUSTERROLE_COUNT" -ne 3 ]; then + echo "FAIL: expected 3 ClusterRoles (openova:application-{admin,editor,viewer}), found $CLUSTERROLE_COUNT" >&2 + exit 1 +fi +echo " PASS ($CLUSTERROLE_COUNT ClusterRoles)" + echo "[composition-validate] Case 4: every expected claim kind is present" EXPECTED_KINDS=( RegionClaim @@ -78,6 +87,7 @@ EXPECTED_KINDS=( LoadBalancerClaim PeeringClaim NodeActionClaim + UserAccess ) for kind in "${EXPECTED_KINDS[@]}"; do if ! grep -q "kind: $kind$" "$TMP/render.yaml"; then @@ -85,7 +95,7 @@ for kind in "${EXPECTED_KINDS[@]}"; do exit 1 fi done -echo " PASS (all 6 claim kinds present)" +echo " PASS (all ${#EXPECTED_KINDS[@]} claim kinds present)" echo "[composition-validate] Case 5: every rendered document is valid YAML" # We can't run `kubectl apply --dry-run=client` without an API server diff --git a/platform/crossplane-claims/chart/tests/fixtures/useraccess-sample.yaml b/platform/crossplane-claims/chart/tests/fixtures/useraccess-sample.yaml new file mode 100644 index 00000000..2a92c86e --- /dev/null +++ b/platform/crossplane-claims/chart/tests/fixtures/useraccess-sample.yaml @@ -0,0 +1,18 @@ +# UserAccess sample fixture — single-grant shape (post catalyst-api expansion). +# Multi-app / multi-namespace UserAccess Claims authored by the api are +# expanded into N of these single-grant claims before submitting. +apiVersion: access.openova.io/v1alpha1 +kind: UserAccess +metadata: + name: alice-helmwatch-omantel-prod +spec: + user: + keycloakSubject: alice + keycloakGroups: + - sovereign-ops + sovereignRef: omantel + applications: + - app: helmwatch + role: editor + namespaces: + - helmwatch-prod diff --git a/platform/crossplane-claims/chart/values.yaml b/platform/crossplane-claims/chart/values.yaml index ff0d5a45..a015d1ab 100644 --- a/platform/crossplane-claims/chart/values.yaml +++ b/platform/crossplane-claims/chart/values.yaml @@ -1,9 +1,18 @@ -# bp-crossplane-claims has no operator-tunable values — the XRDs and -# Compositions it ships are static manifests with no Go-template -# substitutions. This file exists so `helm template` and the CI smoke -# render have an explicit defaults document; future per-Sovereign -# overrides (e.g. defaultCompositionRef swaps for a non-Hetzner cloud) -# would land here. +# bp-crossplane-claims has no operator-tunable values for the +# core compose.openova.io/v1alpha1 family — those XRDs and Compositions +# are static manifests with no Go-template substitutions. This file +# exists so `helm template` and the CI smoke render have an explicit +# defaults document; future per-Sovereign overrides (e.g. +# defaultCompositionRef swaps for a non-Hetzner cloud) would land here. catalystBlueprint: upstream: null + +# Sovereign IAM access plane (epic #320). Renders the +# access.openova.io/v1alpha1 XUserAccess XRD, its Composition, and the +# three canonical openova:application-{admin,editor,viewer} ClusterRoles +# that the Composition's RoleBindings reference. Default-on; toggle-off +# is reserved for the rare case where a Sovereign uses an external IAM +# stack and does not consume the Catalyst access plane. +userAccess: + enabled: true