feat(bp-crossplane-claims): UserAccess CRD + Composition + RBAC ClusterRoles for Sovereign IAM (closes #322) (#446)

Adds the data plane for the Sovereign IAM access plane (epic #320):

- platform/crossplane-claims/chart/templates/xrds/useraccess.yaml
  XUserAccess XRD (access.openova.io/v1alpha1) — cluster-scoped Claim
  carrying user identity (Keycloak subject + groups), Sovereign ref, and
  one or more (application, role, namespaces) grants.

- platform/crossplane-claims/chart/templates/compositions/useraccess.yaml
  Default Composition useraccess.compose.openova.io — materialises one
  RoleBinding per Claim via provider-kubernetes Object against the
  per-Sovereign sovereign-<sovereignRef> ProviderConfig. Multi-grant
  shapes are expanded api-side into N single-grant Claims (avoids the
  Composition-iteration trap; no composition-functions introduced).

- platform/crossplane-claims/chart/templates/clusterroles.yaml
  Three canonical ClusterRoles — openova:application-{admin,editor,viewer}.
  Editor + viewer explicitly omit secrets; admin can manage namespace-
  scoped roles/rolebindings (NOT cluster-scoped).

- userAccess.enabled values toggle (default true), version bumps to 1.1.0
  on chart + blueprint, sample fixture, validation script extended to
  expect 7 XRDs / 7 Compositions / 3 ClusterRoles.

Canonical seam: extends the existing platform/crossplane-claims/chart/
XRD+Composition pattern (compose.openova.io/v1alpha1 family). New API
group access.openova.io is intentional — IAM is a separate concern from
the cloud-resource compose.* family. No catalyst-api or UI code touched
(those are #323's territory; this PR ships the data model #323 consumes).

Co-authored-by: hatiyildiz <hatiyildiz@noreply.github.com>
This commit is contained in:
e3mrah 2026-05-01 19:03:10 +04:00 committed by GitHub
parent 7ea496ba64
commit b6810c1940
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 563 additions and 17 deletions

View File

@ -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: |

View File

@ -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

View File

@ -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 }}

View File

@ -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-<sovereignRef>` 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: <sovereign>-<app>-<role>-<subject-or-first-group>
- 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-<role>.
- 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="<group-marker>" — 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-<sovereignRef>. 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 }}

View File

@ -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:<subject>" (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:<group>" (matches --oidc-groups-prefix).
sovereignRef:
type: string
description: |
Sovereign identifier — used to select the per-Sovereign
provider-kubernetes ProviderConfig (named
`sovereign-<sovereignRef>`) 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-<role>.
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-<name>`.
providerConfigRef:
type: object
description: |
Override for the Composition's per-Sovereign
provider-kubernetes ProviderConfig. Defaults to
`sovereign-<sovereignRef>` (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 "<n> 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 }}

View File

@ -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

View File

@ -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

View File

@ -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