diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 47a07ec..140c455 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,11 +15,23 @@ jobs: with: path: guardian - - name: Checkout seam-core (replace dep) + - name: Checkout seam (replace dep) uses: actions/checkout@v4 with: - repository: ontai-dev/seam-core - path: seam-core + repository: ontai-dev/seam + path: seam + + - name: Checkout seam-sdk (replace dep) + uses: actions/checkout@v4 + with: + repository: ontai-dev/seam-sdk + path: seam-sdk + + - name: Checkout platform (replace dep) + uses: actions/checkout@v4 + with: + repository: ontai-dev/platform + path: platform - name: Checkout conductor (replace dep) uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 2ae5474..6d57086 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,23 +3,20 @@ ### Schema authority Primary: docs/guardian-schema.md -Supporting: ~/ontai/conductor/docs/conductor-schema.md (Conductor capabilities and job protocol) -Supporting: ~/ontai/seam-core/docs/seam-core-schema.md (InfrastructureRunnerConfig type definition; Decision G) +Supporting: ~/ontai/conductor/docs/conductor-schema.md (conductor capabilities and job protocol) +Supporting: ~/ontai/seam/docs/seam-schema.md (RunnerConfig type definition) ### Invariants CS-INV-001 -- The admission webhook is the enforcement mechanism. Policy without enforcement is decoration. The webhook must be operational before any other operator is considered enabled. (root INV-003) CS-INV-002 -- CNPG is a guardian dependency only. No other component references or accesses the CNPG cluster in seam-system. (root INV-016) -CS-INV-003 -- The two-phase boot (CRD-only to database-backed) is a named, explicit transition. It is never a silent fallback. +CS-INV-003 -- The two-phase boot (migration and bootstrap to full enforcement) is a named, explicit transition. It is never a silent fallback. CS-INV-004 -- The bootstrap RBAC window has a definite close: when the admission webhook becomes operational. The window is documented, bounded, and reconciled on startup. (root INV-020) CS-INV-005 -- provisioned=true on RBACProfile is set exclusively by this operator. No other controller writes to RBACProfile status. -CS-INV-006 -- Leader election required. Admission webhook requires a stable leader. +CS-INV-006 -- Leader election required. The admission webhook requires a stable leader. CS-INV-007 -- Third-party RBAC ownership is wrapping, not replacement. Helm upgrades must remain safe. Drift is surfaced, not silently overwritten. -INV-005 -- ClusterAssignment references, never owns, cluster/pack/security resources. -INV-015 -- Deletion of TalosCluster never triggers physical cluster destruction. ClusterReset is the only path to cluster destruction. CS-INV-008 -- Three-layer RBAC hierarchy is the authoritative governance model (guardian-schema.md §19). Layer 1: management-policy + management-maximum in seam-system, compiler-authored. Layer 2: cluster-policy + cluster-maximum in seam-tenant-{clusterName}, guardian-authored by ClusterRBACPolicyReconciler. Layer 3: component RBACProfiles only -- no per-component RBACPolicy, no per-component PermissionSet. RBACPolicy is never human-authored. -CS-INV-009 -- Cluster-policy validation against management-maximum happens at ClusterRBACPolicyReconciler creation time (option a). Never at RBACProfile admission time. This is the deadlock-prevention invariant -- admission-time management ceiling checks are prohibited. -CS-INV-010 -- security-system namespace does not exist in this platform. All guardian-owned objects that previously referenced security-system live in seam-system. +CS-INV-009 -- Cluster-policy validation against management-maximum happens at ClusterRBACPolicyReconciler creation time. Never at RBACProfile admission time. This is the deadlock-prevention invariant -- admission-time management ceiling checks are prohibited. ### Session protocol additions -Step 4a -- Read guardian-design.md in this repository. +Step 4a -- Read guardian-design.md in this repository before any implementation work. Step 4b -- Before implementing any EPG computation change, trace its impact on PermissionSnapshot generation and target cluster delivery. Document the impact in PROGRESS.md before proceeding. diff --git a/README.md b/README.md index 0bd7f99..3494220 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,132 @@ # guardian -**Seam Security Plane operator** -**API Group:** `security.ontai.dev` -**Image:** `registry.ontai.dev/ontai-dev/guardian:` +RBAC governance operator for the ONT platform. Guardian owns all RBAC across every +cluster. No operator, application, or human provisions Kubernetes RBAC outside of +guardian. + +API group: `guardian.ontai.dev` --- -## What this repository is +## CRD types -`guardian` is the intelligent operator in the Seam platform. It is the only ONT -operator with genuine in-process logic beyond the thin reconciler pattern. +All types under `guardian.ontai.dev/v1alpha1`. -Guardian owns all RBAC on every cluster. No component provisions its own RBAC. -Guardian's admission webhook gates every RBAC resource written to any cluster in -the Seam stack. +| Kind | Short name | Scope | Description | +|---------------------------|------------|------------|------------------------------------------------------------------------------------| +| RBACPolicy | rp | Namespaced | Governing policy constraining what RBACProfiles in its scope may declare | +| RBACProfile | rbp | Namespaced | Per-component per-tenant permission declaration; gates operator enablement | +| PermissionSet | ps | Namespaced | Named, reusable collection of permission rules; used as governance ceiling | +| PermissionSnapshot | psn | Namespaced | Computed, versioned, signed EPG for a specific target cluster; never hand-authored | +| PermissionSnapshotReceipt | psr | Namespaced | Target-cluster acknowledgement record; written by conductor in agent mode | +| IdentityProvider | idp | Namespaced | External identity source declaration (OIDC, PKI, token) | +| IdentityBinding | ib | Namespaced | Maps an external identity to an ONT permission principal | --- -## CRDs +## Architecture -| Kind | API Group | Role | -|---|---|---| -| `RBACProfile` | `security.ontai.dev` | Declares RBAC policy intent for a tenant or operator | -| `RBACPolicy` | `security.ontai.dev` | Concrete policy rule set applied to a principal set | -| `PermissionSet` | `security.ontai.dev` | Compiled effective permissions for a principal | -| `PermissionSnapshot` | `security.ontai.dev` | Signed point-in-time permission record for target delivery | -| `PermissionSnapshotReceipt` | `security.ontai.dev` | Acknowledgement of snapshot delivery on a target cluster | -| `IdentityProvider` | `security.ontai.dev` | OIDC or LDAP identity source configuration | -| `IdentityBinding` | `security.ontai.dev` | Binding between a domain identity and a Kubernetes principal | -| `InfrastructureLineageIndex` | `security.ontai.dev` | Sealed causal chain index (one per root declaration) | -| `SeamMembership` | `security.ontai.dev` | Domain membership record for a principal on a target cluster | +### Management cluster role ---- - -## Architecture +Guardian runs as a single Deployment on the management cluster with `GUARDIAN_ROLE=management`. +It is provisioned exclusively by the compiler enable bundle. No human, operator, or pipeline +stamps `role=management` on a Guardian Deployment. -Guardian has two operating modes. +Responsibilities in this role: -**Management cluster (role=management):** -- Computes the Effective Permission Graph (EPG) in-process from RBACProfile and RBACPolicy objects. -- Generates signed PermissionSnapshot CRs for delivery to target clusters. -- Runs a CNPG-backed persistent store for EPG state, audit events, and identity resolution logs. -- Deploys first. Its `RBACProfile` reaching `provisioned=true` is the gate that unblocks all other operators. +- EPG computation from provisioned RBACProfiles +- PermissionSnapshot generation (one per target cluster) +- Policy validation and admission webhook enforcement on the management cluster +- ClusterRBACPolicyReconciler: creates `cluster-policy` and `cluster-maximum` per TalosCluster +- APIGroupSweepController: extends `management-maximum` as new CRD API groups are discovered +- PermissionService gRPC: the authoritative authorization decision point for the fleet +- AuditSinkReconciler: receives forwarded audit events from federated tenant Guardians +- CNPG-backed persistence for EPG and audit state -**Target cluster (Conductor agent on target, Guardian runner on admission):** -- Runs the local admission webhook that intercepts all RBAC resources. -- Enforces the `ontai.dev/rbac-owner=guardian` annotation on every RBAC object. -- Serves the local PermissionService gRPC endpoint for authorization decisions. +Guardian deploys first. No other operator is considered enabled until its RBACProfile +reaches `provisioned=true`. INV-003. ---- +### Target cluster admission via conductor -## Two-phase boot +Guardian does not deploy a separate agent on target clusters. Conductor in agent mode +(`CONDUCTOR_ROLE=tenant`) hosts the security plane on each target cluster: -Guardian boots in two phases: +- Admission webhook at `/validate/rbac-ownership`: rejects unannotated RBAC resources +- PermissionSnapshotReceipt: acknowledges the current signed snapshot +- Local PermissionService gRPC: serves authorization decisions from the locally acknowledged snapshot -1. **CRD-only phase** (before CNPG is reachable): reconcilers start with an in-memory - EPG stub. The bootstrap RBAC window is open. Guardian emits a named `BootstrapWindow` - condition during this phase. +The conductor local PermissionService means target cluster authorization decisions are +fully operational even when the management cluster is temporarily unreachable. -2. **Database-backed phase** (after CNPG connection is confirmed): full EPG computation - starts. The `BootstrapWindow` condition closes permanently when the admission webhook - becomes operational. This transition is named and never a silent fallback. +### Optional tenant Guardian -See `guardian-design.md` for the full boot sequence and `docs/guardian-schema.md` for -the API contract. +Tenants may optionally deploy a Guardian ClusterPack (`GUARDIAN_ROLE=tenant`) via the +Dispatcher pack delivery flow. Role=tenant Guardian is sovereign by default: it connects +to a tenant-local CNPG instance and does not forward audit events to the management +Guardian unless `GUARDIAN_AUDIT_FORWARD=true` is explicitly set. Platform never knows +whether a tenant has deployed Guardian and never depends on its presence. --- -## Building +## Two-phase boot -```sh -go build ./cmd/guardian -``` +Guardian on the management cluster starts after the CNPG operator and CNPG Cluster CR +are pre-provisioned by the compiler enable bundle (phase 0). -The binary is built into a distroless container image: +**Phase 1 - Migration and bootstrap:** -```sh -docker build -t registry.ontai.dev/ontai-dev/guardian: . -``` +1. Startup migration runner connects to CNPG and applies all pending schema migrations. + If CNPG is unreachable, Guardian holds in degraded state until CNPG becomes reachable. + This is the only blocking gate before controller registration. +2. Bootstrap annotation sweep stamps `ontai.dev/rbac-owner=guardian` on all pre-existing + RBAC resources across non-exempt namespaces (audit mode during the sweep). +3. Guardian creates baseline PermissionSet, RBACPolicy, and RBACProfile for each known + third-party component whose namespace is present. + +**Phase 2 - Full enforcement:** + +4. All role-gated controllers register. The admission webhook becomes operational. + The bootstrap RBAC window closes permanently. INV-020. +5. BootstrapController monitors all RBACProfiles. Once all profiles reach `provisioned=true`, + the webhook advances from `ObserveOnly` to `Enforcing`. Any RBAC resource created or + updated without `ontai.dev/rbac-owner=guardian` is rejected at admission. + +The two-phase transition is a named, explicit event. It is never a silent fallback. --- -## Testing +## Build -```sh -go test ./test/unit/... +``` +make build +make docker-build +make docker-push ``` --- -## Schema and design reference +## Test -- `docs/guardian-schema.md` - API contract, field definitions, status conditions -- `guardian-design.md` - Implementation architecture and reconciler design +``` +make test +make e2e +``` ---- +E2e specs live under `test/e2e/` and skip automatically when `MGMT_KUBECONFIG` is absent. +Every skipped spec references the exact backlog item ID required for promotion to live. -## Status +--- -Alpha. Deployed and tested on management cluster (ccs-mgmt). -Tenant cluster onboarding is not yet verified end to end. -See [docs/guardian-schema.md](./docs/guardian-schema.md) -for current capability and known gaps. +## Schema -CRDs are deployed and reconciling on the live management cluster. -The schema specification is published at: -https://schema.ontai.dev/v1alpha1/ +The authoritative field reference is `docs/guardian-schema.md`. -## Contributing +--- -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull -request. Every new reconciliation behavior requires a written -specification and senior engineer sign-off before any code is -written. +## Issues -File issues at https://github.com/ontai-dev/guardian/issues. -For security issues contact security@ontai.dev directly. +https://github.com/ontai-dev/guardian/issues --- -*guardian - Seam Security Plane* -*Apache License, Version 2.0* +guardian - Seam RBAC Governance / Apache License, Version 2.0 diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go index 31b503a..eb263df 100644 --- a/api/v1alpha1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -1,10 +1,10 @@ -// Package v1alpha1 contains API types for the security.ontai.dev/v1alpha1 API group. +// Package v1alpha1 contains API types for the guardian.ontai.dev/v1alpha1 API group. // // This package is the Kubernetes API contract for guardian. All CRD types are // registered here. Breaking changes require a version bump to v1alpha2 or v1beta1 // and coordination with all operators that reference these types. // -// +groupName=security.ontai.dev +// +groupName=guardian.ontai.dev // +kubebuilder:object:generate=true package v1alpha1 @@ -15,8 +15,8 @@ import ( var ( // GroupVersion is the group and version for all types in this package. - // API group: security.ontai.dev. INV-008 — this value is ground truth. - GroupVersion = schema.GroupVersion{Group: "security.ontai.dev", Version: "v1alpha1"} + // API group: guardian.ontai.dev. INV-008 -- this value is ground truth. + GroupVersion = schema.GroupVersion{Group: "guardian.ontai.dev", Version: "v1alpha1"} // SchemeBuilder is used to add Go types to the Kubernetes runtime scheme. // All CRD types in this package register via this builder. diff --git a/api/v1alpha1/guardian_types.go b/api/v1alpha1/guardian_types.go index e9b268e..c722ed8 100644 --- a/api/v1alpha1/guardian_types.go +++ b/api/v1alpha1/guardian_types.go @@ -1,129 +1,27 @@ package v1alpha1 -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - // WebhookMode is the global admission enforcement mode of the guardian operator. // It is a one-way ratchet: Initialising → ObserveOnly. Individual namespaces -// transition to full enforce independently via NamespaceEnforcements. +// transition to full enforce independently via NamespaceEnforcementRegistry. // INV-020, CS-INV-004. type WebhookMode string const ( // WebhookModeInitialising is the mode set on guardian startup. - // The webhook refuses to register until the bootstrap label check passes - // (WS3). While in this mode, the global enforcement gate treats all - // non-exempt namespaces as observe — no denials are issued. WebhookModeInitialising WebhookMode = "Initialising" // WebhookModeObserveOnly is set by BootstrapController when all - // platform-native RBACProfiles from the compiler enable bundle have - // reached Provisioned=True. In this mode the global gate allows - // per-namespace transitions to enforce, tracked in NamespaceEnforcements. + // platform-native RBACProfiles have reached Provisioned=True. WebhookModeObserveOnly WebhookMode = "ObserveOnly" - // WebhookModeEnforcing is informational: set when every known namespace - // has an entry in NamespaceEnforcements. The per-namespace registry is the - // canonical enforcement record; this field signals full-cluster readiness. + // WebhookModeEnforcing is set when every known namespace has transitioned + // to full RBAC enforcement. The per-namespace registry is the canonical + // enforcement record. WebhookModeEnforcing WebhookMode = "Enforcing" ) -// Condition type constants for Guardian CR. -const ( - // ConditionTypeBootstrapLabelAbsent is True when the seam-system namespace - // is missing the seam.ontai.dev/webhook-mode=exempt label on startup. - // Guardian refuses to register its admission webhook while this is True. WS3. - ConditionTypeBootstrapLabelAbsent = "BootstrapLabelAbsent" - - // ConditionTypeWebhookRegistered is True when the admission webhook has been - // successfully registered with the manager. INV-020. - ConditionTypeWebhookRegistered = "WebhookRegistered" -) - -// Condition reason constants for Guardian CR. +// Reason constants used by BootstrapController log events and conditions. const ( - // ReasonLabelAbsent is the reason for ConditionTypeBootstrapLabelAbsent=True. - ReasonLabelAbsent = "LabelAbsent" - - // ReasonLabelPresent is the reason for ConditionTypeBootstrapLabelAbsent=False. - ReasonLabelPresent = "LabelPresent" - - // ReasonWebhookRegistered is the reason for ConditionTypeWebhookRegistered=True. - ReasonWebhookRegistered = "WebhookRegistered" - - // ReasonBootstrapProfilesReady is the reason when WebhookMode advances to ObserveOnly. - ReasonBootstrapProfilesReady = "BootstrapProfilesReady" - - // ReasonBootstrapProfilesPending is the reason when RBACProfiles are not yet all provisioned. + ReasonBootstrapProfilesReady = "BootstrapProfilesReady" ReasonBootstrapProfilesPending = "BootstrapProfilesPending" ) - -// GuardianSpec has no user-configurable fields. Guardian is a status-only singleton -// CR that records the operator's own admission enforcement state. -type GuardianSpec struct{} - -// GuardianStatus defines the observed admission enforcement state of the guardian -// operator. All fields are written exclusively by guardian controllers. -type GuardianStatus struct { - // WebhookMode is the current global admission enforcement mode. - // Transitions: Initialising → ObserveOnly (when bootstrap RBACProfiles provisioned). - // ObserveOnly is the stable global mode; per-namespace enforce transitions are - // tracked in NamespaceEnforcements. INV-020, CS-INV-004. - // +optional - WebhookMode WebhookMode `json:"webhookMode,omitempty"` - - // NamespaceEnforcements records the set of namespaces that have transitioned to - // full RBAC enforcement. Populated by BootstrapController when all RBACProfiles - // in a namespace reach Provisioned=True. This transition is one-way and irreversible. - // Keys are namespace names; values are always true (absent key = not yet enforcing). - // +optional - NamespaceEnforcements map[string]bool `json:"namespaceEnforcements,omitempty"` - - // Conditions holds standard Kubernetes status conditions for Guardian. - // +optional - // +listType=map - // +listMapKey=type - Conditions []metav1.Condition `json:"conditions,omitempty"` - - // DiscoveredAPIGroups is the sorted, deduplicated list of third-party API groups - // for which APIGroupSweepController has added explicit rules in management-maximum. - // Informational. Written by APIGroupSweepController on role=management only. - // guardian-schema.md §21. - // +optional - DiscoveredAPIGroups []string `json:"discoveredAPIGroups,omitempty"` -} - -// Guardian is the singleton status CR for the guardian operator. -// It records the global webhook enforcement mode and per-namespace enforcement -// transitions managed by the BootstrapController. -// -// There is exactly one Guardian CR per cluster, named "guardian" in seam-system. -// It is created by guardian on startup if absent. -// -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Namespaced,shortName=gdn -// +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.status.webhookMode` -// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` -type Guardian struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec GuardianSpec `json:"spec,omitempty"` - Status GuardianStatus `json:"status,omitempty"` -} - -// GuardianList is the list type for Guardian. -// -// +kubebuilder:object:root=true -type GuardianList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - - Items []Guardian `json:"items"` -} - -func init() { - SchemeBuilder.Register(&Guardian{}, &GuardianList{}) -} diff --git a/api/v1alpha1/identitybinding_types.go b/api/v1alpha1/identitybinding_types.go index b8bab61..8e55b9e 100644 --- a/api/v1alpha1/identitybinding_types.go +++ b/api/v1alpha1/identitybinding_types.go @@ -3,7 +3,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/ontai-dev/seam-core/pkg/lineage" + "github.com/ontai-dev/seam/pkg/lineage" ) // IdentityType is a typed string declaring the class of external identity. diff --git a/api/v1alpha1/identityprovider_types.go b/api/v1alpha1/identityprovider_types.go index 16a367b..15028aa 100644 --- a/api/v1alpha1/identityprovider_types.go +++ b/api/v1alpha1/identityprovider_types.go @@ -3,7 +3,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/ontai-dev/seam-core/pkg/lineage" + "github.com/ontai-dev/seam/pkg/lineage" ) // IdentityProviderType is the class of external identity source. diff --git a/api/v1alpha1/lineage_conditions.go b/api/v1alpha1/lineage_conditions.go index 41bd5f7..b82b4c3 100644 --- a/api/v1alpha1/lineage_conditions.go +++ b/api/v1alpha1/lineage_conditions.go @@ -6,9 +6,9 @@ package v1alpha1 // // Guardian reconcilers reference these via the securityv1alpha1 package alias; // they continue to compile without modification. New code should prefer importing -// github.com/ontai-dev/seam-core/pkg/conditions directly. +// github.com/ontai-dev/seam/pkg/conditions directly. -import "github.com/ontai-dev/seam-core/pkg/conditions" +import "github.com/ontai-dev/seam/pkg/conditions" const ( // ConditionTypeLineageSynced is the reserved condition type for lineage @@ -20,7 +20,7 @@ const ( // 2. InfrastructureLineageController takes ownership on deployment, sets True. // 3. If InfrastructureLineageController is absent, remains False/LineageControllerAbsent. // - // Canonical source: github.com/ontai-dev/seam-core/pkg/conditions. + // Canonical source: github.com/ontai-dev/seam/pkg/conditions. ConditionTypeLineageSynced = conditions.ConditionTypeLineageSynced // ReasonLineageControllerAbsent is set when the reconciler initialises diff --git a/api/v1alpha1/permissionset_types.go b/api/v1alpha1/permissionset_types.go index 7926f7b..c09c38a 100644 --- a/api/v1alpha1/permissionset_types.go +++ b/api/v1alpha1/permissionset_types.go @@ -3,7 +3,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/ontai-dev/seam-core/pkg/lineage" + "github.com/ontai-dev/seam/pkg/lineage" ) // Verb is a typed Kubernetes RBAC verb. diff --git a/api/v1alpha1/permissionsnapshot_types.go b/api/v1alpha1/permissionsnapshot_types.go index 72a3d41..3538821 100644 --- a/api/v1alpha1/permissionsnapshot_types.go +++ b/api/v1alpha1/permissionsnapshot_types.go @@ -3,7 +3,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/ontai-dev/seam-core/pkg/lineage" + "github.com/ontai-dev/seam/pkg/lineage" ) // SubjectKind identifies the kind of a Kubernetes subject in a PermissionSnapshot. diff --git a/api/v1alpha1/rbacpolicy_types.go b/api/v1alpha1/rbacpolicy_types.go index 2958e00..c7412ad 100644 --- a/api/v1alpha1/rbacpolicy_types.go +++ b/api/v1alpha1/rbacpolicy_types.go @@ -3,7 +3,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/ontai-dev/seam-core/pkg/lineage" + "github.com/ontai-dev/seam/pkg/lineage" ) // EnforcementMode controls how policy violations are handled by the admission webhook. @@ -123,7 +123,7 @@ type RBACPolicyStatus struct { ProfileCount int32 `json:"profileCount,omitempty"` } -// RBACPolicy is the governing policy resource for the security.ontai.dev API group. +// RBACPolicy is the governing policy resource for the guardian.ontai.dev API group. // It constrains what RBACProfiles within its scope may declare. Profiles that // exceed their governing policy are rejected at admission. guardian-schema.md §7. // diff --git a/api/v1alpha1/rbacprofile_types.go b/api/v1alpha1/rbacprofile_types.go index ffe6716..ab78425 100644 --- a/api/v1alpha1/rbacprofile_types.go +++ b/api/v1alpha1/rbacprofile_types.go @@ -3,7 +3,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/ontai-dev/seam-core/pkg/lineage" + "github.com/ontai-dev/seam/pkg/lineage" ) // PermissionScope is a typed string declaring whether permissions are namespaced diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index cc35cc2..eded4fd 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - "github.com/ontai-dev/seam-core/pkg/lineage" + "github.com/ontai-dev/seam/pkg/lineage" "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -50,109 +50,6 @@ func (in *CertificateConfig) DeepCopy() *CertificateConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Guardian) DeepCopyInto(out *Guardian) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Guardian. -func (in *Guardian) DeepCopy() *Guardian { - if in == nil { - return nil - } - out := new(Guardian) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *Guardian) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GuardianList) DeepCopyInto(out *GuardianList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]Guardian, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GuardianList. -func (in *GuardianList) DeepCopy() *GuardianList { - if in == nil { - return nil - } - out := new(GuardianList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *GuardianList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GuardianSpec) DeepCopyInto(out *GuardianSpec) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GuardianSpec. -func (in *GuardianSpec) DeepCopy() *GuardianSpec { - if in == nil { - return nil - } - out := new(GuardianSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GuardianStatus) DeepCopyInto(out *GuardianStatus) { - *out = *in - if in.NamespaceEnforcements != nil { - in, out := &in.NamespaceEnforcements, &out.NamespaceEnforcements - *out = make(map[string]bool, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GuardianStatus. -func (in *GuardianStatus) DeepCopy() *GuardianStatus { - if in == nil { - return nil - } - out := new(GuardianStatus) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IdentityBinding) DeepCopyInto(out *IdentityBinding) { *out = *in diff --git a/cmd/guardian/main.go b/cmd/guardian/main.go index 9b1aeca..d38892f 100644 --- a/cmd/guardian/main.go +++ b/cmd/guardian/main.go @@ -44,7 +44,8 @@ import ( "github.com/ontai-dev/guardian/internal/permissionservice" "github.com/ontai-dev/guardian/internal/role" "github.com/ontai-dev/guardian/internal/webhook" - seamv1alpha1 "github.com/ontai-dev/seam-core/api/v1alpha1" + platformseamv1alpha1 "github.com/ontai-dev/platform/api/seam/v1alpha1" + seamv1alpha1 "github.com/ontai-dev/seam/api/v1alpha1" ) var scheme = runtime.NewScheme() @@ -52,6 +53,8 @@ var scheme = runtime.NewScheme() func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(securityv1alpha1.AddToScheme(scheme)) + // TalosCluster CRD lives in platform (seam.ontai.dev/v1alpha1). Register for watch. + utilruntime.Must(platformseamv1alpha1.AddToScheme(scheme)) // SeamMembership CRD is owned by seam-core (infrastructure.ontai.dev). // Guardian must register it to watch and reconcile SeamMembership objects. utilruntime.Must(seamv1alpha1.AddToScheme(scheme)) @@ -164,7 +167,7 @@ func main() { // For role=tenant: build a dynamic client for the management cluster. // MGMT_KUBECONFIG_PATH must point to a kubeconfig with read access to - // security.ontai.dev PermissionSnapshots on the management cluster. + // guardian.ontai.dev PermissionSnapshots on the management cluster. // guardian-schema.md §7, §8, §15. var mgmtDynClient dynamic.Interface if guardianRole == role.RoleTenant { @@ -250,13 +253,10 @@ func main() { sweepDone := &atomic.Bool{} if err := (&controller.BootstrapController{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorder("bootstrap-controller"), - Gate: modeGate, - Registry: enforcementRegistry, - OperatorNamespace: operatorNamespace, - SweepDone: sweepDone, + Client: mgr.GetClient(), + Gate: modeGate, + Registry: enforcementRegistry, + SweepDone: sweepDone, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Bootstrap") os.Exit(1) @@ -377,7 +377,7 @@ func (r *cnpgStartupRunnable) Start(ctx context.Context) error { log := ctrl.Log.WithName("cnpg-startup") db, err := database.RunWithRetry(ctx, func() (database.ConnConfig, error) { return database.ConnConfigFromSecret(ctx, r.kube) - }, r.kube) + }) if err != nil { // ctx cancelled — clean shutdown. return fmt.Errorf("CNPG startup aborted: %w", err) diff --git a/config/crd/security.ontai.dev_identitybindings.yaml b/config/crd/guardian.ontai.dev_identitybindings.yaml similarity index 99% rename from config/crd/security.ontai.dev_identitybindings.yaml rename to config/crd/guardian.ontai.dev_identitybindings.yaml index a159d92..42ee363 100644 --- a/config/crd/security.ontai.dev_identitybindings.yaml +++ b/config/crd/guardian.ontai.dev_identitybindings.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.1 - name: identitybindings.security.ontai.dev + name: identitybindings.guardian.ontai.dev spec: - group: security.ontai.dev + group: guardian.ontai.dev names: kind: IdentityBinding listKind: IdentityBindingList diff --git a/config/crd/security.ontai.dev_identityproviders.yaml b/config/crd/guardian.ontai.dev_identityproviders.yaml similarity index 99% rename from config/crd/security.ontai.dev_identityproviders.yaml rename to config/crd/guardian.ontai.dev_identityproviders.yaml index b5303b1..cb81a35 100644 --- a/config/crd/security.ontai.dev_identityproviders.yaml +++ b/config/crd/guardian.ontai.dev_identityproviders.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.1 - name: identityproviders.security.ontai.dev + name: identityproviders.guardian.ontai.dev spec: - group: security.ontai.dev + group: guardian.ontai.dev names: kind: IdentityProvider listKind: IdentityProviderList diff --git a/config/crd/security.ontai.dev_permissionsets.yaml b/config/crd/guardian.ontai.dev_permissionsets.yaml similarity index 99% rename from config/crd/security.ontai.dev_permissionsets.yaml rename to config/crd/guardian.ontai.dev_permissionsets.yaml index d686b8f..3c21cfe 100644 --- a/config/crd/security.ontai.dev_permissionsets.yaml +++ b/config/crd/guardian.ontai.dev_permissionsets.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.1 - name: permissionsets.security.ontai.dev + name: permissionsets.guardian.ontai.dev spec: - group: security.ontai.dev + group: guardian.ontai.dev names: kind: PermissionSet listKind: PermissionSetList diff --git a/config/crd/security.ontai.dev_permissionsnapshotreceipts.yaml b/config/crd/guardian.ontai.dev_permissionsnapshotreceipts.yaml similarity index 98% rename from config/crd/security.ontai.dev_permissionsnapshotreceipts.yaml rename to config/crd/guardian.ontai.dev_permissionsnapshotreceipts.yaml index 2351f1c..106e77f 100644 --- a/config/crd/security.ontai.dev_permissionsnapshotreceipts.yaml +++ b/config/crd/guardian.ontai.dev_permissionsnapshotreceipts.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.1 - name: permissionsnapshotreceipts.security.ontai.dev + name: permissionsnapshotreceipts.guardian.ontai.dev spec: - group: security.ontai.dev + group: guardian.ontai.dev names: kind: PermissionSnapshotReceipt listKind: PermissionSnapshotReceiptList diff --git a/config/crd/security.ontai.dev_permissionsnapshots.yaml b/config/crd/guardian.ontai.dev_permissionsnapshots.yaml similarity index 99% rename from config/crd/security.ontai.dev_permissionsnapshots.yaml rename to config/crd/guardian.ontai.dev_permissionsnapshots.yaml index 0c39ef3..1ccd47d 100644 --- a/config/crd/security.ontai.dev_permissionsnapshots.yaml +++ b/config/crd/guardian.ontai.dev_permissionsnapshots.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.1 - name: permissionsnapshots.security.ontai.dev + name: permissionsnapshots.guardian.ontai.dev spec: - group: security.ontai.dev + group: guardian.ontai.dev names: kind: PermissionSnapshot listKind: PermissionSnapshotList diff --git a/config/crd/security.ontai.dev_rbacpolicies.yaml b/config/crd/guardian.ontai.dev_rbacpolicies.yaml similarity index 98% rename from config/crd/security.ontai.dev_rbacpolicies.yaml rename to config/crd/guardian.ontai.dev_rbacpolicies.yaml index 9cf0c69..4b9d1e0 100644 --- a/config/crd/security.ontai.dev_rbacpolicies.yaml +++ b/config/crd/guardian.ontai.dev_rbacpolicies.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.1 - name: rbacpolicies.security.ontai.dev + name: rbacpolicies.guardian.ontai.dev spec: - group: security.ontai.dev + group: guardian.ontai.dev names: kind: RBACPolicy listKind: RBACPolicyList @@ -30,7 +30,7 @@ spec: schema: openAPIV3Schema: description: |- - RBACPolicy is the governing policy resource for the security.ontai.dev API group. + RBACPolicy is the governing policy resource for the guardian.ontai.dev API group. It constrains what RBACProfiles within its scope may declare. Profiles that exceed their governing policy are rejected at admission. guardian-schema.md §7. properties: diff --git a/config/crd/security.ontai.dev_rbacprofiles.yaml b/config/crd/guardian.ontai.dev_rbacprofiles.yaml similarity index 99% rename from config/crd/security.ontai.dev_rbacprofiles.yaml rename to config/crd/guardian.ontai.dev_rbacprofiles.yaml index c46852a..a082603 100644 --- a/config/crd/security.ontai.dev_rbacprofiles.yaml +++ b/config/crd/guardian.ontai.dev_rbacprofiles.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.1 - name: rbacprofiles.security.ontai.dev + name: rbacprofiles.guardian.ontai.dev spec: - group: security.ontai.dev + group: guardian.ontai.dev names: kind: RBACProfile listKind: RBACProfileList diff --git a/config/crd/security.ontai.dev_guardians.yaml b/config/crd/security.ontai.dev_guardians.yaml deleted file mode 100644 index 9ab9028..0000000 --- a/config/crd/security.ontai.dev_guardians.yaml +++ /dev/null @@ -1,146 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.16.1 - name: guardians.security.ontai.dev -spec: - group: security.ontai.dev - names: - kind: Guardian - listKind: GuardianList - plural: guardians - shortNames: - - gdn - singular: guardian - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .status.webhookMode - name: Mode - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - Guardian is the singleton status CR for the guardian operator. - It records the global webhook enforcement mode and per-namespace enforcement - transitions managed by the BootstrapController. - - There is exactly one Guardian CR per cluster, named "guardian" in seam-system. - It is created by guardian on startup if absent. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - GuardianSpec has no user-configurable fields. Guardian is a status-only singleton - CR that records the operator's own admission enforcement state. - type: object - status: - description: |- - GuardianStatus defines the observed admission enforcement state of the guardian - operator. All fields are written exclusively by guardian controllers. - properties: - conditions: - description: Conditions holds standard Kubernetes status conditions - for Guardian. - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - namespaceEnforcements: - additionalProperties: - type: boolean - description: |- - NamespaceEnforcements records the set of namespaces that have transitioned to - full RBAC enforcement. Populated by BootstrapController when all RBACProfiles - in a namespace reach Provisioned=True. This transition is one-way and irreversible. - Keys are namespace names; values are always true (absent key = not yet enforcing). - type: object - webhookMode: - description: |- - WebhookMode is the current global admission enforcement mode. - Transitions: Initialising → ObserveOnly (when bootstrap RBACProfiles provisioned). - ObserveOnly is the stable global mode; per-namespace enforce transitions are - tracked in NamespaceEnforcements. INV-020, CS-INV-004. - type: string - type: object - type: object - served: true - storage: true - subresources: - status: {} diff --git a/config/webhook/lineage-validating-webhook-configuration.yaml b/config/webhook/lineage-validating-webhook-configuration.yaml index 3d0452c..124ddf2 100644 --- a/config/webhook/lineage-validating-webhook-configuration.yaml +++ b/config/webhook/lineage-validating-webhook-configuration.yaml @@ -6,14 +6,14 @@ metadata: # caBundle is populated at compile time by compiler enable from guardian-ca-secret. ontai.dev/managed-by: compiler webhooks: - - name: validate-lineage.security.ontai.dev + - name: validate-lineage.guardian.ontai.dev admissionReviewVersions: ["v1"] sideEffects: None # FailurePolicy: Fail — a missing lineage check is a security breach. - # CLAUDE.md §14 Decision 1, seam-core-schema.md §5. + # CLAUDE.md §14 Decision 1, seam-schema.md §5. failurePolicy: Fail rules: - - apiGroups: ["security.ontai.dev"] + - apiGroups: ["guardian.ontai.dev"] apiVersions: ["v1alpha1"] operations: ["UPDATE"] resources: diff --git a/config/webhook/operator-cr-validating-webhook-configuration.yaml b/config/webhook/operator-cr-validating-webhook-configuration.yaml index af7ffd2..aa84fae 100644 --- a/config/webhook/operator-cr-validating-webhook-configuration.yaml +++ b/config/webhook/operator-cr-validating-webhook-configuration.yaml @@ -6,7 +6,7 @@ metadata: # caBundle is populated at compile time by compiler enable from guardian-ca-secret. ontai.dev/managed-by: compiler webhooks: - - name: validate-operator-cr.security.ontai.dev + - name: validate-operator-cr.guardian.ontai.dev admissionReviewVersions: ["v1"] sideEffects: None # FailurePolicy: Fail -- if the webhook is unreachable, admission is blocked. @@ -14,17 +14,17 @@ webhooks: # G-BL-CR-IMMUTABILITY. failurePolicy: Fail rules: - - apiGroups: ["infra.ontai.dev"] + - apiGroups: ["seam.ontai.dev"] apiVersions: ["v1alpha1"] operations: ["UPDATE"] - resources: ["packinstances", "packexecutions"] + resources: ["packinstalleds", "packexecutions"] scope: "*" - - apiGroups: ["runner.ontai.dev"] + - apiGroups: ["seam.ontai.dev"] apiVersions: ["v1alpha1"] operations: ["UPDATE"] resources: ["runnerconfigs"] scope: "*" - - apiGroups: ["security.ontai.dev"] + - apiGroups: ["guardian.ontai.dev"] apiVersions: ["v1alpha1"] operations: ["UPDATE"] resources: ["permissionsnapshots"] diff --git a/config/webhook/validating-webhook-configuration.yaml b/config/webhook/validating-webhook-configuration.yaml index a463bde..1d0a80d 100644 --- a/config/webhook/validating-webhook-configuration.yaml +++ b/config/webhook/validating-webhook-configuration.yaml @@ -6,7 +6,7 @@ metadata: # caBundle is populated at compile time by compiler enable from guardian-ca-secret. ontai.dev/managed-by: compiler webhooks: - - name: validate-rbac.security.ontai.dev + - name: validate-rbac.guardian.ontai.dev admissionReviewVersions: ["v1"] sideEffects: None # FailurePolicy: Fail — if the webhook is unreachable, admission is blocked. diff --git a/docs/guardian-schema.md b/docs/guardian-schema.md index 0092d58..673b2b8 100644 --- a/docs/guardian-schema.md +++ b/docs/guardian-schema.md @@ -1,86 +1,81 @@ # guardian-schema -> API Group: security.ontai.dev +> API Group: guardian.ontai.dev > Operator: guardian -> All agents absorb this document. Security is platform-wide. +> All agents absorb this document. RBAC governance is platform-wide. --- ## 1. Domain Boundary -guardian owns all RBAC across the entire platform - management cluster and -every target cluster. It is the only operator with cross-cutting authority. It -is the only operator with genuine in-process intelligence (EPG computation, policy -validation, admission webhook). It is the only operator with a CNPG dependency. +guardian owns all RBAC across the entire platform - management cluster and every target +cluster. It is the only operator with cross-cutting authority. It is the only operator +with genuine in-process intelligence (EPG computation, policy validation, admission +webhook). It is the only operator with a CNPG dependency. **Absolute rules with no exceptions:** - No ONT operator or application implements its own authorization logic. - No component provisions its own Kubernetes RBAC artifacts. - All authorization flows through guardian's PermissionService. -- guardian's admission webhook gates every RBAC resource on every cluster. +- guardian's admission webhook gates every RBAC resource on the management cluster. - guardian deploys first. All other operators wait for RBACProfile provisioned=true before being considered enabled. INV-003. **Deployment boundary:** -Guardian is a single binary with two declared deployment roles - see §15 Guardian Role -Model for the complete contract. Role=management is deployed on the management cluster -by compiler enable. Role=tenant is optionally deployed on tenant clusters exclusively -via ClusterPack through Wrapper. Platform never deploys Guardian and never assumes -Guardian is present on any target cluster. - -On target clusters without a Guardian ClusterPack: the security plane responsibilities -- admission webhook, PermissionSnapshot receipt, local PermissionService, RBAC -enforcement - are hosted exclusively by the conductor Deployment in ont-system. This -is the default model for all target clusters. - -On target clusters running a Guardian ClusterPack (role=tenant or role=management): -Guardian runs alongside Conductor. The role determines which controller sets register -and whether the tenant's security plane is federated with or sovereign from the -management Guardian. Conductor continues to own the admission webhook and +Guardian is a single binary with two declared deployment roles. Role=management is +deployed on the management cluster exclusively, provisioned by compiler enable. +Role=tenant is optionally deployed on tenant clusters via Dispatcher ClusterPack. +Platform never deploys Guardian and never assumes Guardian is present on any target +cluster. + +On target clusters without a Guardian ClusterPack: conductor in agent mode in ont-system +hosts the security plane responsibilities -- admission webhook, PermissionSnapshot receipt, +local PermissionService, and RBAC enforcement. This is the default model for all target +clusters. + +On target clusters running a Guardian ClusterPack (role=tenant): Guardian runs alongside +Conductor. The tenant Guardian is sovereign by default. Conductor continues to own the PermissionSnapshotReceipt on all target clusters regardless of Guardian presence. --- ## 2. Namespace Placement -| Resource | Namespace | -|-------------------------------------------------------|-------------------------------------------------------------------------------| -| RBACPolicy (management fleet, compiler-created) | seam-system -- canonical name `management-policy` | -| PermissionSet (management ceiling, compiler-created) | seam-system -- canonical name `management-maximum` | -| RBACProfile (Seam operators) | seam-system on management cluster; ont-system on tenant cluster | -| RBACPolicy (cluster-level, guardian-created) | seam-tenant-{clusterName} -- canonical name `cluster-policy` | -| PermissionSet (cluster ceiling, guardian-created) | seam-tenant-{clusterName} -- canonical name `cluster-maximum` | -| RBACProfile (all non-seam-operator components) | seam-tenant-{clusterName} -- all third-party, pack, and non-seam components | -| IdentityBinding | tenant namespace | -| PermissionSnapshot | seam-system (internal to guardian) | -| CNPG cluster | seam-system | -| PermissionSnapshotReceipt | ont-system on target cluster | +| Resource | Namespace | +|------------------------------------------------------|------------------------------------------------------------------------------| +| RBACPolicy (management fleet, compiler-created) | seam-system -- canonical name `management-policy` | +| PermissionSet (management ceiling, compiler-created) | seam-system -- canonical name `management-maximum` | +| RBACProfile (seam operators) | seam-system on management cluster; ont-system on tenant cluster | +| RBACPolicy (cluster-level, guardian-created) | seam-tenant-{clusterName} -- canonical name `cluster-policy` | +| PermissionSet (cluster ceiling, guardian-created) | seam-tenant-{clusterName} -- canonical name `cluster-maximum` | +| RBACProfile (all non-seam-operator components) | seam-tenant-{clusterName} -- all third-party, pack, and non-seam components | +| IdentityBinding | tenant namespace | +| IdentityProvider | seam-system | +| PermissionSnapshot | seam-system (internal to guardian) | +| CNPG cluster | seam-system | +| PermissionSnapshotReceipt | ont-system on target cluster | **Canonical name contract:** The names `management-policy`, `management-maximum`, `cluster-policy`, and `cluster-maximum` are platform constants. Compiler uses -`management-policy`/`management-maximum`. Guardian's ClusterRBACPolicyReconciler -uses `cluster-policy`/`cluster-maximum`. No other names are valid for these objects. +`management-policy` and `management-maximum`. Guardian's ClusterRBACPolicyReconciler +uses `cluster-policy` and `cluster-maximum`. No other names are valid for these objects. **RBACPolicy authorship rule (invariant):** RBACPolicy is never human-authored. Compiler authors `management-policy` as part of -the bootstrap/enable bundle. Guardian's ClusterRBACPolicyReconciler authors one -`cluster-policy` per cluster when InfrastructureTalosCluster is admitted. These are -the only two authorship paths. No other controller or human may create an RBACPolicy. +the bootstrap and enable bundle. Guardian's ClusterRBACPolicyReconciler authors one +`cluster-policy` per cluster when a TalosCluster is admitted. These are the only two +authorship paths. No other controller or human may create an RBACPolicy. **Seam operator RBACProfile placement:** -Seam operator profiles (guardian, platform, wrapper, conductor, seam-core) are created -by compiler enable as part of the management cluster bootstrap bundle. On the management +Seam operator profiles (guardian, platform, dispatcher, conductor) are created by +compiler enable as part of the management cluster bootstrap bundle. On the management cluster they live in seam-system and reference `management-policy`. On tenant clusters they live in ont-system and reference that tenant cluster's `cluster-policy`. **All other component RBACProfile placement:** -Every component that is not a Seam operator -- third-party tools (cert-manager, kueue, +Every component that is not a seam operator -- third-party tools (cert-manager, kueue, metallb), pack components, and any future additions -- has its RBACProfile in -`seam-tenant-{clusterName}` referencing that cluster's `cluster-policy`. The actual -Kubernetes RBAC resources (Roles, ClusterRoles, RoleBindings) for these components -live in their operational namespaces as before. Only the governance record (RBACProfile) -moves to seam-tenant-{clusterName}. There are no per-component RBACPolicy objects and -no per-component PermissionSet objects. The cluster-maximum PermissionSet is the sole -ceiling for all components on that cluster. +`seam-tenant-{clusterName}` referencing that cluster's `cluster-policy`. There are no +per-component RBACPolicy objects and no per-component PermissionSet objects. **RBACProfileReconciler same-namespace constraint:** The governing RBACPolicy must be in the same namespace as the RBACProfile. This @@ -89,373 +84,485 @@ constraint is satisfied by design: seam operator profiles in seam-system referen reference `cluster-policy` in seam-tenant-{clusterName}. **seam-tenant-{clusterName} namespace lifecycle:** -ClusterRBACPolicyReconciler (role=management only) creates `cluster-maximum` -PermissionSet and `cluster-policy` RBACPolicy when InfrastructureTalosCluster is -admitted. When the TalosCluster CR is deleted, the reconciler cascades deletion of -all RBACProfiles labeled `ontai.dev/policy-type=component` in the namespace, then -`cluster-maximum` and `cluster-policy`, then removes its finalizer from the -TalosCluster. Cross-namespace ownerReferences are prohibited by Kubernetes; the +ClusterRBACPolicyReconciler creates `cluster-maximum` PermissionSet and `cluster-policy` +RBACPolicy when a TalosCluster is admitted. On TalosCluster deletion the reconciler +cascades deletion of all RBACProfiles labeled `ontai.dev/policy-type=component` in the +namespace, then `cluster-maximum` and `cluster-policy`, then removes its finalizer from +the TalosCluster. Cross-namespace ownerReferences are prohibited by Kubernetes; the finalizer pattern is the authoritative lifecycle coupling (see §18 and §19). --- ## 3. Management Cluster Boot Sequence -**This section supersedes the former Two-Phase Boot model as of 2026-04-05.** - Guardian on the management cluster starts after CNPG is already operational. -Compiler enable phase 0 (00-infrastructure-dependencies) provisions the CNPG operator -and CNPG Cluster CR before Guardian is deployed - see §16 CNPG Deployment Contract and -conductor-schema.md §9 for the six-phase enable bundle structure. +Compiler enable phase 0 (`00-infrastructure-dependencies`) provisions the CNPG operator +and CNPG Cluster CR before Guardian is deployed. **Guardian startup sequence (management cluster, role=management):** **Step 1 - Migration runner:** Before registering any controller, Guardian's startup migration runner connects to the CNPG instance and applies all pending schema migrations in order. If CNPG is unreachable -at startup, Guardian emits a `CNPGUnreachable` condition on its singleton status CR and -holds in degraded state - all controller reconciliation is suspended, no crash occurs. -Guardian recovers automatically when CNPG becomes reachable and the migration runner -completes successfully. This is the only blocking gate before controller registration. +at startup, Guardian holds in degraded state -- all controller reconciliation is suspended, +no crash occurs. Guardian recovers automatically when CNPG becomes reachable and the +migration runner completes successfully. This is the only blocking gate before controller +registration. **Step 2 - Bootstrap annotation sweep and third-party profile creation:** After the migration runner completes, the bootstrap annotation sweep runnable starts. It runs in two phases: Phase 2a - Annotation sweep: All pre-existing RBAC resources (Roles, RoleBindings, -ClusterRoles, ClusterRoleBindings, ServiceAccounts) across all non-exempt namespaces -are stamped with `ontai.dev/rbac-owner=guardian` and -`ontai.dev/rbac-enforcement-mode=audit`. This phase runs in audit mode -- RBAC changes -are logged but not rejected. kube-system is always skipped. The sweep is idempotent. - -Phase 2b - Third-party profile creation: Immediately after the sweep completes, -Guardian creates baseline PermissionSet, RBACPolicy, and RBACProfile for each -third-party component whose namespace exists on the cluster. Resources are created -in the component's canonical namespace (cert-manager, kueue-system, -metallb-system, local-path-storage). Cilium is excluded -- kube-system is -sweep-exempt. If a component namespace is absent, that component is skipped silently. -This creation is idempotent -- existing resources are left unchanged. +ClusterRoles, ClusterRoleBindings, ServiceAccounts) across all non-exempt namespaces are +stamped with `ontai.dev/rbac-owner=guardian` and `ontai.dev/rbac-enforcement-mode=audit`. +Enforcement mode during this phase is audit -- RBAC changes are logged but not rejected. +kube-system is always skipped. The sweep is idempotent. + +Phase 2b - Third-party profile creation: Immediately after the sweep completes, Guardian +creates baseline PermissionSet, RBACPolicy, and RBACProfile for each third-party component +whose namespace exists on the cluster. Resources are created in the component's canonical +namespace (cert-manager, kueue-system, metallb-system, local-path-storage). Cilium is +excluded -- kube-system is sweep-exempt. If a component namespace is absent, that component +is skipped silently. This creation is idempotent -- existing resources are left unchanged. Once both phases complete, `SweepDone` is set to true. **Step 3 - Controller registration and enforcement mode transition:** All role-gated controllers register (see §15 for the role=management controller set). -The admission webhook becomes operational. The bootstrap RBAC window closes. +The admission webhook becomes operational. The bootstrap RBAC window closes permanently. -BootstrapController monitors all RBACProfiles across all namespaces. Once all -profiles (Seam operator profiles in seam-system plus third-party profiles in their -component namespaces) reach `provisioned=true`, WebhookMode advances: -Initialising -> ObserveOnly -> Enforcing. +BootstrapController monitors all RBACProfiles across all namespaces. Once all profiles +reach `provisioned=true`, WebhookMode advances: `Initialising -> ObserveOnly -> Enforcing`. -In Enforcing mode, any RBAC resource created or updated without -`ontai.dev/rbac-owner=guardian` is rejected at admission. All RBAC changes must go -through Guardian. The only path for a third-party component to change its RBAC -after this point is through an updated RBACProfile. +In Enforcing mode, any RBAC resource created or updated without `ontai.dev/rbac-owner=guardian` +is rejected at admission. If the management cluster is rebuilt, all three steps re-execute in order. The migration -runner is idempotent - it applies only unapplied migrations and is safe to re-run. +runner is idempotent. --- ## 4. Bootstrap RBAC Window -Before guardian's admission webhook is operational on the management cluster, -the conductor enable phase must apply RBAC to install guardian itself. This -window is explicitly declared in the enable phase protocol. The bootstrap RBACPolicy -in git defines exactly what is permitted in this window. As soon as guardian's -webhook becomes operational, the window closes permanently. RBAC applied in this -window is immediately reconciled by guardian on startup - validated and -ownership-annotated if compliant, flagged for remediation if not. INV-020. - -On target clusters, the bootstrap RBAC window is handled differently: the conductor -Deployment arrives via the agent ClusterPack deployment. Once the conductor starts -on a target cluster, its admission webhook is immediately operational. There is no -bootstrap RBAC window on target clusters - the agent pack is applied via the -agent bootstrap exception (wrapper-schema.md Section 6) before any webhook exists, -and from that point forward the webhook runs continuously. +Before guardian's admission webhook is operational on the management cluster, the conductor +enable phase must apply RBAC to install guardian itself. This window is explicitly declared +in the enable phase protocol. The bootstrap RBACPolicy in git defines exactly what is +permitted in this window. As soon as guardian's webhook becomes operational, the window +closes permanently. INV-020. + +RBAC applied in this window is immediately reconciled by guardian on startup: validated and +ownership-annotated if compliant, flagged for remediation if not. + +On target clusters, conductor in agent mode starts with its admission webhook immediately +operational. There is no bootstrap RBAC window on target clusters. --- ## 5. Admission Webhook -guardian runs an admission webhook on the management cluster. The webhook -intercepts all creates and updates to: Role, ClusterRole, RoleBinding, -ClusterRoleBinding, ServiceAccount. - -Any RBAC resource arriving without annotation ontai.dev/rbac-owner=guardian -is rejected at admission with a structured error. The only path for RBAC resources -to land on the management cluster is through guardian taking ownership first. - -**On target clusters:** The admission webhook is hosted by the conductor Deployment -in ont-system, not by a separate guardian controller. The conductor webhook -uses the current PermissionSnapshotReceipt as its authority for admission decisions. -This means target cluster RBAC enforcement is fully operational even when the -management cluster is temporarily unreachable - the conductor serves decisions -from its local acknowledged snapshot state. - -The webhook behavior is identical on management and target clusters: any RBAC -resource lacking the `ontai.dev/rbac-owner=guardian` annotation is rejected. - -**Tenant cluster bootstrap sweep (Conductor role=tenant):** -Conductor role=tenant mirrors this enforcement model independently. On leader -election it runs `TenantBootstrapSweep` in two phases: - -Phase 1 (annotation sweep): stamps `ontai.dev/rbac-owner=guardian` on all -pre-existing RBAC resources (Role, ClusterRole, RoleBinding, ClusterRoleBinding, -ServiceAccount) using the same annotation constants as Guardian. Enforcement mode -during this phase is audit -- resources lacking the annotation are logged but -admitted. - -Phase 2 (profile creation): creates PermissionSet, RBACPolicy, and RBACProfile -for each component in its known catalog (cert-manager, kueue, cnpg, metallb, -local-path-provisioner) via the dynamic client in the component's canonical namespace. -Components whose namespace is absent are skipped and retried on the next periodic -run. If Guardian's security CRDs are not installed on the tenant cluster, the profile -creation phase is skipped entirely and the enforcement gate remains in audit mode. - -After both phases complete, the `EnforcementGate` transitions to strict mode and -the admission webhook at `/validate/rbac-ownership` begins rejecting unannotated -RBAC resources. The sweep repeats every 5 minutes so newly deployed Helm charts are -picked up without restart. - -Annotation constants (`ontai.dev/rbac-owner=guardian`) are defined in both Guardian -and Conductor independently -- Conductor does not import Guardian internal packages. -Both use identical string literals. CS-INV-001, conductor-schema.md §15. +Guardian runs an admission webhook on the management cluster. The webhook intercepts all +creates and updates to: Role, ClusterRole, RoleBinding, ClusterRoleBinding, ServiceAccount. + +Any RBAC resource arriving without annotation `ontai.dev/rbac-owner=guardian` is rejected +at admission with a structured error. The only path for RBAC resources to land on the +management cluster is through guardian taking ownership first. CS-INV-001. + +**On target clusters:** +The admission webhook is hosted by the conductor Deployment in ont-system, not by a +separate guardian controller. The conductor webhook uses the current PermissionSnapshotReceipt +as its authority for admission decisions. Target cluster RBAC enforcement is fully operational +even when the management cluster is temporarily unreachable. + +The webhook behavior is identical on management and target clusters: any RBAC resource +lacking `ontai.dev/rbac-owner=guardian` is rejected. + +**Tenant cluster bootstrap sweep (conductor role=tenant):** +On leader election, conductor role=tenant runs `TenantBootstrapSweep`: + +Phase 1 (annotation sweep): stamps `ontai.dev/rbac-owner=guardian` on all pre-existing +RBAC resources using the same annotation constants as Guardian. Enforcement mode during +this phase is audit. + +Phase 2 (profile creation): creates PermissionSet, RBACPolicy, and RBACProfile for each +component in its known catalog via the dynamic client in the component's canonical namespace. +Components whose namespace is absent are skipped and retried on the next periodic run. +If Guardian's CRDs are not installed on the tenant cluster, the profile creation phase is +skipped entirely and the enforcement gate remains in audit mode. + +After both phases complete, the `EnforcementGate` transitions to strict mode and the +admission webhook at `/validate/rbac-ownership` begins rejecting unannotated RBAC +resources. The sweep repeats every 5 minutes so newly deployed Helm charts are picked up +without restart. + +Annotation constants (`ontai.dev/rbac-owner=guardian`) are defined independently in both +Guardian and Conductor -- Conductor does not import Guardian internal packages. Both use +identical string literals. CS-INV-001. --- ## 6. Third-Party RBAC Ownership -**LOCKED INVARIANT (partial) - Platform Governor directive 2026-04-05: RBACProfile authorship.** - -guardian wraps third-party component RBAC - CNPG, cert-manager, Kueue, metallb, -and future components - into RBACProfiles with ownership annotations. +Guardian wraps third-party component RBAC -- CNPG, cert-manager, Kueue, metallb, and +future components -- into RBACProfiles with ownership annotations. The model is wrapping, not replacement: - guardian creates a RBACProfile declaring policy compliance for the component. -- Existing RBAC resources are annotated: ontai.dev/rbac-owner=guardian. -- guardian watches those resources. Drift from the declared RBACProfile raises - a policy violation. It never silently overwrites. -- The conductor enable phase splits compiled chart output into RBAC resources and - workload resources. RBAC goes through guardian intake. Workload applies directly. +- Existing RBAC resources are annotated: `ontai.dev/rbac-owner=guardian`. +- guardian watches those resources. Drift from the declared RBACProfile raises a policy + violation. It never silently overwrites. Any ONT operator joining the stack on the management cluster must, by default, request -RBAC from guardian before its controller starts. The RBACProfile gate (provisioned=true) +RBAC from guardian before its controller starts. The RBACProfile gate (`provisioned=true`) blocks all operator controllers until guardian has validated and provisioned their permission declarations. INV-003. **RBACProfile authorship - automatic bootstrap creation:** -Guardian automatically creates baseline PermissionSet, RBACPolicy, and RBACProfile -for known third-party components (cert-manager, kueue, CNPG, metallb, -local-path-provisioner) as part of Phase 2b of the bootstrap sequence. Resources are -created in the component's canonical namespace immediately after the annotation sweep -completes and before SweepDone is set. This is Guardian's authoritative bootstrap -path for its known component catalog. - -Baseline PermissionSets grant broad access (`*/*` with all standard verbs) during -the bootstrap phase. Post-bootstrap, operators may submit updated RBACProfiles with +Guardian automatically creates baseline PermissionSet, RBACPolicy, and RBACProfile for +known third-party components (cert-manager, kueue, CNPG, metallb, local-path-provisioner) +as part of Phase 2b of the bootstrap sequence. Baseline PermissionSets grant broad access +during the bootstrap phase. Post-bootstrap, operators may submit updated RBACProfiles with tighter PermissionSets to reduce to least-privilege. -For components not in Guardian's static catalog, `compiler component` remains the -authorship path. See conductor-schema.md §16. - -**Enforcement boundary:** -During the annotation sweep (Phase 2a), enforcement mode is audit: RBAC changes -are logged but not rejected. Once all third-party profiles reach Provisioned=True -and WebhookMode advances to Enforcing, any RBAC resource created or updated without -`ontai.dev/rbac-owner=guardian` is rejected at admission. The only path for a -component to change its RBAC after this point is through an updated RBACProfile -submitted to Guardian. - -**Seam operator RBACProfiles:** -The first-class platform-owned RBACProfiles for Seam operator service accounts -(Guardian, Platform, Wrapper, Conductor, seam-core) are produced by `compiler enable` -as part of the management cluster bootstrap bundle and never modified at runtime. - -**Pack component RBAC intake flow (Governor-approved 2026-04-25):** -When a ClusterPack is compiled, it produces an OCI artifact that includes an RBAC -layer (Kubernetes Role/ClusterRole/RoleBinding/ClusterRoleBinding manifests) and -a declared permission profile for the component. Guardian intake handles the RBAC -layer; conductor (role=tenant) handles the same on tenant clusters. The intake -creates exactly one governance object per component in `seam-tenant-{targetCluster}`: - -RBACProfile named `{componentName}` -- the component's governance entry with: +**Pack component RBAC intake flow:** +When a ClusterPack is compiled, it produces an OCI artifact that includes an RBAC layer +and a declared permission profile for the component. Guardian's `/rbac-intake/pack` +endpoint handles the RBAC layer for the management cluster. + +The intake creates exactly one governance object per component in `seam-tenant-{targetCluster}`: + +RBACProfile named `{componentName}` with: - `rbacPolicyRef: cluster-policy` -- references the cluster-level RBACPolicy -- `permissionDeclarations` -- the component's specific permission claim declared - inline (no separate PermissionSet object per component) +- `permissionDeclarations` -- the component's specific permission claim declared inline - Labels: `ontai.dev/managed-by=guardian`, `ontai.dev/policy-type=component` -There are no per-component RBACPolicy objects and no per-component PermissionSet -objects. The cluster-maximum PermissionSet is the sole governance ceiling; component -permission claims live in RBACProfile.permissionDeclarations. +There are no per-component RBACPolicy objects and no per-component PermissionSet objects. +The cluster-maximum PermissionSet is the sole governance ceiling; component permission +claims live in RBACProfile.permissionDeclarations. The intake guard: if `cluster-policy` does not yet exist in `seam-tenant-{targetCluster}`, the intake handler returns an error and the caller retries. ClusterRBACPolicyReconciler (§18, §19) must run before any component RBAC can be registered. -On ClusterPack deletion or TalosCluster deletion: ClusterRBACPolicyReconciler cascades -deletion of all RBACProfiles labeled `ontai.dev/policy-type=component` in -`seam-tenant-{clusterName}`, eliminating orphaned governance records. The actual -Kubernetes RBAC resources (Roles etc.) in operational namespaces are cleaned up by -the pack-delete flow independently. +On ClusterPack or TalosCluster deletion: ClusterRBACPolicyReconciler cascades deletion +of all RBACProfiles labeled `ontai.dev/policy-type=component` in `seam-tenant-{clusterName}`. +The actual Kubernetes RBAC resources in operational namespaces are cleaned up by the +pack-delete flow independently. --- -## 7. CRDs - Management Cluster +## 7. Master GVK Reference + +All types under `guardian.ontai.dev/v1alpha1`. + +| Kind | Short | Scope | Authored by | Notes | +|---------------------------|-------|------------|--------------------------------------------|-------------------------------------------| +| RBACPolicy | rp | Namespaced | Compiler (Layer 1), Guardian reconciler (Layer 2) | Never human-authored | +| RBACProfile | rbp | Namespaced | Compiler (seam operators), guardian intake | `provisioned=true` set exclusively by Guardian | +| PermissionSet | ps | Namespaced | Compiler (management-maximum), Guardian (cluster-maximum) | No per-component PermissionSets | +| PermissionSnapshot | psn | Namespaced | EPGReconciler | Never hand-authored; signed by conductor | +| PermissionSnapshotReceipt | psr | Namespaced | Conductor agent mode | Lives in ont-system on target cluster | +| IdentityProvider | idp | Namespaced | Human operator | Upstream trust anchor | +| IdentityBinding | ib | Namespaced | Human operator | Maps identity to permission principal | + +--- + +## 8. CRDs - Management Cluster ### RBACPolicy -Scope: Namespaced. seam-system for Layer 1 (management-policy). seam-tenant-{clusterName} -for Layer 2 (cluster-policy). See §19 for the authoritative placement rules. -Short name: rp +Scope: Namespaced. `seam-system` for Layer 1 (`management-policy`). +`seam-tenant-{clusterName}` for Layer 2 (`cluster-policy`). See §19 for placement rules. +Short name: `rp` Governing policy that constrains what RBACProfiles within its scope may declare. Profiles that exceed their governing policy are rejected at admission. -management-policy is compiler-created and committed to git as part of the enable -bundle. cluster-policy is guardian-created by ClusterRBACPolicyReconciler when -InfrastructureTalosCluster is admitted. Neither is human-authored. +`management-policy` is compiler-created and committed to git as part of the enable bundle. +`cluster-policy` is guardian-created by ClusterRBACPolicyReconciler when a TalosCluster +is admitted. Neither is human-authored. + +**Spec fields:** + +| Field | Type | Required | Description | +|------------------------|----------------|----------|------------------------------------------------------------------------------------| +| subjectScope | string (enum) | Yes | Class of principals this policy governs. Values: `platform`, `tenant` | +| allowedClusters | []string | No | Cluster names this policy permits operations on. Empty = management cluster only | +| maximumPermissionSetRef| string | Yes | Name of PermissionSet CR defining the permission ceiling for governed profiles | +| enforcementMode | string (enum) | Yes | How violations are handled. Values: `strict` (reject), `audit` (log only) | +| lineage | object | No | Sealed causal chain record. Immutable after creation. Decision 1 | + +**Status fields:** -Key spec fields: subjectScope, allowedClusters, maximumPermissionSetRef, -enforcementMode (strict or audit). +| Field | Type | Description | +|-------------------|------------------|---------------------------------------------------------------------| +| observedGeneration| int64 | Spec generation last reconciled | +| conditions | []Condition | Types: `RBACPolicyValid`, `RBACPolicyDegraded` | +| validationSummary | string | Human-readable one-line state summary | +| profileCount | int32 | Number of RBACProfiles currently governed by this policy | + +**Condition reasons:** `ValidationPassed`, `ValidationFailed`, `PermissionSetNotFound`, +`StructureInvalid` --- ### RBACProfile -Scope: Namespaced. seam-system/ont-system for seam operator profiles. seam-tenant-{clusterName} -for all other component profiles. See §19 for the authoritative placement rules. -Short name: rbp +Scope: Namespaced. `seam-system` or `ont-system` for seam operator profiles. +`seam-tenant-{clusterName}` for all other component profiles. See §19 for placement rules. +Short name: `rbp` -Per-component per-tenant permission declaration. Validated against governing -RBACPolicy before provisioned=true is set. No operator is enabled until its -RBACProfile reaches provisioned=true. INV-003. +Per-component per-tenant permission declaration. Validated against the governing RBACPolicy +before `provisioned=true` is set. No operator is enabled until its RBACProfile reaches +`provisioned=true`. INV-003. CS-INV-005: `provisioned=true` is set exclusively by guardian. -Key spec fields: principalRef, targetClusters, permissionDeclarations, -rbacPolicyRef. +**Spec fields:** -Status conditions: Provisioned, ValidationFailed, Pending. +| Field | Type | Required | Description | +|-----------------------|------------------------|----------|-----------------------------------------------------------------------------| +| principalRef | string | Yes | Name of the principal this profile governs | +| targetClusters | []string | Yes | Cluster names this profile grants access to. Must not be empty | +| permissionDeclarations| []PermissionDeclaration| Yes | List of permission declarations for this profile. Must not be empty | +| rbacPolicyRef | string | Yes | Name of the governing RBACPolicy in the same namespace | +| domainIdentityRef | string | No | Reference to the DomainIdentity for seam operator service accounts | +| lineage | object | No | Sealed causal chain record. Immutable after creation. Decision 1 | -Invariant: provisioned=true is set exclusively by guardian. No other controller -writes to RBACProfile status. CS-INV-005. +**PermissionDeclaration fields:** ---- +| Field | Type | Required | Description | +|-----------------|---------------|----------|---------------------------------------------------------------------------------| +| permissionSetRef| string | Yes | Name of a PermissionSet CR in the same namespace | +| scope | string (enum) | Yes | Values: `namespaced`, `cluster` | +| clusters | []string | No | Cluster names this declaration applies to. Empty = all TargetClusters on profile| -### IdentityBinding +**Status fields:** -Scope: Namespaced. -Short name: ib +| Field | Type | Description | +|--------------------|-------------|------------------------------------------------------------------------------------| +| observedGeneration | int64 | Spec generation last reconciled | +| provisioned | bool | Primary gate field. True only when all validation and compliance checks pass. CS-INV-005 | +| conditions | []Condition | Types: `Provisioned`, `ProfileValidated`, `PolicyCompliant` | +| validationSummary | string | Human-readable state summary | +| epgVersion | string | Version of EPG computation that last included this profile | +| lastProvisionedAt | time | Timestamp when Provisioned last transitioned to true. Cleared on regression | -Maps external identity to ONT permission principal. -Key spec fields: identityType (oidc, serviceAccount, certificate), identity-specific -fields, principalName, trustMethod (mtls default, token requires justification and -max 15-minute TTL). +**Condition reasons:** `ProvisioningComplete`, `ProvisioningFailed`, `PolicyNotFound`, +`PolicyViolation`, `PermissionSetMissing`, `EPGPending` --- -### IdentityProvider +### PermissionSet -Scope: Namespaced - seam-system. -Short name: idp +Scope: Namespaced. `seam-system` for `management-maximum`. `seam-tenant-{clusterName}` +for `cluster-maximum`. See §19 for placement rules. Short name: `ps` -Declares an external identity source whose assertions Guardian will recognize and -validate. This is distinct from IdentityBinding: IdentityProvider configures the -upstream source - SSO provider, PKI certificate authority, token issuer, OIDC -endpoint - while IdentityBinding maps a specific identity from that source to a -platform permission principal. One IdentityProvider per external identity source. -Multiple IdentityBindings may reference the same IdentityProvider. +Named, reusable collection of permission rules used as governance ceiling by RBACPolicy. +`management-maximum` is compiler-created alongside `management-policy`. `cluster-maximum` +is guardian-created alongside `cluster-policy` for each TalosCluster. There are no +per-component PermissionSet objects. Component permission claims are declared inline in +RBACProfile.permissionDeclarations. -Key spec fields: type (oidc, pki, token), issuerURL (for OIDC providers), caBundle -(for PKI providers), tokenSigningKey (for token issuers), allowedAudiences, -validationRules. +**Spec fields:** -Status conditions: Reachable, ValidationFailed, Pending. +| Field | Type | Required | Description | +|-------------|-------------------|----------|------------------------------------------------------------------| +| description | string | No | Human-readable explanation of the permission set's intent | +| permissions | []PermissionRule | Yes | List of permission rules. Must not be empty | +| lineage | object | No | Sealed causal chain record. Immutable after creation. Decision 1 | -**Relationship to IdentityBinding:** Guardian validates IdentityBinding trust -assertions against the IdentityProvider declared for that identity type. An -IdentityBinding without a matching IdentityProvider for its identityType is -rejected at admission. The IdentityProvider is the upstream trust anchor. The -IdentityBinding is the principal assignment. +**PermissionRule fields:** ---- +| Field | Type | Required | Description | +|---------------|----------|----------|---------------------------------------------------------------------------------------| +| apiGroups | []string | No | API groups this rule applies to. Empty string = core API group | +| resources | []string | Yes | Resource types this rule applies to. Must not be empty | +| verbs | []Verb | Yes | Permitted operations. Valid values: `get`, `list`, `watch`, `create`, `update`, `patch`, `delete`, `deletecollection` | +| resourceNames | []string | No | Specific resource instance names. Empty = all instances | -### PermissionSet +**Status fields:** -Scope: Namespaced. seam-system for management-maximum. seam-tenant-{clusterName} -for cluster-maximum. See §19 for the authoritative placement rules. -Short name: ps +| Field | Type | Description | +|----------------------|-------------|-----------------------------------------------------------------| +| observedGeneration | int64 | Spec generation last reconciled | +| conditions | []Condition | Type: `PermissionSetValid` | +| profileReferenceCount| int32 | Number of RBACProfiles currently referencing this PermissionSet | -Named permission collection used as governance ceiling by RBACPolicy. management-maximum -is compiler-created alongside management-policy. cluster-maximum is guardian-created -alongside cluster-policy for each TalosCluster. There are no per-component PermissionSet -objects. Component permission claims are declared inline in RBACProfile.permissionDeclarations. -Key spec fields: permissions (API group, resource, verbs), description. +**Condition reasons:** `Valid`, `Invalid` --- ### PermissionSnapshot -Scope: Namespaced - seam-system. Internal to guardian. -Short name: psn +Scope: Namespaced - seam-system. Internal to guardian. Short name: `psn` + +Computed, versioned, signed EPG for a specific target cluster. Generated on any input +change by the EPGReconciler. Signed by the management cluster conductor after generation. +Never manually authored. One per target cluster, replaced in-place on recomputation. + +**Spec fields:** + +| Field | Type | Required | Description | +|----------------------|----------------|----------|-----------------------------------------------------------------------------------------------------| +| targetCluster | string | Yes | Cluster name this snapshot governs | +| version | string | Yes | Monotonically increasing version string (ISO8601 timestamp) | +| generatedAt | time | Yes | Timestamp when this snapshot was generated | +| snapshotTimestamp | time | No | Canonical timestamp used by PermissionSnapshotReconciler for FreshnessCondition | +| signingKeyFingerprint| string | No | Fingerprint of the Ed25519 key used to sign this snapshot | +| freshnessWindowSeconds| int32 | No | Window in seconds within which the snapshot is considered fresh. Default 300 | +| subjects | []SubjectEntry | No | Per-subject permission grant list for the target cluster | +| principalPermissions | []PrincipalPermissionEntry | No | Per-principal EPG permission map populated by EPGReconciler | +| lineage | object | No | Sealed causal chain anchoring this snapshot to its root declaration. Immutable after creation | + +**SubjectEntry fields:** + +| Field | Type | Required | Description | +|-------------|----------------|----------|----------------------------------------------------------------------| +| subjectName | string | Yes | Name of the subject | +| subjectKind | string (enum) | Yes | Values: `ServiceAccount`, `User`, `Group` | +| namespace | string | No | Namespace of the subject. Empty for cluster-scoped subjects | +| permissions | []PermissionEntry | No | List of permission rules granted to this subject | + +**Status fields:** + +| Field | Type | Description | +|--------------------|-------------|--------------------------------------------------------------------------------------| +| observedGeneration | int64 | Spec generation last reconciled | +| expectedVersion | string | Version the management cluster expects agents to acknowledge | +| lastAckedVersion | string | Version last acknowledged by the target cluster agent | +| drift | bool | True when lastAckedVersion != expectedVersion. Deprecated: prefer driftDetected | +| lastSeen | time | Timestamp of the most recent agent acknowledgement | +| signed | bool | True when the conductor signing loop has written the snapshot-signature annotation | +| driftDetected | bool | Set by Guardian's drift detection loop when observed RBAC diverges from snapshot | +| conditions | []Condition | Types: `Fresh`, `Compliant` | + +The signature annotation (`ontai.dev/snapshot-signature`) is written by the management +cluster conductor signing loop, not by the EPGReconciler. Operators and reconcilers must +not write this annotation. It is validated by target cluster conductor before receipt +acknowledgement. + +--- + +### IdentityProvider + +Scope: Namespaced - seam-system. Short name: `idp` + +Declares an external identity source whose assertions Guardian will recognize and validate. +IdentityProvider is the upstream trust anchor. IdentityBinding is the principal assignment. +One IdentityProvider per external identity source. Multiple IdentityBindings may reference +the same IdentityProvider. + +**Spec fields:** -Computed, versioned, signed EPG for a specific target cluster. Generated on any -input change by the EPGReconciler. Signed by the management cluster conductor -after generation. Never manually authored. One per target cluster, replaced -in-place on recomputation. Version field provides monotonic ordering. +| Field | Type | Required | Description | +|------------------|---------------|----------------|--------------------------------------------------------------------------------| +| type | string (enum) | Yes | Identity provider class. Values: `oidc`, `pki`, `token` | +| issuerURL | string | When type=oidc | OIDC provider URL. Reconciler fetches discovery document to verify reachability | +| caBundle | string | When type=pki | PEM-encoded CA certificate bundle for PKI trust | +| tokenSigningKey | string | When type=token| PEM-encoded public key used to verify token signatures | +| allowedAudiences | []string | No | Audiences accepted in identity assertions. Empty = any audience accepted | +| validationRules | []string | No | CEL expressions evaluated against identity assertions. All must pass | +| lineage | object | No | Sealed causal chain record. Immutable after creation. Decision 1 | -Delivery tracking fields: expectedVersion, lastAckedVersion, drift, lastSeen. +**Status fields:** -The signature annotation (ontai.dev/snapshot-signature) is written by the management -cluster conductor signing loop, not by the EPGReconciler. Operators and reconcilers -must not write this annotation. It is validated by target cluster conductor before -receipt acknowledgement. +| Field | Type | Description | +|--------------------|-------------|--------------------------------------------------------------------------| +| observedGeneration | int64 | Spec generation last reconciled | +| conditions | []Condition | Types: `Valid`, `Reachable` (OIDC only) | + +**Condition reasons:** `Valid`, `Invalid`, `Reachable`, `Unreachable`, `Pending` --- -## 8. CRDs - Target Cluster (conductor Managed) +### IdentityBinding + +Scope: Namespaced. Short name: `ib` + +Maps an external identity to an ONT permission principal. The principal name is referenced +by RBACProfile.principalRef. An IdentityBinding without a matching IdentityProvider for +its identityType is rejected at admission. + +**Spec fields:** + +| Field | Type | Required | Description | +|----------------------|---------------|----------------------|----------------------------------------------------------------------------------------| +| identityType | string (enum) | Yes | Class of external identity. Values: `oidc`, `serviceAccount`, `certificate` | +| principalName | string | Yes | Maps to the principal name used in RBACProfiles | +| trustMethod | string (enum) | Yes | Values: `mtls` (default), `token` (requires justification, max 900s TTL) | +| oidcConfig | object | When identityType=oidc | Issuer, clientID, groupsClaim | +| serviceAccountConfig | object | When identityType=serviceAccount | ServiceAccount name and namespace | +| certificateConfig | object | When identityType=certificate | Certificate CN and organization | +| tokenMaxTTLSeconds | int32 | When trustMethod=token | Maximum token lifetime. Hard limit: 900 seconds. Non-configurable security constraint | +| identityProviderRef | string | Required for oidc and certificate | Name of the IdentityProvider in the same namespace serving as trust anchor | +| lineage | object | No | Sealed causal chain record. Immutable after creation. Decision 1 | + +**Status fields:** -All CRDs in this section are created and maintained exclusively by the conductor -Deployment in ont-system on the target cluster. No separate guardian agent -exists on target clusters. conductor incorporates all target cluster security plane -responsibilities. +| Field | Type | Description | +|--------------------|-------------|------------------------------------------------------------------------------| +| observedGeneration | int64 | Spec generation last reconciled | +| conditions | []Condition | Types: `IdentityBindingValid`, `TrustAnchorResolved` | +| validationSummary | string | Human-readable state summary | + +**Condition reasons:** `Valid`, `Invalid`, `TokenTTLExceeded`, `TrustMethodMismatch`, +`TrustAnchorResolved`, `TrustAnchorNotFound`, `TrustAnchorInvalid`, `TrustAnchorTypeMismatch` + +--- + +## 9. CRDs - Target Cluster (conductor managed) + +All CRDs in this section are created and maintained exclusively by the conductor Deployment +in ont-system on the target cluster. No separate guardian agent exists on target clusters. +Conductor incorporates all target cluster security plane responsibilities. ### PermissionSnapshotReceipt -Scope: Namespaced - ont-system on target cluster. -Short name: psr +Scope: Namespaced - ont-system on target cluster. Short name: `psr` + +Local record of the current acknowledged PermissionSnapshot and provisioned RBAC artifact +status. Created and maintained exclusively by conductor in agent mode. Never authored manually. + +Before writing a receipt acknowledgement, conductor verifies the cryptographic signature on +the PermissionSnapshot against the platform public key embedded in the conductor binary. +Verification failure results in `SyncStatus=DegradedSecurityState` and does not advance +`lastAckedVersion`. This prevents a compromised management cluster from pushing malicious +permission snapshots to target clusters. -Local record of current acknowledged PermissionSnapshot and provisioned RBAC -artifact status. Created and maintained exclusively by conductor in agent mode. -Never authored manually. +One PermissionSnapshotReceipt per target cluster. If the management cluster is rebuilt, it +reconstructs delivery status by reading this CR on each cluster. -Before writing a receipt acknowledgement, conductor verifies the cryptographic -signature on the PermissionSnapshot against the platform public key embedded in -the conductor binary. Verification failure results in SyncStatus=DegradedSecurityState -and does not advance lastAckedVersion. This prevents a compromised management cluster -from pushing malicious permission snapshots to target clusters. +**Spec fields:** -One PermissionSnapshotReceipt per target cluster. If the management cluster is -rebuilt, it reconstructs delivery status by reading this CR on each cluster. +| Field | Type | Required | Description | +|-----------------|--------|----------|--------------------------------------------------------------------| +| clusterName | string | Yes | Name of the target cluster this receipt belongs to | +| snapshotVersion | string | Yes | Version of the PermissionSnapshot this receipt acknowledges | +| acknowledgedAt | time | Yes | Timestamp when the agent acknowledged this snapshot | -Key fields (agent-managed): snapshotVersion, acknowledgedAt, localProvisioningStatus, -localArtifacts, syncStatus (InSync, OutOfSync, DegradedSecurityState). +**Status fields:** + +| Field | Type | Description | +|------------------------|----------|-------------------------------------------------------------------------------------| +| localProvisioningStatus| string | Human-readable description of local RBAC provisioning | +| localArtifacts | []string | RBAC artifact names provisioned from this snapshot | +| syncStatus | string | Values: `InSync`, `OutOfSync`, `DegradedSecurityState` | +| degradedSecurityState | bool | True when signature verification failed. Written by conductor agent in tenant mode | +| degradedReason | string | Describes signature verification failure when degradedSecurityState=true | --- -## 9. Permission Propagation +## 10. Permission Propagation -Push is optimization. Pull is correctness. Acknowledgement is truth. -Verification is trust. +Push is optimization. Pull is correctness. Acknowledgement is truth. Verification is trust. -**Delivery contract:** sign snapshot → push snapshot → agent verifies signature → -agent acknowledges → guardian records. +**Delivery contract:** sign snapshot -> push snapshot -> agent verifies signature -> agent acknowledges -> guardian records. -**SnapshotOutOfSync:** acknowledgement not received within 2× TTL (default 10 min). +**SnapshotOutOfSync:** acknowledgement not received within 2x TTL (default 10 min). Consequence: new PackExecution blocked on affected cluster. **DegradedSecurityState:** persistent failure beyond extended threshold, or signature -verification failure. -Consequence: no new authorization decisions permitted. Human intervention required. +verification failure. Consequence: no new authorization decisions permitted. Human +intervention required. **Pull loop:** conductor periodically compares local version against management cluster expected version. Self-heals by pulling and re-verifying. Pull is the correctness @@ -463,37 +570,55 @@ guarantee. Push is the performance optimization. --- -## 10. PermissionService gRPC API +## 11. EPG Computation Model + +The Effective Permission Graph (EPG) is an in-memory, CNPG-backed structure that +represents the complete set of permissions granted to every principal across all clusters. +EPG computation is exclusively a management cluster function in the guardian controller. + +The EPGReconciler triggers on any change to: +- RBACProfile (new, updated, or deleted) +- RBACPolicy (new, updated, or deleted) +- PermissionSet (new, updated, or deleted) + +On trigger, the reconciler: +1. Reads all provisioned RBACProfiles across all governance namespaces. +2. For each profile, resolves the referenced PermissionSet(s) and validates against the + governing RBACPolicy ceiling. +3. Recomputes the effective permission set per principal per cluster. +4. Writes the updated EPG to CNPG. +5. Generates a new PermissionSnapshot for each affected target cluster. +6. Updates `RBACProfile.status.epgVersion` for all included profiles. + +The local PermissionService implementation in conductor is a read-only projection of the +acknowledged snapshot -- it does not compute the EPG. + +--- -Single runtime authorization decision point. All ONT operators and applications -call this service. No operator queries Kubernetes RBAC API directly. +## 12. PermissionService gRPC API -Operations: CheckPermission, ListPermissions, WhoCanDo, ExplainDecision. +Single runtime authorization decision point. All ONT operators and applications call this +service. No operator queries Kubernetes RBAC API directly. -**On management cluster:** the guardian controller exposes the PermissionService -gRPC endpoint backed by the current in-memory EPG (backed by CNPG). +Operations: `CheckPermission`, `ListPermissions`, `WhoCanDo`, `ExplainDecision`. -**On target clusters:** conductor in agent mode exposes a local PermissionService -gRPC endpoint in ont-system. Application operators and controllers on the target -cluster call the local agent endpoint. The agent serves decisions from its current -acknowledged PermissionSnapshotReceipt without requiring management cluster -connectivity. This is how future Screen and application operators achieve runtime -authorization without management cluster network dependency. +**On management cluster:** the guardian controller exposes the PermissionService gRPC +endpoint backed by the current in-memory EPG (backed by CNPG). -The local PermissionService implementation in conductor is a read-only projection -of the acknowledged snapshot - it does not compute the EPG. EPG computation is -exclusively a management cluster function in the guardian controller. +**On target clusters:** conductor in agent mode exposes a local PermissionService gRPC +endpoint in ont-system. Application operators and controllers on the target cluster call +the local agent endpoint. The agent serves decisions from its current acknowledged +PermissionSnapshotReceipt without requiring management cluster connectivity. -PermissionService is the planned QuantAI integration point for AI-proposed -infrastructure operations requiring human gate review. +PermissionService is the planned QuantAI integration point for AI-proposed infrastructure +operations requiring human gate review. --- -## 11. Execution Gatekeeper +## 13. Execution Gatekeeper -All four conditions must pass before PackExecution is admitted to Kueue. Enforced -by guardian's admission webhook on the management cluster - a hard block, not -a soft check: +All four conditions must pass before PackExecution is admitted to Kueue. Enforced by +guardian's admission webhook on the management cluster -- a hard block, not a soft check: 1. Target cluster has current, acknowledged, verified PermissionSnapshot. 2. Requesting principal has validated, provisioned RBACProfile. @@ -502,200 +627,142 @@ a soft check: --- -## 12. Tenant Isolation +## 14. Tenant Isolation Three-layer isolation, each independent of the others: -1. Namespace isolation: tenant-{cluster-name} namespace boundary. +1. Namespace isolation: `seam-tenant-{clusterName}` namespace boundary. 2. RBAC enforcement: tenants cannot list cluster-scoped resources globally. -3. Policy-level: guardian validates targetCluster against allowedClusters at - admission. Bypass via RBAC misconfiguration in layers 1 or 2 is impossible. - ---- - -## 13. CNPG Security Warehouse Access Controls - -NetworkPolicy restricts ingress to seam-system to guardian pods only. -CNPG credentials are Secrets in seam-system with no RBAC bindings for human -users - not even cluster-admin can read them through normal paths. -Audit access for the security team is granted through a designated read-only view -exposed by guardian's PermissionService - never through direct database access. - ---- - -## 14. Cross-Domain Rules - -Reads: platform.ontai.dev/QueueProfile to provision Kueue ClusterQueue resources. -Reads: infrastructure.ontai.dev/InfrastructureTalosCluster to detect new cluster registrations and - create initial RBACProfiles. -Reads: infrastructure.ontai.dev/InfrastructureRunnerConfig status (capability confirmation). -Intercepts: infrastructure.ontai.dev/InfrastructurePackExecution at admission (execution gatekeeper). -Writes: security.ontai.dev resources on management cluster. -Writes: PermissionSnapshotReceipt on target clusters via conductor. -Writes: Kueue ClusterQueue and ResourceFlavor resources (derived from QueueProfile). -Never writes to platform.ontai.dev or infrastructure.ontai.dev CRDs. - -The signing annotation (ontai.dev/snapshot-signature) on PermissionSnapshot is -written by the management cluster conductor, not by the guardian controller. -The controller generates the snapshot. The agent signs it. These are sequential, -not concurrent writes. +3. Policy-level: guardian validates targetCluster against allowedClusters at admission. + Bypass via RBAC misconfiguration in layers 1 or 2 is impossible. --- ## 15. Guardian Role Model -**LOCKED INVARIANT - Platform Governor directive 2026-04-05.** - Guardian is a single binary with two declared deployment roles. The role is injected as -the startup environment variable `GUARDIAN_ROLE`. Guardian refuses to start if -`GUARDIAN_ROLE` is absent or set to any value other than `management` or `tenant`. -An absent or invalid `GUARDIAN_ROLE` causes an immediate structured exit before any -controller or gRPC server initialisation. +the startup environment variable `GUARDIAN_ROLE`. Guardian refuses to start if `GUARDIAN_ROLE` +is absent or set to any value other than `management` or `tenant`. An absent or invalid +`GUARDIAN_ROLE` causes an immediate structured exit before any controller or gRPC server +initialisation. **Role=management:** -Deployed on the management cluster exclusively. Provisioned by compiler enable. The -management cluster Guardian runs with full controller authority: EPG computation, -PermissionSnapshot generation, policy validation, cross-cluster AuditSink, and -PermissionService gRPC. It connects to a management-cluster-local CNPG instance -(provisioned by compiler enable phase 0) for all persistent EPG and audit state. -No human, operator, or pipeline other than compiler enable may stamp role=management -on a Guardian Deployment. +Deployed on the management cluster exclusively. Provisioned by compiler enable. Runs with +full controller authority: EPG computation, PermissionSnapshot generation, policy validation, +cross-cluster AuditSink, and PermissionService gRPC. Connects to a management-cluster-local +CNPG instance provisioned by compiler enable phase 0. No human, operator, or pipeline other +than compiler enable may stamp `role=management` on a Guardian Deployment. **Role=tenant:** -Deployed on tenant clusters exclusively via ClusterPack through Wrapper. Optional per +Deployed on tenant clusters exclusively via ClusterPack through Dispatcher. Optional per tenant choice. Platform never knows whether a tenant has deployed Guardian, and never -depends on its presence. The tenant Guardian always connects to a tenant-local CNPG -instance (provisioned as part of the same ClusterPack). There is no CRD-only mode for -role=tenant - full persistence parity with role=management is the only supported -configuration. The tenant Guardian registers a reduced controller set. Audit forwarding -to the management Guardian is opt-in, controlled exclusively by the +depends on its presence. The tenant Guardian always connects to a tenant-local CNPG instance +provisioned as part of the same ClusterPack. There is no CRD-only mode for role=tenant. +Audit forwarding to the management Guardian is opt-in, controlled exclusively by the `GUARDIAN_AUDIT_FORWARD` environment variable (default: `false`). Tenant Guardian is sovereign by default. **GUARDIAN_AUDIT_FORWARD env var:** Controls whether the tenant Guardian forwards audit events to the management Guardian. -Injected alongside `GUARDIAN_ROLE` in the Guardian Deployment spec. Only valid for -role=tenant; absent for role=management. Any value other than `true` or `false` causes -an immediate structured exit before controller initialisation. Valid values: - -- `false` (default) - **Sovereign mode.** The tenant Guardian is fully sovereign: - independent CNPG instance, independent identity plane, no audit forwarding to the - management Guardian, no participation in the cross-cluster audit chain. The management - Guardian has no knowledge of and no dependency on any tenant Guardian in this mode. -- `true` - **Federated mode.** The tenant Guardian forwards audit events to the - management Guardian via the Conductor federation channel. Conductor is the transport: - the tenant Guardian is the audit producer, the management Guardian is the audit - consumer. The management Guardian processes forwarded events through its AuditSink - pipeline. The tenant Guardian's AuditForwarderController activates in this mode only. - -`GUARDIAN_AUDIT_FORWARD` is a Guardian concern only and has no effect on the Conductor -federation channel. The tenant Conductor connects to the management Conductor for -RunnerConfig validation regardless of Guardian topology. A cross-cluster identity trust -relationship between a tenant Guardian and the management Guardian is established only by -an explicit `federated-downstream` IdentityProvider CR authored by a human - never by -Guardian inference. +Only valid for role=tenant; absent for role=management. Any value other than `true` or `false` +causes an immediate structured exit. + +- `false` (default) -- Sovereign mode. The tenant Guardian is fully sovereign: independent + CNPG instance, independent identity plane, no audit forwarding to the management Guardian. + The management Guardian has no knowledge of and no dependency on any tenant Guardian in + this mode. +- `true` -- Federated mode. The tenant Guardian forwards audit events to the management + Guardian via the Conductor federation channel. Conductor is the transport: the tenant + Guardian is the audit producer, the management Guardian is the audit consumer. **Controller sets registered at startup, gated by role:** -| Controller | role=management | role=tenant (GUARDIAN_AUDIT_FORWARD=false) | role=tenant (GUARDIAN_AUDIT_FORWARD=true) | -|-------------------------------|-----------------|-------------------------------------------|------------------------------------------| -| PolicyReconciler | ✓ | ✓ | ✓ | -| ProfileReconciler | ✓ | ✓ | ✓ | -| IdentityProviderReconciler | ✓ | ✓ | ✓ | -| IdentityBindingReconciler | ✓ | ✓ | ✓ | -| ClusterRBACPolicyReconciler | ✓ | - | - | -| AuditSinkReconciler | ✓ | - | - | -| AuditForwarderController | - | - | ✓ | - -PermissionService gRPC runs in both roles. The management Guardian serves authorization -decisions for the management cluster and all tenant Guardians operating in federated mode -(GUARDIAN_AUDIT_FORWARD=true) that forward audit events to it. The tenant Guardian -(role=tenant) serves decisions for its own cluster locally - this supplements, but does -not replace, the Conductor local PermissionService. - -This is a locked invariant. The role gating on controller registration is permanent. -Adding a controller to a role that does not include it requires a Platform Governor -constitutional amendment. +| Controller | role=management | role=tenant (GUARDIAN_AUDIT_FORWARD=false) | role=tenant (GUARDIAN_AUDIT_FORWARD=true) | +|-----------------------------|-----------------|-------------------------------------------|------------------------------------------| +| PolicyReconciler | yes | yes | yes | +| ProfileReconciler | yes | yes | yes | +| IdentityProviderReconciler | yes | yes | yes | +| IdentityBindingReconciler | yes | yes | yes | +| ClusterRBACPolicyReconciler | yes | no | no | +| AuditSinkReconciler | yes | no | no | +| AuditForwarderController | no | no | yes | + +PermissionService gRPC runs in both roles. + +This is a locked invariant. The role gating on controller registration is permanent. Adding +a controller to a role that does not include it requires a Platform Governor constitutional +amendment. --- ## 16. CNPG Deployment Contract -**LOCKED INVARIANT - Platform Governor directive 2026-04-05.** - **Management cluster:** The CNPG operator and CNPG Cluster CR are provisioned by compiler enable as a dedicated -phase 0 of the enable bundle (`00-infrastructure-dependencies`) - before Guardian is -deployed. See conductor-schema.md §9 for the six-phase enable bundle structure. Guardian's -startup migration runner (§3 Step 1) connects to CNPG and applies pending schema +phase 0 of the enable bundle (`00-infrastructure-dependencies`) -- before Guardian is +deployed. Guardian's startup migration runner connects to CNPG and applies pending schema migrations before registering any controller. If CNPG is unreachable at Guardian startup, -Guardian emits a `CNPGUnreachable` condition on its singleton status CR and holds in -degraded state - controller reconciliation is suspended, no crash occurs. Guardian -recovers automatically when CNPG becomes reachable and the migration runner completes. +Guardian holds in degraded state -- controller reconciliation is suspended, no crash occurs. +Guardian recovers automatically when CNPG becomes reachable and the migration runner +completes. The CNPG deployment on the management cluster is owned exclusively by compiler enable. -No operator writes CNPG resources on the management cluster. Human review of the enable -bundle must verify phase 0 contents before GitOps application. +No operator writes CNPG resources on the management cluster. **Tenant clusters:** -CNPG on a tenant cluster is provisioned via ClusterPack through Wrapper. It is part of -the Guardian tenant deployment pack - a pack the tenant opts into by creating the -appropriate PackExecution. Platform has no knowledge of or dependency on CNPG on any -tenant cluster. CNPG is invisible to Platform. CNPG is invisible to Conductor unless the -tenant's Guardian pack explicitly wires CNPG connectivity. Wrapper delivers the pack -contents; it does not understand or interpret what those contents include. +CNPG on a tenant cluster is provisioned via ClusterPack through Dispatcher. It is part of +the Guardian tenant deployment pack. Platform has no knowledge of or dependency on CNPG on +any tenant cluster. **Authority boundary:** - Management cluster CNPG: owned by compiler enable (phase 0), consumed by Guardian (role=management). - Tenant cluster CNPG: owned by the tenant's ClusterPack, consumed by tenant Guardian. -- No operator other than Guardian has a CNPG dependency. INV-016. +- No operator other than Guardian has a CNPG dependency. INV-016. CS-INV-002. - Platform never provisions CNPG on any cluster under any circumstance. - Conductor never provisions CNPG on any cluster under any circumstance. -**F-P8:** compiler enable phase 0 implementation (adding CNPG operator manifests and CNPG -Cluster CR to the enable bundle as 00-infrastructure-dependencies output) requires a -Conductor Engineer session. This is tracked in CONTEXT.md. - ---- +**CNPG access controls:** +NetworkPolicy restricts ingress to seam-system to guardian pods only. CNPG credentials are +Secrets in seam-system with no RBAC bindings for human users. Audit access is granted +through a designated read-only view exposed by guardian's PermissionService -- never through +direct database access. --- ## 17. Audit Record Schema -Guardian writes audit events to the CNPG audit_events table via `AuditWriter` -(database package). The record type is `database.AuditEvent`. This section -specifies the canonical field contract for that type. +Guardian writes audit events to the CNPG `audit_events` table via `AuditWriter`. The record +type is `database.AuditEvent`. -### AuditEvent fields +**AuditEvent fields:** -| Field | Type | Description | -|----------------|--------|-------------------------------------------------------------------------------| -| ClusterID | string | Identifier of the cluster where the event originated. | -| SequenceNumber | int64 | Monotonic event sequence number. Used for deduplication. | -| Subject | string | Identity of the principal performing the action. | -| Action | string | Dot-namespaced event type (e.g., rbac.wrapped, bootstrap.annotation_sweep). | -| Resource | string | Name of the resource the action targets. | -| Decision | string | Authorization decision: admit or deny. | -| MatchedPolicy | string | Name of the RBACPolicy or rule that produced the decision. Optional. | -| LineageIndexRef| object | Reference to the InfrastructureLineageIndex governing the root declaration associated with this event. Optional -- absent for platform-wide events not associated with a specific root declaration. | +| Field | Type | Description | +|----------------|--------|---------------------------------------------------------------------------------| +| ClusterID | string | Identifier of the cluster where the event originated | +| SequenceNumber | int64 | Monotonic event sequence number. Used for deduplication | +| Subject | string | Identity of the principal performing the action | +| Action | string | Dot-namespaced event type (e.g., `rbac.wrapped`, `bootstrap.annotation_sweep`) | +| Resource | string | Name of the resource the action targets | +| Decision | string | Authorization decision: `admit` or `deny` | +| MatchedPolicy | string | Name of the RBACPolicy or rule that produced the decision. Optional | +| LineageIndexRef| object | Reference to the LineageRecord governing the root declaration for this event. Optional | -### lineageIndexRef +**lineageIndexRef fields:** -| Field | Type | Description | -|-----------|--------|-----------------------------------------------| -| Name | string | Name of the InfrastructureLineageIndex CR. | -| Namespace | string | Namespace of the InfrastructureLineageIndex CR. | +| Field | Type | Description | +|-----------|--------|-----------------------------------------| +| Name | string | Name of the LineageRecord CR | +| Namespace | string | Namespace of the LineageRecord CR | -**Population rule:** Guardian reconcilers populate LineageIndexRef when emitting -audit events for governed objects (RBACProfile provisioned, PermissionSnapshot -drift, IdentityBinding resolved). Platform-wide events (bootstrap annotation sweep, -startup migration complete) leave LineageIndexRef absent. This is not an error -- -absent lineageIndexRef signals a platform-wide event, not an object-scoped event. +**Population rule:** Guardian reconcilers populate LineageIndexRef when emitting audit events +for governed objects (RBACProfile provisioned, PermissionSnapshot drift, IdentityBinding +resolved). Platform-wide events (bootstrap annotation sweep, startup migration complete) leave +LineageIndexRef absent. Absent LineageIndexRef signals a platform-wide event, not an +object-scoped event. **Correlation contract:** When LineageIndexRef is present, the combination -(ClusterID, SequenceNumber, LineageIndexRef.Name, LineageIndexRef.Namespace) -uniquely identifies the event within the causal chain of the root declaration -recorded in the InfrastructureLineageIndex. Vortex uses this to correlate audit -events with lineage records without additional lookups. +(ClusterID, SequenceNumber, LineageIndexRef.Name, LineageIndexRef.Namespace) uniquely +identifies the event within the causal chain of the root declaration recorded in the +LineageRecord. --- @@ -703,22 +770,21 @@ events with lineage records without additional lookups. **Role gate:** role=management only. Never runs on role=tenant instances. -**Watches:** `infrastructure.ontai.dev/v1alpha1/InfrastructureTalosCluster` (seam-system namespace). -Guardian imports seam-core's `seamv1alpha1` package to watch this type -- Decision G. -Also watches changes to `management-maximum` PermissionSet in seam-system and re-queues -all TalosCluster CRs when it changes (so cluster-maximum re-validation runs). +**Watches:** `seam.ontai.dev/v1alpha1/TalosCluster` (seam-system namespace). Also watches +changes to `management-maximum` PermissionSet in seam-system and re-queues all TalosCluster +CRs when it changes so that cluster-maximum re-validation runs. -**Purpose:** For every InfrastructureTalosCluster, maintain exactly one cluster-level -RBACPolicy (`cluster-policy`) and its governance ceiling PermissionSet (`cluster-maximum`) -in the `seam-tenant-{clusterName}` namespace. See §19 for the full three-layer hierarchy. +**Purpose:** For every TalosCluster, maintain exactly one cluster-level RBACPolicy +(`cluster-policy`) and its governance ceiling PermissionSet (`cluster-maximum`) in the +`seam-tenant-{clusterName}` namespace. -**Finalizer:** `security.ontai.dev/cluster-rbac` placed on the TalosCluster CR. +**Finalizer:** `guardian.ontai.dev/cluster-rbac` placed on the TalosCluster CR. **On creation (or reconcile when cluster-policy absent):** 1. Read `management-maximum` PermissionSet from seam-system. Validate that the cluster - PermissionSet to be created is a subset of the management ceiling. This is the - functional obligation check -- option (a), at creation time, not at admission. - No deadlock: management-maximum is compiler-created and exists before guardian starts. + PermissionSet to be created is a subset of the management ceiling. This is CS-INV-009: + creation-time validation, not admission-time. No deadlock: management-maximum is + compiler-created and exists before guardian starts. 2. Create PermissionSet `cluster-maximum` in `seam-tenant-{clusterName}`: - Labels: `ontai.dev/managed-by=guardian`, `ontai.dev/policy-type=cluster` - Spec.permissions: initially broad ceiling; tightened post-bootstrap by operator. @@ -728,17 +794,16 @@ in the `seam-tenant-{clusterName}` namespace. See §19 for the full three-layer - Spec.allowedClusters: [{clusterName}] - Spec.maximumPermissionSetRef: cluster-maximum - Spec.enforcementMode: audit (initial; promoted to strict post-bootstrap) -4. Add finalizer `security.ontai.dev/cluster-rbac` to the TalosCluster CR. +4. Add finalizer `guardian.ontai.dev/cluster-rbac` to the TalosCluster CR. -All steps are idempotent. A second reconcile that finds both objects already present -re-runs the validation check but performs no writes if objects are unchanged. +All steps are idempotent. **On deletion (TalosCluster DeletionTimestamp set):** 1. List and delete all RBACProfiles in `seam-tenant-{clusterName}` labeled - `ontai.dev/policy-type=component` (all non-seam-operator component profiles). + `ontai.dev/policy-type=component`. 2. Delete PermissionSet `cluster-maximum` in `seam-tenant-{clusterName}`. 3. Delete RBACPolicy `cluster-policy` in `seam-tenant-{clusterName}`. -4. Remove finalizer `security.ontai.dev/cluster-rbac` from TalosCluster. +4. Remove finalizer `guardian.ontai.dev/cluster-rbac` from TalosCluster. Steps 2 and 3 run after step 1 completes. Step 4 runs after steps 2 and 3 complete. @@ -750,34 +815,34 @@ cluster-policy or cluster-maximum pointing to TalosCluster. **Label constants:** - `ontai.dev/managed-by`: value `guardian` -- `ontai.dev/policy-type`: value `cluster` for cluster-level objects, `component` for all other component profiles +- `ontai.dev/policy-type`: value `cluster` for cluster-level objects, `component` for + component profiles, `seam-operator` for seam operator profiles in seam-tenant-* namespaces + +--- ## 19. Three-Layer RBAC Hierarchy This section is the authoritative structural specification for the ONT RBAC governance -model. It supersedes any per-section descriptions that conflict with it. - ---- +model. CS-INV-008. ### Layer 1 - Management RBACPolicy (fleet ceiling) **Object:** `management-policy` (RBACPolicy) in seam-system. **Object:** `management-maximum` (PermissionSet) in seam-system. -**Authorship:** compiler exclusively, as part of the bootstrap/enable bundle. - Never created or modified by guardian's reconcilers. +**Authorship:** compiler exclusively, as part of the bootstrap and enable bundle. Never +created or modified by guardian's reconcilers. -`management-policy` governs the entire fleet. It references `management-maximum` -as its ceiling PermissionSet. Without guardian sweeping and taking governance -ownership of this policy on startup, it is inert. Guardian gives it enforcement -meaning through the bootstrap annotation sweep and webhook enforcement chain. +`management-policy` governs the entire fleet. It references `management-maximum` as its +ceiling PermissionSet. Guardian gives it enforcement meaning through the bootstrap +annotation sweep and webhook enforcement chain. **Who references Layer 1:** -Seam operator RBACProfiles (guardian, platform, wrapper, conductor, seam-core). -On the management cluster these profiles live in seam-system. On tenant clusters -they live in ont-system. In both cases `rbacPolicyRef: management-policy`. +Seam operator RBACProfiles (guardian, platform, dispatcher, conductor). On the management +cluster these profiles live in seam-system. On tenant clusters they live in ont-system. +In both cases `rbacPolicyRef: management-policy`. -Layer 1 is the fleet authority. Any operation that spans clusters or authorizes -fleet management actions must be governed by Layer 1. +Layer 1 is the fleet authority. Any operation that spans clusters or authorizes fleet +management actions must be governed by Layer 1. --- @@ -785,35 +850,25 @@ fleet management actions must be governed by Layer 1. **Object:** `cluster-policy` (RBACPolicy) in seam-tenant-{clusterName}. **Object:** `cluster-maximum` (PermissionSet) in seam-tenant-{clusterName}. -**Authorship:** guardian's ClusterRBACPolicyReconciler (role=management only). - Never human-authored. +**Authorship:** guardian's ClusterRBACPolicyReconciler (role=management only). Never +human-authored. -One cluster-policy per TalosCluster. Created when InfrastructureTalosCluster is -admitted to seam-system. The cluster PermissionSet (`cluster-maximum`) is the sole -governance ceiling for all non-seam-operator components on that cluster. There are -no per-component RBACPolicy objects and no per-component PermissionSet objects. +One cluster-policy per TalosCluster. Created when a TalosCluster is admitted to seam-system. +The cluster PermissionSet (`cluster-maximum`) is the sole governance ceiling for all +non-seam-operator components on that cluster. There are no per-component RBACPolicy objects +and no per-component PermissionSet objects. **Functional obligation to Layer 1:** At cluster-policy creation time, ClusterRBACPolicyReconciler reads `management-maximum` from seam-system and validates that `cluster-maximum` is a subset of it. This is -option (a) -- creation-time validation, not admission-time. The deadlock-free guarantee: -management-maximum is compiler-created and guaranteed to exist before guardian starts; -the reconciler only runs after guardian is up. Re-validation occurs whenever +creation-time validation, not admission-time (CS-INV-009). Re-validation occurs whenever management-maximum changes. -**Delivery to tenant clusters:** -`cluster-maximum` PermissionSet is included in the signed PermissionSnapshot -delivered from the management cluster to the tenant cluster. Conductor (role=tenant) -verifies the guardian signature and reconciles the PermissionSet before permitting -any cluster operations. The PermissionSet on a tenant cluster is a signed, delivered -artifact -- never locally authored. - **Management cluster special case:** -The management cluster (ccs-mgmt) holds both Layer 1 (`management-policy` for -fleet-wide authority) and Layer 2 (`cluster-policy` in seam-tenant-ccs-mgmt, created -when InfrastructureTalosCluster for ccs-mgmt is admitted). This is because ccs-mgmt -is also a seam tenant. Fleet-wide operations are governed by Layer 1. Operations -scoped to the ccs-mgmt cluster are governed by Layer 2 for ccs-mgmt. +The management cluster holds both Layer 1 (`management-policy` for fleet-wide authority) +and Layer 2 (`cluster-policy` in seam-tenant-ccs-mgmt, created when the TalosCluster for +ccs-mgmt is admitted). Fleet-wide operations are governed by Layer 1. Operations scoped +to the management cluster are governed by Layer 2 for that cluster. --- @@ -824,27 +879,29 @@ scoped to the ccs-mgmt cluster are governed by Layer 2 for ccs-mgmt. - rbacPolicyRef: management-policy (Layer 1) - Created by compiler enable as part of the bootstrap bundle -**All other component profiles (third-party tools, pack components, seam-core on non-management clusters):** +**All other component profiles (third-party tools, pack components):** - Location: seam-tenant-{clusterName} - rbacPolicyRef: cluster-policy (Layer 2) - Created by guardian intake flow (management cluster) or conductor role=tenant sweep (tenant cluster) -- No separate PermissionSet object per component; permission claims are declared - inline via RBACProfile.permissionDeclarations +- No separate PermissionSet object per component; permission claims are declared inline via + RBACProfile.permissionDeclarations - Label: `ontai.dev/policy-type=component` -**IdentityProvider / IdentityBinding chain:** +**IdentityProvider and IdentityBinding chain:** IdentityProvider declares the upstream trust anchor (OIDC endpoint, PKI CA, token issuer). IdentityBinding maps a specific identity from that provider to an ONT permission principal. The principal is referenced by RBACProfile.principalRef. The governance chain is: - IdentityProvider (trust anchor) - -> IdentityBinding (identity to principal) - -> RBACProfile.principalRef (principal to permissions) - -> rbacPolicyRef: cluster-policy (Layer 2) or management-policy (Layer 1) +``` +IdentityProvider (trust anchor) + -> IdentityBinding (identity to principal) + -> RBACProfile.principalRef (principal to permissions) + -> rbacPolicyRef: cluster-policy (Layer 2) or management-policy (Layer 1) +``` -There is no direct IdentityProvider-to-RBACPolicy link. The link runs through the -principal resolved by IdentityBinding. Multiple RBACProfiles may reference the same -principal. Multiple IdentityBindings may reference the same IdentityProvider. +There is no direct IdentityProvider-to-RBACPolicy link. The link runs through the principal +resolved by IdentityBinding. Multiple RBACProfiles may reference the same principal. +Multiple IdentityBindings may reference the same IdentityProvider. --- @@ -863,34 +920,26 @@ webhook enforces -- no deadlock at any startup phase. ## 20. Tenant Cluster Conductor Onboarding Protocol -This section specifies the two-site handshake that Platform and Guardian execute -together when a TalosCluster with mode=import, role=tenant is admitted. The result -of the protocol is a running Conductor agent on the tenant cluster that operates -under signed management authority. +This section specifies the two-site handshake that Platform and Guardian execute together +when a TalosCluster with mode=import, role=tenant is admitted. ---- +**Participants:** -### Participants - -| Participant | Runs on | Responsibility | -|-------------|---------|----------------| -| Platform (TalosClusterReconciler) | management cluster | Remote infrastructure -- ont-system, SA, Deployment | -| Guardian (ClusterRBACPolicyReconciler) | management cluster | Management-side RBACProfile for tenant conductor | -| Conductor (role=tenant) | tenant cluster | Pull RBACProfile from management, write to ont-system | - ---- +| Participant | Runs on | Responsibility | +|------------------------------------------|--------------------|--------------------------------------------------------------| +| Platform (TalosClusterReconciler) | management cluster | Remote infrastructure: ont-system, SA, Deployment | +| Guardian (ClusterRBACPolicyReconciler) | management cluster | Management-side RBACProfile for tenant conductor | +| Conductor (role=tenant) | tenant cluster | Pull RBACProfile from management, write to ont-system | -### Protocol sequence +**Protocol sequence:** -**Step 1 -- Management-side namespace and secrets (Platform)** +**Step 1 - Management-side namespace and secrets (Platform)** Platform creates `seam-tenant-{clusterName}` and copies the kubeconfig Secret into it. -See platform-schema.md §12 steps 1-2. -**Step 2 -- Guardian provisions conductor-tenant RBACProfile (Guardian)** +**Step 2 - Guardian provisions conductor-tenant RBACProfile (Guardian)** ClusterRBACPolicyReconciler creates a `conductor-tenant` RBACProfile in -`seam-tenant-{clusterName}` as part of its normal reconcile pass for any -role=tenant TalosCluster. This profile is the management-side authoritative -declaration for the tenant conductor's permissions. +`seam-tenant-{clusterName}` as part of its normal reconcile pass for any role=tenant +TalosCluster. Profile contract: - Name: `conductor-tenant` @@ -898,136 +947,75 @@ Profile contract: - Labels: `ontai.dev/managed-by=guardian`, `ontai.dev/policy-type=seam-operator` - Spec.principalRef: `conductor` - Spec.targetClusters: `[clusterName]` -- Spec.rbacPolicyRef: `cluster-policy` (Layer 2, same namespace) +- Spec.rbacPolicyRef: `cluster-policy` - Spec.permissionDeclarations: `[{permissionSetRef: cluster-maximum, scope: cluster}]` -The `seam-operator` policy-type label distinguishes this profile from component -profiles. The component backfill runnable (GUARDIAN-BL-RBACPROFILE-SWEEP) does -not process seam-operator profiles. Deletion is handled explicitly by -reconcileDelete when the TalosCluster is removed, not by the component sweep. +**Step 3 - Platform creates remote infrastructure (Platform)** +Platform reads the import-path kubeconfig and connects to the tenant cluster to create: +1. The `ont-system` namespace if absent. +2. The `conductor` ServiceAccount in `ont-system` if absent. +3. The Conductor Deployment (role=tenant) in `ont-system` if absent. -**Step 3 -- Platform creates remote infrastructure (Platform)** -Platform's `EnsureConductorDeploymentOnTargetCluster` reads the import-path -kubeconfig (`target-cluster-kubeconfig` in `seam-tenant-{clusterName}`) and -connects to the tenant cluster. Using this remote connection it: -1. Creates the `ont-system` namespace if absent. -2. Creates the `conductor` ServiceAccount in `ont-system` if absent. -3. Creates the Conductor Deployment (role=tenant) in `ont-system` if absent. - -The Deployment is built by `BuildConductorAgentDeployment` with `CONDUCTOR_ROLE=tenant`. -Platform does not write the `conductor-tenant` RBACProfile to the tenant cluster. -That step is performed by Conductor itself (step 4). - -**Step 4 -- Tenant conductor pulls and writes RBACProfile (Conductor)** +**Step 4 - Tenant conductor pulls and writes RBACProfile (Conductor)** Conductor role=tenant, once running, pulls the `conductor-tenant` RBACProfile from `seam-tenant-{clusterName}` on the management cluster and writes it into `ont-system` -on the tenant cluster. This is the CONDUCTOR-BL-TENANT-ROLE-RBACPROFILE-DISTRIBUTION -implementation path. Management Conductor retains signing authority. - -**Step 5 -- Platform observes Deployment availability and advances (Platform)** -Platform polls the Conductor Deployment for `Available=True` via the remote kubernetes -client. When Available=True, Platform sets `ConductorReady=True` and `Ready=True` on -the TalosCluster. The cluster is now fully operational under Seam governance. - ---- - -### Readiness gate +on the tenant cluster. -Platform uses `ConductorReady=True` as the sole gate before setting `Ready=True` on -an import-mode tenant TalosCluster. The phase progression is: +**Step 5 - Platform observes Deployment availability and advances (Platform)** +Platform polls the Conductor Deployment for `Available=True`. When Available=True, Platform +sets `ConductorReady=True` and `Ready=True` on the TalosCluster. -``` -Bootstrapped=False --> Bootstrapped=True (after management-side steps complete) -ConductorReady=False --> ConductorReady=True (after Conductor Deployment Available) -Ready=True (set together with ConductorReady=True) -``` - -Platform does NOT use a separate `phase` field. The Conditions slice is the authoritative -state carrier. `RequeueAfter` is set to the capiPollInterval (20s) during the conductor -availability wait. - ---- - -### Invariants +**Readiness gate:** +Platform uses `ConductorReady=True` as the sole gate before setting `Ready=True` on an +import-mode tenant TalosCluster. +**Invariants:** - Guardian creates exactly one `conductor-tenant` RBACProfile per role=tenant TalosCluster. - Platform creates exactly one Conductor Deployment per role=tenant import cluster. -- The Deployment is stamped CONDUCTOR_ROLE=tenant. Any other value is a programming error. +- The Deployment is stamped `CONDUCTOR_ROLE=tenant`. Any other value is a programming error. - When the TalosCluster is deleted, Guardian deletes the `conductor-tenant` RBACProfile - in reconcileDelete. Platform deletes the remote infrastructure through the normal - controller-runtime GC path (ownerReference on seam-tenant-* resources) or teardown - sequence (Decision H). -- The full PermissionSnapshotReceipt gRPC ceremony is future work - (CONDUCTOR-BL-TENANT-ROLE-RBACPROFILE-DISTRIBUTION). The current readiness gate is - Deployment Available=True, not gRPC handshake completion. + in reconcileDelete. --- ## 21. APIGroupSweepController -### Purpose - -When CRDs for a previously unseen API group are installed on the management cluster, -the seam operators (guardian, platform, wrapper, conductor, seam-core) must explicitly -hold permission declarations for that group so the governance record remains complete -and accurate. Although `management-maximum` already carries a wildcard rule -`{apiGroups: ["*"], resources: ["*"]}` that grants effective access, governance -explicitness and future least-privilege migration require an explicit PermissionRule -entry per discovered group. - -### Scope - -Management role only. The controller runs on the management cluster. Propagation to -tenant clusters is automatic: ClusterRBACPolicyReconciler (§18, §19) auto-syncs -`cluster-maximum` from `management-maximum` on every reconcile cycle. No additional -tenant-side controller is needed. +**Role gate:** role=management only. Never registered for role=tenant. -### Discovery logic +**Purpose:** When CRDs for a previously unseen API group are installed on the management +cluster, the seam operators must explicitly hold permission declarations for that group so +the governance record remains complete and accurate. -The controller watches `CustomResourceDefinition` objects -(`apiextensions.k8s.io/v1`). On any CREATE or DELETE event it reconciles a synthetic -singleton key `sweep/apigroups`. During reconcile it: +**Discovery logic:** +The controller watches `CustomResourceDefinition` objects (`apiextensions.k8s.io/v1`). +On any CREATE or DELETE event it reconciles a synthetic singleton key `sweep/apigroups`. +During reconcile it: 1. Lists all CRDs on the management cluster. 2. Extracts the unique set of `.spec.group` values. -3. Filters out system-owned and seam-owned groups (see Exclusion list below). +3. Filters out system-owned and seam-owned groups (see exclusion list below). 4. Reads `management-maximum` PermissionSet in `seam-system`. 5. For each group not yet represented by an explicit rule in `management-maximum.Spec.Permissions`: - - Appends `PermissionRule{APIGroups: [group], Resources: ["*"], Verbs: all-standard}`. + appends `PermissionRule{APIGroups: [group], Resources: ["*"], Verbs: all-standard}`. 6. If any rules were added, patches `management-maximum`. -7. Updates `Guardian.Status.DiscoveredAPIGroups` with the current full list of - discovered groups (alphabetically sorted, deduplicated). +7. Updates `Guardian.Status.DiscoveredAPIGroups` with the current full list of discovered + groups (alphabetically sorted, deduplicated). The controller never removes rules. Rule removal requires a governance-reviewed -PermissionSet update (human-at-boundary). Additions are monotonic. +PermissionSet update. `all-standard` verbs: `get`, `list`, `watch`, `create`, `update`, `patch`, `delete`. -### Exclusion list - -The following groups are never added as explicit rules because they are either -built-in k8s groups or owned by seam/CAPI: - +**Exclusion list:** - Empty string (k8s core group) -- Any group ending in `.k8s.io` (all k8s extension groups) -- Any group ending in `.x-k8s.io` (CAPI and k8s SIG ecosystem groups) -- Any group ending in `.ontai.dev` (seam-owned groups) +- Any group ending in `.k8s.io` +- Any group ending in `.x-k8s.io` +- Any group ending in `.ontai.dev` - Bare names without a dot: `apps`, `batch`, `autoscaling`, `policy`, `core` -Groups matching these patterns are silently skipped. They are not recorded in -`DiscoveredAPIGroups`. - -### Guardian CR status field - -`Guardian.Status.DiscoveredAPIGroups []string` -- sorted, deduplicated list of -third-party API groups that the controller has added explicit rules for in -`management-maximum`. Informational. Written by APIGroupSweepController on the -management cluster only. - -### Cascade to tenant clusters - -No tenant-side action is needed. The existing auto-sync path in -ClusterRBACPolicyReconciler handles this automatically: +**Cascade to tenant clusters:** +No tenant-side action is needed. The existing auto-sync path in ClusterRBACPolicyReconciler +handles this automatically: ``` APIGroupSweepController patches management-maximum @@ -1036,116 +1024,38 @@ APIGroupSweepController patches management-maximum --> cluster-maximum.Spec.Permissions = management-maximum.Spec.Permissions (auto-sync) ``` -The auto-sync path (§18 lines "If cluster-maximum.Spec.Permissions diverged from -management-maximum, ClusterRBACPolicyReconciler patches cluster-maximum") covers all -tenant clusters. - -### Invariants - -- APIGroupSweepController is management-only. It is never registered for role=tenant. +**Invariants:** - Additions to management-maximum are monotonic. No removal path exists in this controller. - The controller never creates new PermissionSet objects. It only patches management-maximum. -- management-maximum must exist before any CRD sweep can proceed. If absent, the - controller requeues with a 30-second delay and logs a warning. -- DiscoveredAPIGroups in Guardian status is informational only. It records what the - controller added. It is not the authoritative list of groups in management-maximum - (which may contain additional rules added by other authorized paths). +- management-maximum must exist before any CRD sweep can proceed. If absent, the controller + requeues with a 30-second delay. +- `DiscoveredAPIGroups` in Guardian status is informational only. + +--- + +## 22. Cross-Domain Rules + +Reads: `seam.ontai.dev/TalosCluster` to detect new cluster registrations and create initial + cluster-level RBACPolicy and PermissionSet. +Reads: `seam.ontai.dev/RunnerConfig` status (capability confirmation). +Intercepts: `seam.ontai.dev/PackExecution` at admission (execution gatekeeper, §13). +Writes: `guardian.ontai.dev` resources on management cluster. +Writes: PermissionSnapshotReceipt on target clusters via conductor. +Never writes to `seam.ontai.dev` or `platform.ontai.dev` CRDs. + +The signing annotation (`ontai.dev/snapshot-signature`) on PermissionSnapshot is written +by the management cluster conductor, not by the guardian controller. The controller +generates the snapshot. The agent signs it. These are sequential, not concurrent writes. --- -*security.ontai.dev schema - guardian* +*guardian.ontai.dev schema - guardian* *Amendments appended below with date and rationale.* -2026-03-30 - Target cluster security plane responsibilities transferred to conductor. - No separate guardian Deployment on target clusters. conductor hosts admission - webhook, PermissionSnapshotReceipt management, local PermissionService, and drift - detection on target clusters. Cryptographic signing model added: management cluster - conductor signs PermissionSnapshot; target cluster conductor verifies before - acknowledgement. Section 1 domain boundary clarified. Section 5 admission webhook - updated for two-context model. Section 8 receipt management updated to name conductor - explicitly. Section 10 PermissionService split into management and target context. - INV-026 referenced. - -2026-04-03 - IdentityProvider CRD added to Section 7. Relationship to IdentityBinding - formally specified. IdentityProvider is the upstream trust anchor. IdentityBinding is - the principal assignment. An IdentityBinding without a matching IdentityProvider for - its identityType is rejected at admission. IdentityProvider is a prerequisite before - any Controller Engineer session implementing identity trust methods in IdentityBinding. -2026-04-05 - Section 6 "Third-Party RBAC Ownership" amended with RBACProfile authorship - invariant. compiler component (conductor-schema.md §16) is the exclusive authorship - path for third-party RBACProfiles. Guardian enforces declarations; it never generates - them. Seam operator RBACProfiles produced by compiler enable as part of bootstrap - bundle. Third-party components without a Guardian-provisioned RBACProfile may not - operate in a Guardian-governed cluster. - -2026-04-05 - Guardian dual-role model locked. §1 Deployment boundary updated: Guardian - is a single binary with two declared roles (management/tenant); role=tenant is optional - per tenant via ClusterPack through Wrapper; Platform never deploys Guardian. §3 Two-Phase - Boot superseded by §3 Management Cluster Boot Sequence: CNPG is pre-provisioned by - compiler enable phase 0; Guardian startup migration runner connects before registering - any controller; CNPGUnreachable condition on failure, degraded hold, no crash; three-step - startup sequence (migration runner → bootstrap RBAC → controller registration). §15 - Guardian Role Model added (locked invariant): GUARDIAN_ROLE env var (management/tenant); - absent/invalid causes structured exit; tenant role=management = sovereign mode (independent - CNPG, no audit forwarding, no management Guardian relationship unless explicit - federated-downstream IdentityProvider); management Guardian never assumes tenant topology; - controller sets role-gated (management adds AuditSinkReconciler, tenant adds - AuditForwarderController); PermissionService gRPC runs in both roles. §16 CNPG Deployment - Contract added (locked invariant): management CNPG owned by compiler enable phase 0; tenant - CNPG owned by ClusterPack; no other operator has CNPG dependency (INV-016); F-P8 recorded. - -2026-04-21 - lineageIndexRef added to audit record specification (§17 Audit Record - Schema added). Guardian reconcilers populate this field when emitting audit events - for governed objects. Platform-wide events leave lineageIndexRef absent. Closes the - correlation loop between governance events and the structural lineage index. - LineageIndexRef carries name and namespace of the InfrastructureLineageIndex CR - governing the root declaration associated with the event. Session/12. - -2026-04-09 - G-BL-11: Tenant Guardian CNPG and audit forwarding model amended. §15 - Role=tenant updated: tenant Guardian always connects to tenant-local CNPG (no CRD-only - mode; full persistence parity with role=management). Audit forwarding changed from - default-on to opt-in: GUARDIAN_AUDIT_FORWARD env var (default: false) is the sole - control. GUARDIAN_AUDIT_FORWARD=false = sovereign mode (independent CNPG, independent - identity plane, no forwarding; the default). GUARDIAN_AUDIT_FORWARD=true = federated - mode (tenant Guardian forwards audit events to management Guardian via Conductor - federation channel; Conductor is the transport, tenant is the producer, management is - the consumer). Sovereign mode is no longer tied to role=management on a tenant cluster - - it is the default state of every role=tent Guardian. Controller set table expanded - to three columns reflecting the GUARDIAN_AUDIT_FORWARD axis: AuditForwarderController - activates only when GUARDIAN_AUDIT_FORWARD=true. PermissionService paragraph updated - to reference federated-mode tenants rather than "non-sovereign" tenants. - -2026-04-25 - Three-Layer RBAC Hierarchy. Governor-approved architectural change. - §2 namespace placement fully rewritten: two canonical policies (management-policy/ - management-maximum in seam-system; cluster-policy/cluster-maximum per seam-tenant-*); - seam operator profiles in seam-system/ont-system referencing Layer 1; all other - component profiles in seam-tenant-{clusterName} referencing Layer 2; no per-component - RBACPolicy or per-component PermissionSet objects. §6 pack intake updated: one - RBACProfile per component (inline permissionDeclarations, rbacPolicyRef=cluster-policy), - no separate PermissionSet per component. §18 ClusterRBACPolicyReconciler updated: - creation-time validation of cluster-maximum against management-maximum (no deadlock), - deletion cascade covers only component-labeled RBACProfiles. §19 Three-Layer RBAC - Hierarchy added as authoritative structural specification: Layer 1 (compiler-created - fleet ceiling), Layer 2 (guardian-created per-cluster policy, functionally bound to - Layer 1 at creation time), Layer 3 (component profiles, no per-component governance - objects). No-deadlock guarantee documented. IdentityProvider/IdentityBinding chain - specified. Management cluster dual-layer pattern documented. RBACPolicy authorship - invariant: compiler for Layer 1, guardian reconciler for Layer 2, never human-authored. - security-system namespace removed throughout (does not exist). - -2026-05-02 - §21 APIGroupSweepController added. Management-only controller that watches - CRDs and extends management-maximum with explicit PermissionRules per third-party API - group. Groups ending in .k8s.io, .x-k8s.io, .ontai.dev, and bare k8s names are excluded. - Additions are monotonic; removal requires human governance review. cascade to tenant - cluster-maximum is automatic via ClusterRBACPolicyReconciler auto-sync (§18). Guardian - CR status gains DiscoveredAPIGroups field (informational, sorted, deduplicated). - -2026-04-26 - §20 Tenant Cluster Conductor Onboarding Protocol added. T-19 and T-19a - implementation contract. Guardian (ClusterRBACPolicyReconciler) creates conductor-tenant - RBACProfile in seam-tenant-{cluster} for every role=tenant TalosCluster -- this is the - management-side authoritative profile. Platform creates remote infrastructure (ont-system, - conductor SA, Conductor Deployment) on the tenant cluster using the import-path kubeconfig. - Conductor role=tenant pulls the profile and writes it to ont-system. Platform gates - Ready=True on ConductorReady=True (Deployment Available), not on gRPC handshake - (CONDUCTOR-BL-TENANT-ROLE-RBACPROFILE-DISTRIBUTION is future). LabelValuePolicyTypeSeamOperator - added as the policy-type discriminator for seam-operator profiles in seam-tenant-* namespaces. +2026-05-13 - Full rewrite from security.ontai.dev to guardian.ontai.dev. Guardian singleton + CR removed -- health signal is now the guardian Deployment readiness probe. All references + to security-system namespace removed (namespace does not exist; all guardian-owned objects + live in seam-system or seam-tenant-*). seam-core-schema.md references updated to + seam-schema.md. wrapper renamed to dispatcher throughout. Master GVK reference table + added (§7). Cross-domain rules updated to reflect correct API groups (§22). Finalizer + updated from security.ontai.dev/cluster-rbac to guardian.ontai.dev/cluster-rbac (§18). diff --git a/go.mod b/go.mod index 93a085f..020a435 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,20 @@ go 1.25.3 replace github.com/ontai-dev/conductor => ../conductor -replace github.com/ontai-dev/seam-core => ../seam-core +replace github.com/ontai-dev/platform => ../platform + +replace github.com/ontai-dev/seam => ../seam + +replace github.com/ontai-dev/seam-sdk => ../seam-sdk require ( github.com/lib/pq v1.12.3 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 - github.com/ontai-dev/seam-core v0.0.0-00010101000000-000000000000 - google.golang.org/grpc v1.72.2 + github.com/ontai-dev/platform v0.0.0-00010101000000-000000000000 + github.com/ontai-dev/seam v0.0.0-00010101000000-000000000000 + github.com/ontai-dev/seam-sdk v0.0.0-00010101000000-000000000000 + google.golang.org/grpc v1.79.3 k8s.io/api v0.35.3 k8s.io/apiextensions-apiserver v0.35.0 k8s.io/apimachinery v0.35.3 @@ -24,7 +30,7 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -57,18 +63,18 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.41.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index cafb066..814aa32 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,9 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= @@ -131,18 +132,18 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -153,32 +154,34 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/controller/apigroup_sweep_controller.go b/internal/controller/apigroup_sweep_controller.go index c08f2f7..7404c89 100644 --- a/internal/controller/apigroup_sweep_controller.go +++ b/internal/controller/apigroup_sweep_controller.go @@ -8,7 +8,6 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -25,9 +24,6 @@ const ( managementMaximumName = "management-maximum" managementMaximumNamespace = "seam-system" - // guardianSingletonName is the canonical Guardian CR name. - guardianSingletonName = "guardian" - // sweepSingletonKey is the synthetic reconcile key used by the sweep. sweepSingletonKey = "sweep/apigroups" ) @@ -50,9 +46,7 @@ var allStandardVerbs = []securityv1alpha1.Verb{ // Runs on role=management only. // // +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=list;watch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsets,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=guardians,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=guardians/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsets,verbs=get;update;patch type APIGroupSweepController struct { // Client is the controller-runtime client. Client client.Client @@ -122,16 +116,11 @@ func (r *APIGroupSweepController) Reconcile(ctx context.Context, req ctrl.Reques "added", newGroups, "total", len(ps.Spec.Permissions)) } - // Build sorted deduplicated discovered set (union of existing + new). + // Log the full discovered set for observability. for g := range thirdParty { existingExplicit[g] = true } - discovered := sortedKeys(existingExplicit) - - // Update Guardian singleton status with the discovered groups. - if err := r.updateDiscoveredGroups(ctx, discovered); err != nil { - return ctrl.Result{}, err - } + logger.Info("API group sweep complete", "discovered", sortedKeys(existingExplicit)) return ctrl.Result{}, nil } @@ -154,30 +143,6 @@ func (r *APIGroupSweepController) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -// updateDiscoveredGroups writes the sorted discovered group list into Guardian status. -func (r *APIGroupSweepController) updateDiscoveredGroups(ctx context.Context, groups []string) error { - gdn := &securityv1alpha1.Guardian{} - gdnKey := types.NamespacedName{Name: guardianSingletonName, Namespace: r.OperatorNamespace} - if err := r.Client.Get(ctx, gdnKey, gdn); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return fmt.Errorf("get Guardian singleton: %w", err) - } - if stringSliceEqual(gdn.Status.DiscoveredAPIGroups, groups) { - return nil - } - patch := client.MergeFrom(gdn.DeepCopy()) - gdn.Status.DiscoveredAPIGroups = groups - if err := r.Client.Status().Patch(ctx, gdn, patch); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return fmt.Errorf("patch Guardian status: %w", err) - } - return nil -} - // CollectThirdPartyGroups returns a set of API groups extracted from the CRD list // after filtering out system-owned and seam-owned groups. guardian-schema.md §21. // Exported for unit testing. @@ -243,50 +208,3 @@ func sortedKeys(m map[string]bool) []string { return keys } -// stringSliceEqual returns true if a and b have equal length and identical elements. -func stringSliceEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -// ensureManagementMaximum creates management-maximum if it does not exist. -// Called during startup reconcile to ensure the PermissionSet exists before CRD -// sweep runs. No-op if already present. -func ensureManagementMaximum(ctx context.Context, c client.Client, namespace string) error { - ps := &securityv1alpha1.PermissionSet{} - key := types.NamespacedName{Name: managementMaximumName, Namespace: namespace} - err := c.Get(ctx, key, ps) - if err == nil { - return nil - } - if !apierrors.IsNotFound(err) { - return fmt.Errorf("check management-maximum: %w", err) - } - newPS := &securityv1alpha1.PermissionSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: managementMaximumName, - Namespace: namespace, - }, - Spec: securityv1alpha1.PermissionSetSpec{ - Description: "Layer 1 fleet ceiling. guardian-schema.md §19.", - Permissions: []securityv1alpha1.PermissionRule{ - { - APIGroups: []string{"*"}, - Resources: []string{"*"}, - Verbs: allStandardVerbs, - }, - }, - }, - } - if err := c.Create(ctx, newPS); err != nil && !apierrors.IsAlreadyExists(err) { - return fmt.Errorf("create management-maximum: %w", err) - } - return nil -} diff --git a/internal/controller/audit_helpers.go b/internal/controller/audit_helpers.go index d5bee0d..d50c051 100644 --- a/internal/controller/audit_helpers.go +++ b/internal/controller/audit_helpers.go @@ -5,7 +5,7 @@ import ( "time" "github.com/ontai-dev/guardian/internal/database" - "github.com/ontai-dev/seam-core/pkg/lineage" + "github.com/ontai-dev/seam/pkg/lineage" ) // writeAudit writes event to aw if aw is non-nil. Failures are discarded -- diff --git a/internal/controller/bootstrap_annotation_sweep.go b/internal/controller/bootstrap_annotation_sweep.go index 910d516..41916f7 100644 --- a/internal/controller/bootstrap_annotation_sweep.go +++ b/internal/controller/bootstrap_annotation_sweep.go @@ -54,7 +54,7 @@ func mustBuildSweepAnnotationPatch() []byte { } // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsets;rbacpolicies;rbacprofiles,verbs=get;create +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsets;rbacpolicies;rbacprofiles,verbs=get;create // BootstrapAnnotationRunnable scans all pre-existing RBAC resources on the cluster // and stamps ownership annotations on any resource missing ontai.dev/rbac-owner=guardian. diff --git a/internal/controller/bootstrap_controller.go b/internal/controller/bootstrap_controller.go index 0bff8db..746ac02 100644 --- a/internal/controller/bootstrap_controller.go +++ b/internal/controller/bootstrap_controller.go @@ -7,12 +7,6 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - clientevents "k8s.io/client-go/tools/events" - "k8s.io/client-go/util/workqueue" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -22,36 +16,22 @@ import ( securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/webhook" + "k8s.io/client-go/util/workqueue" ) -// GuardianSingletonName is the name of the singleton Guardian CR that -// BootstrapController creates and manages. There is exactly one Guardian CR -// per cluster. INV-020. -const GuardianSingletonName = "guardian" - -// BootstrapController manages the lifecycle of the singleton Guardian CR and -// drives the admission enforcement mode transitions. +// BootstrapController drives the admission enforcement mode transitions. // // Responsibilities: -// 1. Create the Guardian CR singleton on startup if absent. -// 2. Set WebhookMode=Initialising on the Guardian CR when first created. -// 3. When all RBACProfiles in all namespaces reach Provisioned=True: -// advance WebhookMode from Initialising to ObserveOnly, and update -// the in-memory WebhookModeGate. -// 4. For each namespace where all RBACProfiles are Provisioned=True: -// record the namespace in Guardian.Status.NamespaceEnforcements, and -// mark it active in the in-memory NamespaceEnforcementRegistry. +// 1. List all RBACProfiles and evaluate global readiness. +// 2. When all RBACProfiles in all namespaces reach Provisioned=True: +// advance the in-memory WebhookModeGate from Initialising to ObserveOnly. +// 3. For each namespace where all RBACProfiles are Provisioned=True: +// activate the namespace in the in-memory NamespaceEnforcementRegistry. // -// The per-namespace enforcement transition is one-way and irreversible. -// The global ObserveOnly transition is one-way: once set, BootstrapController -// never reverts it. INV-020, CS-INV-004. -// -// BootstrapController reconciles on RBACProfile changes (any namespace). -// It reads all RBACProfiles to evaluate global and per-namespace readiness. +// State is held entirely in-memory in the Gate and Registry fields. +// There is no persistent CR. INV-020, CS-INV-004. type BootstrapController struct { - Client client.Client - Scheme *runtime.Scheme - Recorder clientevents.EventRecorder + Client client.Client // Gate is the in-memory global webhook mode gate shared with the webhook handler. Gate *webhook.WebhookModeGate @@ -60,10 +40,6 @@ type BootstrapController struct { // webhook handler via GuardedNamespaceModeResolver. Registry *webhook.NamespaceEnforcementRegistry - // OperatorNamespace is the namespace where the Guardian singleton CR lives and - // where the operator itself runs. Populated from OPERATOR_NAMESPACE env var. - OperatorNamespace string - // SweepDone is set to true by BootstrapAnnotationRunnable when the pre-existing // RBAC annotation sweep has completed. BootstrapController blocks the // Initialising → ObserveOnly transition until this flag is true. @@ -77,124 +53,47 @@ type BootstrapController struct { // SetupWithManager registers BootstrapController with the manager. // It watches RBACProfile CRs across all namespaces so that any provisioning // transition triggers re-evaluation of the global and per-namespace gates. -// -// Watches() is used instead of For() to decouple this controller's informer -// registration from RBACProfileReconciler's For(RBACProfile) registration. -// In controller-runtime v0.23.3, two controllers sharing the same GVK informer -// via For() may not both receive events reliably after cache sync. -// -// All RBACProfile events are mapped to a fixed reconcile request for the -// Guardian singleton — BootstrapController reconciles global state, not -// individual RBACProfile objects. func (r *BootstrapController) SetupWithManager(mgr ctrl.Manager) error { - singletonKey := handler.EnqueueRequestsFromMapFunc( + bootstrapRequest := []reconcile.Request{{}} + triggerBootstrap := handler.EnqueueRequestsFromMapFunc( func(_ context.Context, _ client.Object) []reconcile.Request { - return []reconcile.Request{ - {NamespacedName: types.NamespacedName{ - Namespace: r.OperatorNamespace, - Name: GuardianSingletonName, - }}, - } + return bootstrapRequest }, ) - // startupSource enqueues one reconcile request for the Guardian singleton - // when the controller starts (after the cache is synced). This guarantees - // the Guardian CR is created and enforcement state is evaluated on startup, - // independent of whether any RBACProfile events arrive. - // - // source.Func.Start is called by controller-runtime exactly once, after the - // informer cache is ready and the controller goroutine begins. startupSource := source.Func(func(_ context.Context, q workqueue.TypedRateLimitingInterface[reconcile.Request]) error { - q.Add(reconcile.Request{NamespacedName: types.NamespacedName{ - Namespace: r.OperatorNamespace, - Name: GuardianSingletonName, - }}) + q.Add(reconcile.Request{}) return nil }) return ctrl.NewControllerManagedBy(mgr). - Watches(&securityv1alpha1.RBACProfile{}, singletonKey). + Watches(&securityv1alpha1.RBACProfile{}, triggerBootstrap). WatchesRawSource(startupSource). Named("bootstrap"). Complete(r) } -// Reconcile processes an RBACProfile event and re-evaluates the Guardian CR -// enforcement state. It is safe to call concurrently — state transitions are -// idempotent and guarded by status patch conflicts. -func (r *BootstrapController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +// Reconcile evaluates the current RBACProfile readiness state and advances +// the in-memory WebhookModeGate and NamespaceEnforcementRegistry accordingly. +func (r *BootstrapController) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) - // Ensure the singleton Guardian CR exists. - gdn := &securityv1alpha1.Guardian{} - err := r.Client.Get(ctx, types.NamespacedName{ - Name: GuardianSingletonName, - Namespace: r.OperatorNamespace, - }, gdn) - if apierrors.IsNotFound(err) { - gdn = r.newGuardianSingleton() - if createErr := r.Client.Create(ctx, gdn); createErr != nil && !apierrors.IsAlreadyExists(createErr) { - logger.Error(createErr, "failed to create Guardian singleton") - return ctrl.Result{}, createErr - } - // Refetch after create. - if getErr := r.Client.Get(ctx, types.NamespacedName{ - Name: GuardianSingletonName, - Namespace: r.OperatorNamespace, - }, gdn); getErr != nil { - return ctrl.Result{}, getErr - } - } else if err != nil { - return ctrl.Result{}, err - } - - // List all RBACProfiles across all namespaces. profiles := &securityv1alpha1.RBACProfileList{} if err := r.Client.List(ctx, profiles); err != nil { return ctrl.Result{}, err } - // Evaluate global and per-namespace readiness. globalReady, nsReadiness := evaluateReadiness(profiles.Items) - // Update in-memory gate for per-namespace enforcements. - // Always update registry for all ready namespaces — idempotent. for ns, ready := range nsReadiness { if ready { r.Registry.SetActive(ns) } } - // Capture the mode as it stands at reconcile entry, before any transitions. - // The Enforcing evaluation block must only run when the mode was ALREADY - // ObserveOnly at the start of this reconcile — not in the same pass that - // performs the Initialising → ObserveOnly transition. - modeAtEntry := gdn.Status.WebhookMode - - // Prepare the updated status patch. - patch := client.MergeFrom(gdn.DeepCopy()) - - // Update NamespaceEnforcements on the Guardian CR. - if gdn.Status.NamespaceEnforcements == nil { - gdn.Status.NamespaceEnforcements = make(map[string]bool) - } - changed := false - for ns, ready := range nsReadiness { - if ready && !gdn.Status.NamespaceEnforcements[ns] { - gdn.Status.NamespaceEnforcements[ns] = true - changed = true - } - } + currentMode := r.Gate.Mode() - // Advance global WebhookMode from Initialising to ObserveOnly when ready. - // This transition is one-way: do not revert ObserveOnly or Enforcing. - // - // Gate: the annotation sweep (BootstrapAnnotationRunnable) must complete before - // the mode is advanced. Without a clean annotation baseline, advancing to - // ObserveOnly may begin per-namespace enforce transitions against unannotated - // pre-existing resources. guardian-schema.md §4. INV-020. - if globalReady && gdn.Status.WebhookMode == securityv1alpha1.WebhookModeInitialising && + if globalReady && currentMode == securityv1alpha1.WebhookModeInitialising && r.SweepDone != nil && !r.SweepDone.Load() { logger.Info("annotation sweep not yet complete; requeuing before ObserveOnly advance", "requeueAfter", "5s", @@ -202,58 +101,27 @@ func (r *BootstrapController) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{RequeueAfter: 5 * time.Second}, nil } - if globalReady && gdn.Status.WebhookMode == securityv1alpha1.WebhookModeInitialising { - gdn.Status.WebhookMode = securityv1alpha1.WebhookModeObserveOnly - securityv1alpha1.SetCondition( - &gdn.Status.Conditions, - "BootstrapComplete", - metav1.ConditionTrue, - securityv1alpha1.ReasonBootstrapProfilesReady, - "all RBACProfiles provisioned; webhook advancing to ObserveOnly", - gdn.Generation, - ) - changed = true + if globalReady && currentMode == securityv1alpha1.WebhookModeInitialising { + r.Gate.SetMode(securityv1alpha1.WebhookModeObserveOnly) logger.Info("bootstrap profiles all provisioned; advancing to ObserveOnly") - } else if !globalReady && gdn.Status.WebhookMode == securityv1alpha1.WebhookModeInitialising { - securityv1alpha1.SetCondition( - &gdn.Status.Conditions, - "BootstrapComplete", - metav1.ConditionFalse, - securityv1alpha1.ReasonBootstrapProfilesPending, - "waiting for all RBACProfiles to reach Provisioned=True", - gdn.Generation, - ) - changed = true + return ctrl.Result{}, nil } - // Evaluate per-namespace Enforcing readiness once ObserveOnly is reached. - // A namespace is Enforcing-ready when all profiles are provisioned AND all - // RBAC resources carry ontai.dev/rbac-owner=guardian. - // - // This block gates on modeAtEntry — the mode at the START of this reconcile — - // not the potentially-modified gdn.Status.WebhookMode. This ensures the - // Initialising → ObserveOnly and ObserveOnly → Enforcing transitions never - // collapse into a single reconcile. Each transition is patched independently, - // keeping status history legible and test assertions tractable. - if modeAtEntry == securityv1alpha1.WebhookModeObserveOnly || - modeAtEntry == securityv1alpha1.WebhookModeEnforcing { + if currentMode == securityv1alpha1.WebhookModeObserveOnly || + currentMode == securityv1alpha1.WebhookModeEnforcing { nsEnforcing, clusterReady, enfErr := r.evaluateEnforcingReadiness(ctx, nsReadiness) if enfErr != nil { return ctrl.Result{}, enfErr } - // Promote namespaces that are Enforcing-ready. for ns, ready := range nsEnforcing { if ready { r.Registry.SetEnforcing(ns) } } - // Advance global mode from ObserveOnly to Enforcing when all namespaces - // with profiles are Enforcing-ready AND cluster-scoped resources are clean. - // This transition is one-way: once Enforcing, do not revert. INV-020. - if modeAtEntry == securityv1alpha1.WebhookModeObserveOnly && clusterReady { + if currentMode == securityv1alpha1.WebhookModeObserveOnly && clusterReady { allEnforcing := len(nsEnforcing) > 0 for _, ready := range nsEnforcing { if !ready { @@ -262,65 +130,18 @@ func (r *BootstrapController) Reconcile(ctx context.Context, req ctrl.Request) ( } } if allEnforcing { - gdn.Status.WebhookMode = securityv1alpha1.WebhookModeEnforcing - securityv1alpha1.SetCondition( - &gdn.Status.Conditions, - "EnforcingComplete", - metav1.ConditionTrue, - "AllNamespacesEnforcing", - "all namespaces RBAC-annotated and profiles provisioned; advancing to Enforcing", - gdn.Generation, - ) - changed = true + r.Gate.SetMode(securityv1alpha1.WebhookModeEnforcing) logger.Info("all namespaces enforcing-ready; advancing to Enforcing") } } } - if !changed { - return ctrl.Result{}, nil - } - - if err := r.Client.Status().Patch(ctx, gdn, patch); err != nil { - if apierrors.IsConflict(err) { - // Conflict on status patch — requeue for re-evaluation. - return ctrl.Result{Requeue: true}, nil - } - return ctrl.Result{}, err - } - - // Advance in-memory gate after successful status patch. - switch gdn.Status.WebhookMode { - case securityv1alpha1.WebhookModeObserveOnly: - r.Gate.SetMode(securityv1alpha1.WebhookModeObserveOnly) - case securityv1alpha1.WebhookModeEnforcing: - r.Gate.SetMode(securityv1alpha1.WebhookModeEnforcing) - } - return ctrl.Result{}, nil } -// newGuardianSingleton constructs the initial Guardian singleton CR with -// WebhookMode=Initialising. Created on first reconcile if absent. -func (r *BootstrapController) newGuardianSingleton() *securityv1alpha1.Guardian { - gdn := &securityv1alpha1.Guardian{ - ObjectMeta: metav1.ObjectMeta{ - Name: GuardianSingletonName, - Namespace: r.OperatorNamespace, - }, - } - gdn.Status.WebhookMode = securityv1alpha1.WebhookModeInitialising - return gdn -} - // evaluateEnforcingReadiness checks per-namespace and cluster-scoped enforcing // readiness. A namespace is Enforcing-ready when it is profile-ready (nsReadiness=true) -// AND all its namespaced RBAC resources (Roles, RoleBindings, ServiceAccounts) carry -// the ontai.dev/rbac-owner=guardian annotation. clusterReady is true when all -// ClusterRoles and ClusterRoleBindings carry the annotation. -// -// Only namespaces that are already profile-ready are checked — namespaces not yet -// profile-ready cannot be Enforcing-ready. +// AND all its namespaced RBAC resources carry the ontai.dev/rbac-owner=guardian annotation. func (r *BootstrapController) evaluateEnforcingReadiness( ctx context.Context, nsReadiness map[string]bool, @@ -380,7 +201,7 @@ func (r *BootstrapController) isNamespaceAnnotationComplete(ctx context.Context, } // isClusterScopedAnnotationComplete returns true when all ClusterRoles and -// ClusterRoleBindings on the cluster carry ontai.dev/rbac-owner=guardian. +// ClusterRoleBindings carry ontai.dev/rbac-owner=guardian. func (r *BootstrapController) isClusterScopedAnnotationComplete(ctx context.Context) (bool, error) { crList := &rbacv1.ClusterRoleList{} if err := r.Client.List(ctx, crList); err != nil { @@ -407,20 +228,13 @@ func (r *BootstrapController) isClusterScopedAnnotationComplete(ctx context.Cont // evaluateReadiness computes global readiness (all profiles provisioned) and // per-namespace readiness (all profiles in that namespace provisioned). -// A namespace with zero profiles is NOT considered ready — there must be at -// least one provisioned profile for enforcement to be meaningful. -// -// Returns: -// -// globalReady: true if there is at least one profile and all are provisioned. -// nsReadiness: map from namespace to true when all profiles in it are provisioned. +// A namespace with zero profiles is NOT considered ready. func evaluateReadiness(profiles []securityv1alpha1.RBACProfile) (globalReady bool, nsReadiness map[string]bool) { nsReadiness = make(map[string]bool) if len(profiles) == 0 { return false, nsReadiness } - // Per-namespace tracking: total count and provisioned count. type nsState struct { total int provisioned int diff --git a/internal/controller/cluster_rbacpolicy_controller.go b/internal/controller/cluster_rbacpolicy_controller.go index 47af99b..e740988 100644 --- a/internal/controller/cluster_rbacpolicy_controller.go +++ b/internal/controller/cluster_rbacpolicy_controller.go @@ -23,7 +23,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" - seamv1alpha1 "github.com/ontai-dev/seam-core/api/v1alpha1" + platformseamv1alpha1 "github.com/ontai-dev/platform/api/seam/v1alpha1" ) const ( @@ -32,7 +32,7 @@ const ( // Cross-namespace ownerReferences are prohibited; the finalizer is the authoritative // lifecycle coupling between TalosCluster (seam-system) and seam-tenant-* objects. // guardian-schema.md §18, CS-INV-008. - clusterRBACFinalizer = "security.ontai.dev/cluster-rbac" + clusterRBACFinalizer = "guardian.ontai.dev/cluster-rbac" // ClusterPolicyName is the canonical name of the cluster-level RBACPolicy // created in seam-tenant-{clusterName} for every InfrastructureTalosCluster. @@ -103,7 +103,7 @@ type ClusterRBACPolicyReconciler struct { // guardian-schema.md §18: "Re-validation occurs whenever management-maximum changes." func (r *ClusterRBACPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&seamv1alpha1.InfrastructureTalosCluster{}). + For(&platformseamv1alpha1.TalosCluster{}). WithEventFilter(predicate.GenerationChangedPredicate{}). Watches( &securityv1alpha1.PermissionSet{}, @@ -120,7 +120,7 @@ func (r *ClusterRBACPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { // in seam-system. Invoked when management-maximum changes so every cluster-maximum is // validated and synced to the new fleet ceiling. Exported for unit testing. §18. func (r *ClusterRBACPolicyReconciler) EnqueueAllTalosClusters(ctx context.Context, _ client.Object) []reconcile.Request { - list := &seamv1alpha1.InfrastructureTalosClusterList{} + list := &platformseamv1alpha1.TalosClusterList{} if err := r.Client.List(ctx, list, client.InNamespace(ManagementNamespace)); err != nil { return nil } @@ -137,7 +137,7 @@ func (r *ClusterRBACPolicyReconciler) EnqueueAllTalosClusters(ctx context.Contex func (r *ClusterRBACPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx).WithValues("taloscluster", req.NamespacedName) - tc := &seamv1alpha1.InfrastructureTalosCluster{} + tc := &platformseamv1alpha1.TalosCluster{} if err := r.Client.Get(ctx, req.NamespacedName, tc); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil @@ -155,7 +155,7 @@ func (r *ClusterRBACPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Re // reconcileCreate ensures cluster-maximum PermissionSet and cluster-policy RBACPolicy // exist in seam-tenant-{clusterName}, then adds the cluster-rbac finalizer. // Validates cluster-maximum against management-maximum at creation time (CS-INV-009). -func (r *ClusterRBACPolicyReconciler) reconcileCreate(ctx context.Context, tc *seamv1alpha1.InfrastructureTalosCluster, logger interface { +func (r *ClusterRBACPolicyReconciler) reconcileCreate(ctx context.Context, tc *platformseamv1alpha1.TalosCluster, logger interface { Info(string, ...interface{}) Error(error, string, ...interface{}) }) (ctrl.Result, error) { @@ -231,7 +231,7 @@ func (r *ClusterRBACPolicyReconciler) reconcileCreate(ctx context.Context, tc *s // seam-tenant-{clusterName}. This is the management-side authoritative profile that // the tenant conductor pulls and writes into ont-system on the target cluster. // guardian-schema.md §20. - if tc.Spec.Role == seamv1alpha1.InfrastructureTalosClusterRoleTenant { + if tc.Spec.Role == platformseamv1alpha1.TalosClusterRoleTenant { if err := r.ensureConductorTenantProfile(ctx, tc, ns); err != nil { return ctrl.Result{}, err } @@ -255,7 +255,7 @@ func (r *ClusterRBACPolicyReconciler) reconcileCreate(ctx context.Context, tc *s // writes it into ont-system on the target cluster. Idempotent. guardian-schema.md §20. func (r *ClusterRBACPolicyReconciler) ensureConductorTenantProfile( ctx context.Context, - tc *seamv1alpha1.InfrastructureTalosCluster, + tc *platformseamv1alpha1.TalosCluster, ns string, ) error { profile := &securityv1alpha1.RBACProfile{ @@ -287,7 +287,7 @@ func (r *ClusterRBACPolicyReconciler) ensureConductorTenantProfile( // reconcileDelete cascades deletion of all component RBACProfiles, then cluster objects, // then removes the finalizer from the TalosCluster. -func (r *ClusterRBACPolicyReconciler) reconcileDelete(ctx context.Context, tc *seamv1alpha1.InfrastructureTalosCluster, logger interface { +func (r *ClusterRBACPolicyReconciler) reconcileDelete(ctx context.Context, tc *platformseamv1alpha1.TalosCluster, logger interface { Info(string, ...interface{}) Error(error, string, ...interface{}) }) (ctrl.Result, error) { @@ -301,7 +301,7 @@ func (r *ClusterRBACPolicyReconciler) reconcileDelete(ctx context.Context, tc *s // Step 1a: for role=tenant clusters, delete conductor-tenant seam-operator profile. // This profile is NOT labeled component, so the component sweep below will not reach it. // guardian-schema.md §20. - if tc.Spec.Role == seamv1alpha1.InfrastructureTalosClusterRoleTenant { + if tc.Spec.Role == platformseamv1alpha1.TalosClusterRoleTenant { conductorProfile := &securityv1alpha1.RBACProfile{ ObjectMeta: metav1.ObjectMeta{Name: ConductorTenantProfileName, Namespace: ns}, } diff --git a/internal/controller/epg_controller.go b/internal/controller/epg_controller.go index 76e441e..f75dc4c 100644 --- a/internal/controller/epg_controller.go +++ b/internal/controller/epg_controller.go @@ -25,7 +25,7 @@ import ( securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/database" "github.com/ontai-dev/guardian/internal/epg" - seamconditions "github.com/ontai-dev/seam-core/pkg/conditions" + seamconditions "github.com/ontai-dev/seam/pkg/conditions" ) const ( @@ -53,14 +53,14 @@ const ( // // Implementation: guardian-design.md §2. // -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacprofiles,verbs=get;list;watch;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacpolicies,verbs=get;list;watch;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identitybindings,verbs=get;list;watch;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsets,verbs=get;list;watch;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identityproviders,verbs=get;list;watch;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsnapshots,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsnapshots/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsnapshotreceipts,verbs=get;list;watch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacprofiles,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacpolicies,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identitybindings,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsets,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identityproviders,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsnapshots,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsnapshots/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsnapshotreceipts,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch type EPGReconciler struct { // Client is the controller-runtime client for Kubernetes API access. @@ -340,12 +340,12 @@ func (r *EPGReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R }) // Step K — Emit a Normal event on the management cluster's RunnerConfig. - // runner.ontai.dev types are not imported into this operator. Unstructured access + // seam.ontai.dev types are not imported into this operator. Unstructured access // is used. Skip silently if RunnerConfig is not found (test and bootstrap scenarios). if len(result.TargetClusters) > 0 { rc := &unstructured.Unstructured{} rc.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "runner.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", Kind: "RunnerConfig", }) diff --git a/internal/controller/epg_stale_predicate_test.go b/internal/controller/epg_stale_predicate_test.go index 5b976b1..177b08f 100644 --- a/internal/controller/epg_stale_predicate_test.go +++ b/internal/controller/epg_stale_predicate_test.go @@ -16,7 +16,7 @@ import ( "testing" securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" - seamconditions "github.com/ontai-dev/seam-core/pkg/conditions" + seamconditions "github.com/ontai-dev/seam/pkg/conditions" ) // makeSnapshotWithFresh returns a minimal PermissionSnapshot with the Fresh diff --git a/internal/controller/identitybinding_controller.go b/internal/controller/identitybinding_controller.go index 74f41ae..a965300 100644 --- a/internal/controller/identitybinding_controller.go +++ b/internal/controller/identitybinding_controller.go @@ -52,10 +52,10 @@ type IdentityBindingReconciler struct { // 6. Set IdentityBindingValid=True. // 7. Annotate with epg-recompute-requested. // -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identitybindings,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identitybindings/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identitybindings/finalizers,verbs=update -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identityproviders,verbs=get;list;watch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identitybindings,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identitybindings/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identitybindings/finalizers,verbs=update +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identityproviders,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch func (r *IdentityBindingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) diff --git a/internal/controller/identityprovider_controller.go b/internal/controller/identityprovider_controller.go index 26e28fb..081a39d 100644 --- a/internal/controller/identityprovider_controller.go +++ b/internal/controller/identityprovider_controller.go @@ -46,9 +46,9 @@ type HTTPDoer interface { // 7. For Type=oidc: fetch discovery document, set Reachable condition. // 8. Annotate with epg-recompute-requested to signal EPGReconciler. // -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identityproviders,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identityproviders/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=identityproviders/finalizers,verbs=update +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identityproviders,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identityproviders/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=identityproviders/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch type IdentityProviderReconciler struct { // Client is the controller-runtime client for Kubernetes API access. diff --git a/internal/controller/permissionset_controller.go b/internal/controller/permissionset_controller.go index 162b906..c93c9f4 100644 --- a/internal/controller/permissionset_controller.go +++ b/internal/controller/permissionset_controller.go @@ -35,10 +35,10 @@ import ( // 7. Count ProfileReferenceCount by listing all RBACProfiles. // 8. Annotate with epg-recompute-requested. // -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsets,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsets/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsets/finalizers,verbs=update -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacprofiles,verbs=get;list;watch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsets/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsets/finalizers,verbs=update +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacprofiles,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch type PermissionSetReconciler struct { // Client is the controller-runtime client for Kubernetes API access. diff --git a/internal/controller/permissionsnapshot_controller.go b/internal/controller/permissionsnapshot_controller.go index ca5e6fc..78c29a9 100644 --- a/internal/controller/permissionsnapshot_controller.go +++ b/internal/controller/permissionsnapshot_controller.go @@ -15,7 +15,7 @@ import ( securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/database" - seamconditions "github.com/ontai-dev/seam-core/pkg/conditions" + seamconditions "github.com/ontai-dev/seam/pkg/conditions" ) // defaultFreshnessWindowSeconds is used when spec.FreshnessWindowSeconds is zero @@ -39,8 +39,8 @@ const defaultFreshnessWindowSeconds = 300 // written exclusively by the Conductor signing loop (INV-026) and the Guardian drift // detection loop respectively. // -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsnapshots,verbs=get;list;watch;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsnapshots/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsnapshots,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsnapshots/status,verbs=get;update;patch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch type PermissionSnapshotReconciler struct { // Client is the controller-runtime client for Kubernetes API access. diff --git a/internal/controller/rbacpolicy_controller.go b/internal/controller/rbacpolicy_controller.go index e73a36b..a637222 100644 --- a/internal/controller/rbacpolicy_controller.go +++ b/internal/controller/rbacpolicy_controller.go @@ -24,7 +24,7 @@ import ( // rbacPolicyFinalizer is added to every RBACPolicy on first reconcile to ensure // cleanup events are emitted before the object is fully removed. // INV-006: deletion triggers events, not Jobs. -const rbacPolicyFinalizer = "security.ontai.dev/rbacpolicy" +const rbacPolicyFinalizer = "guardian.ontai.dev/rbacpolicy" // RBACPolicyReconciler watches RBACPolicy CRs and validates their structure. // @@ -56,9 +56,9 @@ type RBACPolicyReconciler struct { // Reconcile is the main reconciliation loop for RBACPolicy. // -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacpolicies,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacpolicies/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacpolicies/finalizers,verbs=update +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacpolicies,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacpolicies/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacpolicies/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch func (r *RBACPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) diff --git a/internal/controller/rbacpolicy_validation.go b/internal/controller/rbacpolicy_validation.go index 72ce945..6fcebc1 100644 --- a/internal/controller/rbacpolicy_validation.go +++ b/internal/controller/rbacpolicy_validation.go @@ -1,4 +1,4 @@ -// Package controller contains the reconcilers for the security.ontai.dev API group. +// Package controller contains the reconcilers for the guardian.ontai.dev API group. // // INV-002: guardian is the one operator with genuine in-process intelligence. // ValidateRBACPolicySpec performs policy validation entirely in-process, with no diff --git a/internal/controller/rbacprofile_controller.go b/internal/controller/rbacprofile_controller.go index a8eba7f..a3f91e8 100644 --- a/internal/controller/rbacprofile_controller.go +++ b/internal/controller/rbacprofile_controller.go @@ -68,11 +68,11 @@ const epgRecomputeAnnotation = "ontai.dev/epg-recompute-requested" // CS-INV-005 enforcement: provisioned=true is set ONLY in Step I below. // There is no other code path in this file that writes status.Provisioned=true. // -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacprofiles,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacprofiles/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacprofiles/finalizers,verbs=update -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacpolicies,verbs=get;list;watch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=permissionsets,verbs=get;list;watch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacprofiles,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacprofiles/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacprofiles/finalizers,verbs=update +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacpolicies,verbs=get;list;watch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=permissionsets,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch // +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=get;list;watch;create;update;patch;delete;bind;escalate diff --git a/internal/controller/seammembership_controller.go b/internal/controller/seammembership_controller.go index 08d8ae5..97033f3 100644 --- a/internal/controller/seammembership_controller.go +++ b/internal/controller/seammembership_controller.go @@ -13,7 +13,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" - seamv1alpha1 "github.com/ontai-dev/seam-core/api/v1alpha1" + seamv1alpha1 "github.com/ontai-dev/seam/api/v1alpha1" ) // SeamMembershipReconciler watches SeamMembership CRs in seam-system and @@ -40,7 +40,7 @@ type SeamMembershipReconciler struct { // // +kubebuilder:rbac:groups=infrastructure.ontai.dev,resources=seammemberships,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=infrastructure.ontai.dev,resources=seammemberships/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=security.ontai.dev,resources=rbacprofiles,verbs=get;list;watch +// +kubebuilder:rbac:groups=guardian.ontai.dev,resources=rbacprofiles,verbs=get;list;watch func (r *SeamMembershipReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) diff --git a/internal/controller/tenant_snapshot_runnable.go b/internal/controller/tenant_snapshot_runnable.go index 60cc864..3ce10ea 100644 --- a/internal/controller/tenant_snapshot_runnable.go +++ b/internal/controller/tenant_snapshot_runnable.go @@ -18,7 +18,7 @@ import ( ) var snapshotGVR = schema.GroupVersionResource{ - Group: "security.ontai.dev", + Group: "guardian.ontai.dev", Version: "v1alpha1", Resource: "permissionsnapshots", } diff --git a/internal/database/startup.go b/internal/database/startup.go index d33bab9..c07cf8a 100644 --- a/internal/database/startup.go +++ b/internal/database/startup.go @@ -3,14 +3,9 @@ package database import ( "context" "database/sql" - "fmt" "time" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - - securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" ) const ( @@ -18,8 +13,8 @@ const ( // when Guardian is in degraded hold. guardian-schema.md §3 Step 1. CNPGRetryInterval = 30 * time.Second - // ConditionTypeCNPGUnreachable is the condition set on the Guardian singleton - // CR when CNPG is not reachable at startup. guardian-schema.md §3. + // ConditionTypeCNPGUnreachable is the condition name for CNPG unreachability. + // guardian-schema.md §3. ConditionTypeCNPGUnreachable = "CNPGUnreachable" // ReasonCNPGRetrying is the reason string used while Guardian is retrying. @@ -36,26 +31,21 @@ var OpenFunc = func(cfg ConnConfig) (*sql.DB, error) { } // RunWithRetry attempts to open a CNPG connection, run migrations, and return -// the connected DB. If CNPG is unreachable, it sets the CNPGUnreachable condition -// on the Guardian singleton CR and retries every CNPGRetryInterval until ctx is -// cancelled or the connection succeeds. It does not crash — it holds in degraded -// state per guardian-schema.md §3 Step 1. +// the connected DB. If CNPG is unreachable, it logs the degraded state and +// retries every CNPGRetryInterval until ctx is cancelled or the connection +// succeeds. It does not crash -- it holds in degraded state per guardian-schema.md +// §3 Step 1. // // configFn is called on every retry attempt so that a rotated CNPG credential // (secret updated by CNPG after a pod restart) is picked up without requiring -// a guardian restart. kube is used only for condition writes; if nil (tests), -// condition writes are skipped. -func RunWithRetry(ctx context.Context, configFn func() (ConnConfig, error), kube client.Client) (DB, error) { +// a guardian restart. +func RunWithRetry(ctx context.Context, configFn func() (ConnConfig, error)) (DB, error) { logger := log.FromContext(ctx).WithName("cnpg-startup") for { cfg, cfgErr := configFn() if cfgErr != nil { logger.Error(cfgErr, "CNPG config unresolvable; entering degraded hold") - if kube != nil { - msg := fmt.Sprintf("CNPG config unresolvable: %v. Retrying every %s.", cfgErr, CNPGRetryInterval) - _ = setCNPGCondition(ctx, kube, metav1.ConditionTrue, ReasonCNPGRetrying, msg) - } select { case <-ctx.Done(): return nil, ctx.Err() @@ -67,28 +57,16 @@ func RunWithRetry(ctx context.Context, configFn func() (ConnConfig, error), kube if err == nil { runner := NewMigrationRunner(db) if migErr := runner.Run(ctx); migErr == nil { - if kube != nil { - _ = setCNPGCondition(ctx, kube, metav1.ConditionFalse, - ReasonCNPGReady, "CNPG is reachable and migrations are applied.") - } logger.Info("CNPG connected and migrations applied") return db, nil } else { logger.Error(migErr, "migration runner failed; will retry") - err = fmt.Errorf("migration runner: %w", migErr) db.Close() } } else { logger.Error(err, "CNPG unreachable; entering degraded hold") } - // Set CNPGUnreachable condition on the Guardian singleton CR. - if kube != nil { - msg := fmt.Sprintf("CNPG unreachable: %v. Retrying every %s.", err, CNPGRetryInterval) - _ = setCNPGCondition(ctx, kube, metav1.ConditionTrue, ReasonCNPGRetrying, msg) - } - - // Wait before retrying. Return if ctx is cancelled. select { case <-ctx.Done(): return nil, ctx.Err() @@ -96,35 +74,3 @@ func RunWithRetry(ctx context.Context, configFn func() (ConnConfig, error), kube } } } - -// setCNPGCondition writes the CNPGUnreachable condition to the Guardian singleton CR. -// Errors are logged and ignored — condition writes are best-effort during startup. -func setCNPGCondition(ctx context.Context, kube client.Client, - status metav1.ConditionStatus, reason, message string) error { - - logger := log.FromContext(ctx) - - g := &securityv1alpha1.Guardian{} - if err := kube.Get(ctx, client.ObjectKey{ - Name: "guardian", - Namespace: "seam-system", - }, g); err != nil { - logger.Error(err, "setCNPGCondition: could not get Guardian singleton") - return err - } - - patchBase := client.MergeFrom(g.DeepCopy()) - securityv1alpha1.SetCondition( - &g.Status.Conditions, - ConditionTypeCNPGUnreachable, - status, - reason, - message, - g.Generation, - ) - if err := kube.Status().Patch(ctx, g, patchBase); err != nil { - logger.Error(err, "setCNPGCondition: failed to patch Guardian status") - return err - } - return nil -} diff --git a/internal/webhook/rbac_pack_intake_handler.go b/internal/webhook/rbac_pack_intake_handler.go index 654d79c..a9ab3f6 100644 --- a/internal/webhook/rbac_pack_intake_handler.go +++ b/internal/webhook/rbac_pack_intake_handler.go @@ -25,7 +25,7 @@ import ( // rbacPolicyGVK is the GroupVersionKind used to look up RBACPolicy objects via // the dynamic client when checking for cluster-policy existence. guardian-schema.md §6. var rbacPolicyGVK = schema.GroupVersionKind{ - Group: "security.ontai.dev", + Group: "guardian.ontai.dev", Version: "v1alpha1", Kind: "RBACPolicy", } @@ -95,7 +95,7 @@ func EnsurePackRBACProfileCRs(ctx context.Context, c client.Client, componentNam // guardian-schema.md §19 Layer 3, CS-INV-008. profile := &unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": "security.ontai.dev/v1alpha1", + "apiVersion": "guardian.ontai.dev/v1alpha1", "kind": "RBACProfile", "metadata": map[string]interface{}{ "name": componentName, diff --git a/test/e2e/backfill_validation_test.go b/test/e2e/backfill_validation_test.go index 0937115..6aad6f1 100644 --- a/test/e2e/backfill_validation_test.go +++ b/test/e2e/backfill_validation_test.go @@ -24,7 +24,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - e2ehelpers "github.com/ontai-dev/seam-core/pkg/e2e" + e2ehelpers "github.com/ontai-dev/seam/pkg/e2e" ) var ( diff --git a/test/e2e/suite_test.go b/test/e2e/suite_test.go index fe039b3..5d39a3c 100644 --- a/test/e2e/suite_test.go +++ b/test/e2e/suite_test.go @@ -21,7 +21,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - e2ehelpers "github.com/ontai-dev/seam-core/pkg/e2e" + e2ehelpers "github.com/ontai-dev/seam/pkg/e2e" ) // Suite-level cluster clients, initialized in BeforeSuite. diff --git a/test/e2e/tenant_snapshot_test.go b/test/e2e/tenant_snapshot_test.go index 8089ad3..56f60a1 100644 --- a/test/e2e/tenant_snapshot_test.go +++ b/test/e2e/tenant_snapshot_test.go @@ -25,7 +25,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - e2ehelpers "github.com/ontai-dev/seam-core/pkg/e2e" + e2ehelpers "github.com/ontai-dev/seam/pkg/e2e" ) // tenantNSExists returns true if the given namespace exists on the given cluster. diff --git a/test/integration/controller/rbacpolicy_crd_admission_test.go b/test/integration/controller/rbacpolicy_crd_admission_test.go index 0e2fa78..6b9093a 100644 --- a/test/integration/controller/rbacpolicy_crd_admission_test.go +++ b/test/integration/controller/rbacpolicy_crd_admission_test.go @@ -119,7 +119,7 @@ func TestRBACPolicyFinalizer_DeleteRemovesFinalizer(t *testing.T) { nn := types.NamespacedName{Name: policy.Name, Namespace: ns} // Step 1 — Wait for the reconciler to add the finalizer. - // The reconciler adds security.ontai.dev/rbacpolicy on first observation + // The reconciler adds guardian.ontai.dev/rbacpolicy on first observation // before processing the rest of the reconcile loop. ok := poll(t, 10*time.Second, func() bool { got := &securityv1alpha1.RBACPolicy{} @@ -127,7 +127,7 @@ func TestRBACPolicyFinalizer_DeleteRemovesFinalizer(t *testing.T) { return false } for _, f := range got.Finalizers { - if f == "security.ontai.dev/rbacpolicy" { + if f == "guardian.ontai.dev/rbacpolicy" { return true } } diff --git a/test/integration/lineage/lineage_immutability_test.go b/test/integration/lineage/lineage_immutability_test.go index 0e51484..7b61781 100644 --- a/test/integration/lineage/lineage_immutability_test.go +++ b/test/integration/lineage/lineage_immutability_test.go @@ -40,7 +40,7 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" ctrlwebhook "sigs.k8s.io/controller-runtime/pkg/webhook" - seamcorev1alpha1lineage "github.com/ontai-dev/seam-core/pkg/lineage" + seamcorev1alpha1lineage "github.com/ontai-dev/seam/pkg/lineage" securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/webhook" ) diff --git a/test/integration/lineage/testdata/lineage-validating-webhook-configuration.yaml b/test/integration/lineage/testdata/lineage-validating-webhook-configuration.yaml index 99e9615..e223849 100644 --- a/test/integration/lineage/testdata/lineage-validating-webhook-configuration.yaml +++ b/test/integration/lineage/testdata/lineage-validating-webhook-configuration.yaml @@ -3,12 +3,12 @@ kind: ValidatingWebhookConfiguration metadata: name: guardian-lineage-immutability-webhook webhooks: - - name: validate-lineage.security.ontai.dev + - name: validate-lineage.guardian.ontai.dev admissionReviewVersions: ["v1"] sideEffects: None failurePolicy: Fail rules: - - apiGroups: ["security.ontai.dev"] + - apiGroups: ["guardian.ontai.dev"] apiVersions: ["v1alpha1"] operations: ["UPDATE"] resources: diff --git a/test/unit/controller/apigroup_sweep_controller_test.go b/test/unit/controller/apigroup_sweep_controller_test.go index ef3fd26..241a3cb 100644 --- a/test/unit/controller/apigroup_sweep_controller_test.go +++ b/test/unit/controller/apigroup_sweep_controller_test.go @@ -56,16 +56,6 @@ func sweepMgmtMax() *securityv1alpha1.PermissionSet { } } -// sweepGuardian returns the Guardian singleton CR. -func sweepGuardian() *securityv1alpha1.Guardian { - return &securityv1alpha1.Guardian{ - ObjectMeta: metav1.ObjectMeta{ - Name: "guardian", - Namespace: "seam-system", - }, - } -} - // makeCRD returns a minimal CRD for the given group. func makeCRD(name, group string) *apiextensionsv1.CustomResourceDefinition { return &apiextensionsv1.CustomResourceDefinition{ @@ -133,7 +123,7 @@ func TestIsSystemGroup_CAPIGroups(t *testing.T) { func TestIsSystemGroup_SeamGroups(t *testing.T) { cases := []string{ - "security.ontai.dev", + "guardian.ontai.dev", "infrastructure.ontai.dev", "platform.ontai.dev", "core.ontai.dev", @@ -175,7 +165,7 @@ func TestCollectThirdPartyGroups_FiltersSystem(t *testing.T) { *makeCRD("foos.apps", "apps"), *makeCRD("bars.apiextensions.k8s.io", "apiextensions.k8s.io"), *makeCRD("clusters.cluster.x-k8s.io", "cluster.x-k8s.io"), - *makeCRD("pols.security.ontai.dev", "security.ontai.dev"), + *makeCRD("pols.guardian.ontai.dev", "guardian.ontai.dev"), } got := controller.CollectThirdPartyGroups(crds) if len(got) != 1 { @@ -231,8 +221,8 @@ func TestAPIGroupSweep_NewGroupAdded(t *testing.T) { crd := makeCRD("certs.cert-manager.io", "cert-manager.io") c := fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(sweepMgmtMax(), sweepGuardian(), crd). - WithStatusSubresource(&securityv1alpha1.Guardian{}, &securityv1alpha1.PermissionSet{}). + WithObjects(sweepMgmtMax(), crd). + WithStatusSubresource(&securityv1alpha1.PermissionSet{}). Build() r := &controller.APIGroupSweepController{ @@ -266,15 +256,6 @@ func TestAPIGroupSweep_NewGroupAdded(t *testing.T) { if !found { t.Error("expected cert-manager.io rule in management-maximum; not found") } - - // Guardian status should list the discovered group. - gdn := &securityv1alpha1.Guardian{} - if err := c.Get(context.Background(), types.NamespacedName{Name: "guardian", Namespace: "seam-system"}, gdn); err != nil { - t.Fatalf("get Guardian: %v", err) - } - if len(gdn.Status.DiscoveredAPIGroups) != 1 || gdn.Status.DiscoveredAPIGroups[0] != "cert-manager.io" { - t.Errorf("DiscoveredAPIGroups = %v; want [cert-manager.io]", gdn.Status.DiscoveredAPIGroups) - } } func TestAPIGroupSweep_Idempotent(t *testing.T) { @@ -289,8 +270,8 @@ func TestAPIGroupSweep_Idempotent(t *testing.T) { crd := makeCRD("certs.cert-manager.io", "cert-manager.io") c := fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(ps, sweepGuardian(), crd). - WithStatusSubresource(&securityv1alpha1.Guardian{}, &securityv1alpha1.PermissionSet{}). + WithObjects(ps, crd). + WithStatusSubresource(&securityv1alpha1.PermissionSet{}). Build() r := &controller.APIGroupSweepController{ @@ -345,29 +326,6 @@ func TestAPIGroupSweep_ManagementMaximumAbsent_Requeues(t *testing.T) { } } -func TestAPIGroupSweep_GuardianAbsent_NoError(t *testing.T) { - scheme := buildAPIGroupSweepScheme(t) - crd := makeCRD("certs.cert-manager.io", "cert-manager.io") - // No Guardian CR in the fake client. - c := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(sweepMgmtMax(), crd). - WithStatusSubresource(&securityv1alpha1.PermissionSet{}). - Build() - - r := &controller.APIGroupSweepController{ - Client: c, - Scheme: scheme, - OperatorNamespace: "seam-system", - } - _, err := r.Reconcile(context.Background(), ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "sweep/apigroups"}, - }) - if err != nil { - t.Errorf("expected no error when Guardian singleton absent; got: %v", err) - } -} - func TestAPIGroupSweep_NonSweepKey_Ignored(t *testing.T) { scheme := buildAPIGroupSweepScheme(t) c := fake.NewClientBuilder().WithScheme(scheme).Build() @@ -390,16 +348,16 @@ func TestAPIGroupSweep_SystemGroupsNotAdded(t *testing.T) { *makeCRD("foos.apps", "apps"), *makeCRD("bars.rbac.authorization.k8s.io", "rbac.authorization.k8s.io"), *makeCRD("clusters.cluster.x-k8s.io", "cluster.x-k8s.io"), - *makeCRD("pols.security.ontai.dev", "security.ontai.dev"), + *makeCRD("pols.guardian.ontai.dev", "guardian.ontai.dev"), } - clientObjs := []client.Object{sweepMgmtMax(), sweepGuardian()} + clientObjs := []client.Object{sweepMgmtMax()} for i := range crds { clientObjs = append(clientObjs, &crds[i]) } cAny := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(clientObjs...). - WithStatusSubresource(&securityv1alpha1.Guardian{}, &securityv1alpha1.PermissionSet{}). + WithStatusSubresource(&securityv1alpha1.PermissionSet{}). Build() r := &controller.APIGroupSweepController{ @@ -423,11 +381,4 @@ func TestAPIGroupSweep_SystemGroupsNotAdded(t *testing.T) { t.Errorf("expected 1 rule (wildcard only); got %d rules", len(got.Spec.Permissions)) } - gdn := &securityv1alpha1.Guardian{} - if err := cAny.Get(context.Background(), types.NamespacedName{Name: "guardian", Namespace: "seam-system"}, gdn); err != nil { - t.Fatalf("get Guardian: %v", err) - } - if len(gdn.Status.DiscoveredAPIGroups) != 0 { - t.Errorf("DiscoveredAPIGroups should be empty; got %v", gdn.Status.DiscoveredAPIGroups) - } } diff --git a/test/unit/controller/bootstrap_annotation_test.go b/test/unit/controller/bootstrap_annotation_test.go index d48c63a..8e03c3c 100644 --- a/test/unit/controller/bootstrap_annotation_test.go +++ b/test/unit/controller/bootstrap_annotation_test.go @@ -24,13 +24,12 @@ import ( "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" - clientevents "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/controller" "github.com/ontai-dev/guardian/internal/webhook" - ctrl "sigs.k8s.io/controller-runtime" ) // buildSweepScheme returns a Scheme with core + rbac + security API groups registered. @@ -99,7 +98,7 @@ func buildSweepRunnable(t *testing.T, objs ...runtime.Object) (*controller.Boots }, done } -// Test 1 — Sweep skips exempt namespaces. +// Test 1 -- Sweep skips exempt namespaces. // Namespaces with seam.ontai.dev/webhook-mode=exempt must be skipped entirely. // Resources inside them must NOT be annotated by the sweep. CS-INV-004. func TestBootstrapAnnotationSweep_SkipsExemptNamespaces(t *testing.T) { @@ -128,7 +127,7 @@ func TestBootstrapAnnotationSweep_SkipsExemptNamespaces(t *testing.T) { } } -// Test 2 — Sweep annotates unannotated resources in non-exempt namespaces. +// Test 2 -- Sweep annotates unannotated resources in non-exempt namespaces. // Roles, ClusterRoles, and ServiceAccounts missing the ownership annotation // must receive ontai.dev/rbac-owner=guardian and ontai.dev/rbac-enforcement-mode=audit. func TestBootstrapAnnotationSweep_AnnotatesUnannotatedResources(t *testing.T) { @@ -145,7 +144,6 @@ func TestBootstrapAnnotationSweep_AnnotatesUnannotatedResources(t *testing.T) { t.Fatal("SweepDone must be true after Start completes") } - // Role must be annotated. gotRole := &rbacv1.Role{} if err := runnable.Client.Get(context.Background(), types.NamespacedName{ Name: "platform-manager", Namespace: "ont-system", @@ -159,7 +157,6 @@ func TestBootstrapAnnotationSweep_AnnotatesUnannotatedResources(t *testing.T) { t.Errorf("role: want ontai.dev/rbac-enforcement-mode=audit, got %q", gotRole.Annotations[controller.AnnotationRBACEnforcementMode]) } - // ClusterRole must be annotated. gotCR := &rbacv1.ClusterRole{} if err := runnable.Client.Get(context.Background(), types.NamespacedName{ Name: "platform-cluster-role", @@ -170,7 +167,6 @@ func TestBootstrapAnnotationSweep_AnnotatesUnannotatedResources(t *testing.T) { t.Errorf("clusterrole: want ontai.dev/rbac-owner=guardian, got %q", gotCR.Annotations[webhook.AnnotationRBACOwner]) } - // ServiceAccount must be annotated. gotSA := &corev1.ServiceAccount{} if err := runnable.Client.Get(context.Background(), types.NamespacedName{ Name: "platform-sa", Namespace: "ont-system", @@ -182,24 +178,20 @@ func TestBootstrapAnnotationSweep_AnnotatesUnannotatedResources(t *testing.T) { } } -// Test 3 — Sweep is idempotent. +// Test 3 -- Sweep is idempotent. // Running Start twice on the same resources produces the same annotations. -// The second run must not change or remove annotations set by the first. func TestBootstrapAnnotationSweep_Idempotent(t *testing.T) { ns := makeNamespace("ont-system", nil) role := makeRole("platform-manager", "ont-system") runnable, _ := buildSweepRunnable(t, ns, role) - // First run. if err := runnable.Start(context.Background()); err != nil { t.Fatalf("first Start error: %v", err) } - // Reset SweepDone so Start may be called again. runnable.SweepDone.Store(false) - // Second run. if err := runnable.Start(context.Background()); err != nil { t.Fatalf("second Start error: %v", err) } @@ -217,8 +209,8 @@ func TestBootstrapAnnotationSweep_Idempotent(t *testing.T) { } // Test: system: prefixed ClusterRoles are skipped by the sweep. -// Kubernetes built-in ClusterRoles (e.g. system:kube-controller-manager) must never -// be annotated — patching them risks corrupting system RBAC. CS-INV-007. +// Kubernetes built-in ClusterRoles must never be annotated -- patching them risks +// corrupting system RBAC. CS-INV-007. func TestBootstrapAnnotationSweep_SkipsSystemPrefixedClusterRoles(t *testing.T) { ns := makeNamespace("ont-system", nil) systemCR := makeClusterRole("system:kube-controller-manager") @@ -232,7 +224,6 @@ func TestBootstrapAnnotationSweep_SkipsSystemPrefixedClusterRoles(t *testing.T) t.Fatal("SweepDone must be true after Start completes") } - // system: ClusterRole must NOT be annotated. gotSystem := &rbacv1.ClusterRole{} if err := runnable.Client.Get(context.Background(), types.NamespacedName{ Name: "system:kube-controller-manager", @@ -243,7 +234,6 @@ func TestBootstrapAnnotationSweep_SkipsSystemPrefixedClusterRoles(t *testing.T) t.Error("system: ClusterRole must not be annotated by sweep") } - // Regular ClusterRole must still be annotated. gotRegular := &rbacv1.ClusterRole{} if err := runnable.Client.Get(context.Background(), types.NamespacedName{ Name: "platform-cluster-role", @@ -256,14 +246,10 @@ func TestBootstrapAnnotationSweep_SkipsSystemPrefixedClusterRoles(t *testing.T) } } -// Test 4 — Sweep does not annotate already-owned resources. -// Resources that already carry ontai.dev/rbac-owner=guardian must be counted as -// already-owned and left untouched (no enforcement-mode stamp added by sweep). +// Test 4 -- Sweep does not annotate already-owned resources. func TestBootstrapAnnotationSweep_SkipsAlreadyOwnedResources(t *testing.T) { ns := makeNamespace("ont-system", nil) owned := makeAnnotatedRole("already-owned", "ont-system") - // The role already has ontai.dev/rbac-owner=guardian but no enforcement-mode. - // After sweep, enforcement-mode should still be absent (sweep skips it). runnable, done := buildSweepRunnable(t, ns, owned) if err := runnable.Start(context.Background()); err != nil { @@ -279,7 +265,6 @@ func TestBootstrapAnnotationSweep_SkipsAlreadyOwnedResources(t *testing.T) { }, got); err != nil { t.Fatalf("get role: %v", err) } - // The sweep does not patch already-owned resources, so enforcement-mode is absent. if got.Annotations[controller.AnnotationRBACEnforcementMode] != "" { t.Errorf("already-owned role: enforcement-mode annotation should be absent, got %q", got.Annotations[controller.AnnotationRBACEnforcementMode]) @@ -299,21 +284,18 @@ func buildBootstrapReconcilerWithSweep( for _, o := range objs { builder = builder.WithRuntimeObjects(o) } - c := builder.WithStatusSubresource(&securityv1alpha1.Guardian{}, &securityv1alpha1.RBACProfile{}).Build() + c := builder.WithStatusSubresource(&securityv1alpha1.RBACProfile{}).Build() gate := webhook.NewWebhookModeGate() registry := webhook.NewNamespaceEnforcementRegistry() return &controller.BootstrapController{ - Client: c, - Scheme: s, - Recorder: clientevents.NewFakeRecorder(32), - Gate: gate, - Registry: registry, - OperatorNamespace: "seam-system", - SweepDone: sweepDone, + Client: c, + Gate: gate, + Registry: registry, + SweepDone: sweepDone, } } -// Test 5 — BootstrapController blocks ObserveOnly advance when SweepDone=false. +// Test 5 -- BootstrapController blocks ObserveOnly advance when SweepDone=false. // Even when all RBACProfiles are provisioned, the controller must requeue with a // 5s backoff rather than advancing to ObserveOnly when the sweep is incomplete. func TestBootstrapController_BlocksObserveOnlyWhenSweepNotComplete(t *testing.T) { @@ -321,58 +303,31 @@ func TestBootstrapController_BlocksObserveOnlyWhenSweepNotComplete(t *testing.T) done := &atomic.Bool{} // starts false r := buildBootstrapReconcilerWithSweep(t, done, p) - result, err := r.Reconcile(context.Background(), ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: controller.GuardianSingletonName, - Namespace: "seam-system", - }, - }) + result, err := r.Reconcile(context.Background(), ctrl.Request{}) if err != nil { t.Fatalf("unexpected reconcile error: %v", err) } if result.RequeueAfter == 0 { t.Error("expected RequeueAfter > 0 when sweep not complete") } - - // Mode must still be Initialising — advance must not have happened. - gdn := &securityv1alpha1.Guardian{} - if err := r.Client.Get(context.Background(), types.NamespacedName{ - Name: controller.GuardianSingletonName, - Namespace: "seam-system", - }, gdn); err != nil { - t.Fatalf("get Guardian: %v", err) - } - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeInitialising { - t.Errorf("WebhookMode = %q, want Initialising while sweep incomplete", gdn.Status.WebhookMode) + if r.Gate.Mode() != securityv1alpha1.WebhookModeInitialising { + t.Errorf("gate = %q, want Initialising while sweep incomplete", r.Gate.Mode()) } } -// Test 6 — BootstrapController advances to ObserveOnly when SweepDone=true and all +// Test 6 -- BootstrapController advances to ObserveOnly when SweepDone=true and all // profiles are provisioned. The gate transitions normally once the sweep is complete. func TestBootstrapController_AdvancesObserveOnlyWhenSweepComplete(t *testing.T) { p := buildProvisionedProfile("guardian-profile", "seam-system") done := &atomic.Bool{} - done.Store(true) // sweep complete + done.Store(true) r := buildBootstrapReconcilerWithSweep(t, done, p) - _, err := r.Reconcile(context.Background(), ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: controller.GuardianSingletonName, - Namespace: "seam-system", - }, - }) + _, err := r.Reconcile(context.Background(), ctrl.Request{}) if err != nil { t.Fatalf("unexpected reconcile error: %v", err) } - - gdn := &securityv1alpha1.Guardian{} - if err := r.Client.Get(context.Background(), types.NamespacedName{ - Name: controller.GuardianSingletonName, - Namespace: "seam-system", - }, gdn); err != nil { - t.Fatalf("get Guardian: %v", err) - } - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeObserveOnly { - t.Errorf("WebhookMode = %q, want ObserveOnly when sweep complete and profiles ready", gdn.Status.WebhookMode) + if r.Gate.Mode() != securityv1alpha1.WebhookModeObserveOnly { + t.Errorf("gate = %q, want ObserveOnly when sweep complete and profiles ready", r.Gate.Mode()) } } diff --git a/test/unit/controller/bootstrap_controller_test.go b/test/unit/controller/bootstrap_controller_test.go index d75fe4e..3e04426 100644 --- a/test/unit/controller/bootstrap_controller_test.go +++ b/test/unit/controller/bootstrap_controller_test.go @@ -1,8 +1,7 @@ // Package controller_test contains unit tests for the BootstrapController. // // Tests cover: -// - Singleton Guardian CR creation on first reconcile. -// - Startup sequence: WebhookMode=Initialising on creation. +// - Global gate stays Initialising when no RBACProfiles exist. // - ObserveOnly global transition when all RBACProfiles are provisioned. // - Per-namespace enforce transition when namespace profiles are provisioned. // - Partial provisioning: global gate remains Initialising; namespace not promoted. @@ -20,10 +19,8 @@ import ( rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" - clientevents "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -49,78 +46,26 @@ func buildBootstrapReconciler(t *testing.T, objs ...runtime.Object) ( for _, o := range objs { builder = builder.WithRuntimeObjects(o) } - c := builder.WithStatusSubresource(&securityv1alpha1.Guardian{}, &securityv1alpha1.RBACProfile{}).Build() + c := builder.WithStatusSubresource(&securityv1alpha1.RBACProfile{}).Build() gate := webhook.NewWebhookModeGate() registry := webhook.NewNamespaceEnforcementRegistry() r := &controller.BootstrapController{ - Client: c, - Scheme: s, - Recorder: clientevents.NewFakeRecorder(32), - Gate: gate, - Registry: registry, - OperatorNamespace: "seam-system", + Client: c, + Gate: gate, + Registry: registry, } return r, gate, registry } -func reconcileBootstrap(t *testing.T, r *controller.BootstrapController, name, ns string) ctrl.Result { +func reconcileBootstrap(t *testing.T, r *controller.BootstrapController) ctrl.Result { t.Helper() - result, err := r.Reconcile(context.Background(), ctrl.Request{ - NamespacedName: types.NamespacedName{Name: name, Namespace: ns}, - }) + result, err := r.Reconcile(context.Background(), ctrl.Request{}) if err != nil { t.Fatalf("unexpected reconcile error: %v", err) } return result } -func getGuardian(t *testing.T, r *controller.BootstrapController) *securityv1alpha1.Guardian { - t.Helper() - gdn := &securityv1alpha1.Guardian{} - if err := r.Client.Get(context.Background(), types.NamespacedName{ - Name: controller.GuardianSingletonName, - Namespace: "seam-system", - }, gdn); err != nil { - t.Fatalf("get Guardian singleton: %v", err) - } - return gdn -} - -// Test 1 — Startup: Guardian singleton is created with WebhookMode=Initialising. -// First reconcile creates the Guardian CR if absent. INV-020. -func TestBootstrapController_CreatesGuardianSingleton(t *testing.T) { - r, gate, _ := buildBootstrapReconciler(t) - - // No profiles, no Guardian CR pre-existing. - reconcileBootstrap(t, r, "any-profile", "seam-system") - - gdn := getGuardian(t, r) - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeInitialising { - t.Errorf("expected WebhookMode=Initialising on creation; got %q", gdn.Status.WebhookMode) - } - - // In-memory gate must remain Initialising (no profiles → not ready). - if gate.Mode() != securityv1alpha1.WebhookModeInitialising { - t.Errorf("expected gate=Initialising; got %q", gate.Mode()) - } -} - -// Test 2 — Empty profiles: global gate stays Initialising; Guardian CR created. -// With no RBACProfiles present, there is nothing to check → stay in bootstrap. INV-020. -func TestBootstrapController_NoProfiles_StaysInitialising(t *testing.T) { - r, gate, _ := buildBootstrapReconciler(t) - - reconcileBootstrap(t, r, "any", "seam-system") - - gdn := getGuardian(t, r) - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeInitialising { - t.Errorf("WebhookMode = %q, want Initialising", gdn.Status.WebhookMode) - } - if gate.Mode() != securityv1alpha1.WebhookModeInitialising { - t.Errorf("gate = %q, want Initialising", gate.Mode()) - } -} - // buildProvisionedProfile creates a provisioned RBACProfile in the given namespace. func buildProvisionedProfile(name, ns string) *securityv1alpha1.RBACProfile { return &securityv1alpha1.RBACProfile{ @@ -146,79 +91,73 @@ func buildUnprovisionedProfile(name, ns string) *securityv1alpha1.RBACProfile { return p } -// Test 3 — All profiles provisioned: global mode advances to ObserveOnly. +// Test 1 -- No profiles: global gate stays Initialising. +// With no RBACProfiles present, there is nothing to check -> stay in bootstrap. INV-020. +func TestBootstrapController_NoProfiles_StaysInitialising(t *testing.T) { + r, gate, _ := buildBootstrapReconciler(t) + + reconcileBootstrap(t, r) + + if gate.Mode() != securityv1alpha1.WebhookModeInitialising { + t.Errorf("gate = %q, want Initialising with no profiles", gate.Mode()) + } +} + +// Test 2 -- All profiles provisioned: global mode advances to ObserveOnly. // When all RBACProfiles across all namespaces reach Provisioned=True, the -// BootstrapController sets WebhookMode=ObserveOnly on the Guardian CR and -// advances the in-memory gate. INV-020, CS-INV-004. +// BootstrapController advances the in-memory gate to ObserveOnly. INV-020, CS-INV-004. func TestBootstrapController_AllProfilesProvisioned_AdvancesToObserveOnly(t *testing.T) { p1 := buildProvisionedProfile("profile-guardian", "seam-system") p2 := buildProvisionedProfile("profile-platform", "seam-system") r, gate, _ := buildBootstrapReconciler(t, p1, p2) - reconcileBootstrap(t, r, p1.Name, p1.Namespace) + reconcileBootstrap(t, r) - gdn := getGuardian(t, r) - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeObserveOnly { - t.Errorf("WebhookMode = %q, want ObserveOnly", gdn.Status.WebhookMode) - } if gate.Mode() != securityv1alpha1.WebhookModeObserveOnly { t.Errorf("gate = %q, want ObserveOnly", gate.Mode()) } } -// Test 4 — Partial provisioning: global mode stays Initialising. +// Test 3 -- Partial provisioning: global mode stays Initialising. // If any profile is not yet provisioned, the global mode must not advance. func TestBootstrapController_PartialProvisioning_StaysInitialising(t *testing.T) { p1 := buildProvisionedProfile("profile-guardian", "seam-system") p2 := buildUnprovisionedProfile("profile-platform", "seam-system") r, gate, _ := buildBootstrapReconciler(t, p1, p2) - reconcileBootstrap(t, r, p1.Name, p1.Namespace) + reconcileBootstrap(t, r) - gdn := getGuardian(t, r) - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeInitialising { - t.Errorf("WebhookMode = %q, want Initialising (partial provisioning)", gdn.Status.WebhookMode) - } if gate.Mode() != securityv1alpha1.WebhookModeInitialising { - t.Errorf("gate = %q, want Initialising", gate.Mode()) + t.Errorf("gate = %q, want Initialising (partial provisioning)", gate.Mode()) } } -// Test 5 — Per-namespace enforcement: namespace with all profiles provisioned is promoted. -// The BootstrapController records the namespace in Guardian.Status.NamespaceEnforcements -// and marks it active in the in-memory registry. INV-020. +// Test 4 -- Per-namespace enforcement: namespace with all profiles provisioned is promoted. +// The BootstrapController marks the namespace active in the in-memory registry. INV-020. func TestBootstrapController_NamespaceWithAllProfilesProvisioned_IsPromoted(t *testing.T) { p1 := buildProvisionedProfile("profile-a", "seam-system") r, _, registry := buildBootstrapReconciler(t, p1) - reconcileBootstrap(t, r, p1.Name, p1.Namespace) + reconcileBootstrap(t, r) - gdn := getGuardian(t, r) - if !gdn.Status.NamespaceEnforcements["seam-system"] { - t.Error("expected seam-system in NamespaceEnforcements after all profiles provisioned") - } if !registry.IsActive("seam-system") { t.Error("expected seam-system active in in-memory registry") } } -// Test 6 — Per-namespace enforcement: namespace with unprovisioned profile is NOT promoted. +// Test 5 -- Per-namespace enforcement: namespace with unprovisioned profile is NOT promoted. func TestBootstrapController_NamespaceWithUnprovisionedProfile_NotPromoted(t *testing.T) { p1 := buildUnprovisionedProfile("profile-a", "seam-system") r, _, registry := buildBootstrapReconciler(t, p1) - reconcileBootstrap(t, r, p1.Name, p1.Namespace) + reconcileBootstrap(t, r) - gdn := getGuardian(t, r) - if gdn.Status.NamespaceEnforcements["seam-system"] { - t.Error("seam-system must not be in NamespaceEnforcements when profile not provisioned") - } if registry.IsActive("seam-system") { t.Error("seam-system must not be active in registry when profile not provisioned") } } -// Test 7 — Mixed namespaces: provisioned namespace promoted; unprovisioned namespace not. +// Test 6 -- Mixed namespaces: provisioned namespace promoted; unprovisioned namespace not. // Two namespaces: seam-system (all provisioned) and ont-system (one unprovisioned). // Only seam-system should be promoted; global mode stays Initialising (ont-system not ready). func TestBootstrapController_MixedNamespaces_OnlyProvisionedNamespacePromoted(t *testing.T) { @@ -226,59 +165,47 @@ func TestBootstrapController_MixedNamespaces_OnlyProvisionedNamespacePromoted(t p2 := buildUnprovisionedProfile("conductor-profile", "ont-system") r, gate, registry := buildBootstrapReconciler(t, p1, p2) - reconcileBootstrap(t, r, p1.Name, p1.Namespace) + reconcileBootstrap(t, r) - // seam-system fully provisioned → promoted. if !registry.IsActive("seam-system") { t.Error("seam-system should be active in registry") } - // ont-system not fully provisioned → not promoted. if registry.IsActive("ont-system") { t.Error("ont-system must not be active in registry (has unprovisioned profile)") } - // Global gate stays Initialising — not all namespaces ready. if gate.Mode() != securityv1alpha1.WebhookModeInitialising { t.Errorf("gate = %q, want Initialising (ont-system not ready)", gate.Mode()) } } -// Test 8 — ObserveOnly transition is one-way: reconcile after ObserveOnly stays ObserveOnly. +// Test 7 -- ObserveOnly transition is one-way: reconcile after ObserveOnly stays ObserveOnly. // Once the mode advances to ObserveOnly, BootstrapController must not revert it even -// if a profile becomes unprovisioned (e.g., due to drift). INV-020. +// if a profile becomes unprovisioned. INV-020. func TestBootstrapController_ObserveOnlyTransitionIsOneWay(t *testing.T) { p1 := buildProvisionedProfile("profile-a", "seam-system") r, gate, _ := buildBootstrapReconciler(t, p1) - // First reconcile: advance to ObserveOnly. - reconcileBootstrap(t, r, p1.Name, p1.Namespace) + reconcileBootstrap(t, r) if gate.Mode() != securityv1alpha1.WebhookModeObserveOnly { t.Fatalf("precondition: gate should be ObserveOnly after first reconcile") } - // Simulate profile becoming unprovisioned. Use Status().Update so the fake - // client (WithStatusSubresource) actually persists the status change. A plain - // Update() with WithStatusSubresource strips the status field silently. + // Simulate profile becoming unprovisioned. p1.Status.Provisioned = false if err := r.Client.Status().Update(context.Background(), p1); err != nil { t.Fatalf("status update profile: %v", err) } - // Second reconcile: mode must not revert to Initialising. - // It may advance to Enforcing in WS2 if all RBAC is annotated — but it must - // never revert backward. The one-way ratchet invariant is: mode only moves forward. - reconcileBootstrap(t, r, p1.Name, p1.Namespace) + reconcileBootstrap(t, r) - gdn := getGuardian(t, r) - if gdn.Status.WebhookMode == securityv1alpha1.WebhookModeInitialising { - t.Errorf("WebhookMode = %q after revert, mode must never revert to Initialising (one-way ratchet)", - gdn.Status.WebhookMode) - } if gate.Mode() == securityv1alpha1.WebhookModeInitialising { - t.Errorf("gate = %q after revert, gate must never revert to Initialising", gate.Mode()) + t.Errorf("gate = %q after revert, gate must never revert to Initialising (one-way ratchet)", gate.Mode()) } } -// Test 9 — GuardedNamespaceModeResolver: Initialising gate → all non-exempt namespaces observe. +// --- GuardedNamespaceModeResolver tests --- + +// Test 8 -- Initialising gate -> all non-exempt namespaces observe. // During bootstrap (gate=Initialising), GuardedNamespaceModeResolver must return Observe // for any namespace that the base resolver returns Enforce for. INV-020. func TestGuardedResolver_InitialisingGate_NonExemptNamespacesObserve(t *testing.T) { @@ -298,8 +225,8 @@ func TestGuardedResolver_InitialisingGate_NonExemptNamespacesObserve(t *testing. } } -// Test 10 — GuardedNamespaceModeResolver: Initialising gate + exempt namespace → still Exempt. -// Exempt namespaces bypass the global gate — they are always exempt. CS-INV-004. +// Test 9 -- Initialising gate + exempt namespace -> still Exempt. +// Exempt namespaces bypass the global gate -- they are always exempt. CS-INV-004. func TestGuardedResolver_InitialisingGate_ExemptNamespaceRemainsExempt(t *testing.T) { gate := webhook.NewWebhookModeGate() registry := webhook.NewNamespaceEnforcementRegistry() @@ -316,7 +243,7 @@ func TestGuardedResolver_InitialisingGate_ExemptNamespaceRemainsExempt(t *testin } } -// Test 11 — GuardedNamespaceModeResolver: ObserveOnly gate + namespace not in registry → Observe. +// Test 10 -- ObserveOnly gate + namespace not in registry -> Observe. // After ObserveOnly, namespaces not yet promoted by BootstrapController remain observe. INV-020. func TestGuardedResolver_ObserveOnlyGate_NamespaceNotInRegistry_Observe(t *testing.T) { gate := webhook.NewWebhookModeGate() @@ -333,15 +260,14 @@ func TestGuardedResolver_ObserveOnlyGate_NamespaceNotInRegistry_Observe(t *testi } } -// Test 12 — GuardedNamespaceModeResolver: ObserveOnly gate + namespace Active but not -// Enforcing → Observe. SetActive alone is no longer sufficient to enable deny posture. -// The namespace must reach the Enforcing tier (SetEnforcing) before the resolver -// returns Enforce mode. Active-but-not-Enforcing namespaces always observe. WS2. +// Test 11 -- ObserveOnly gate + namespace Active but not Enforcing -> Observe. +// SetActive alone is not sufficient to enable deny posture. The namespace must reach +// the Enforcing tier (SetEnforcing) before the resolver returns Enforce mode. WS2. func TestGuardedResolver_ObserveOnlyGate_NamespaceActiveNotEnforcing_Observe(t *testing.T) { gate := webhook.NewWebhookModeGate() gate.SetMode(securityv1alpha1.WebhookModeObserveOnly) registry := webhook.NewNamespaceEnforcementRegistry() - registry.SetActive("active-only-ns") // Active but NOT Enforcing + registry.SetActive("active-only-ns") base := &webhook.StaticNamespaceModeResolver{ Modes: map[string]webhook.NamespaceMode{ "active-only-ns": webhook.NamespaceModeEnforce, @@ -355,10 +281,9 @@ func TestGuardedResolver_ObserveOnlyGate_NamespaceActiveNotEnforcing_Observe(t * } } -// Test 12b — GuardedNamespaceModeResolver: ObserveOnly gate + namespace Enforcing → base mode. +// Test 12 -- ObserveOnly gate + namespace Enforcing -> base mode. // Only after SetEnforcing does the resolver return the base mode (Enforce for unlabelled -// namespaces). This is the two-tier promotion: Active (profiles ready) then Enforcing -// (profiles ready + all RBAC annotated). WS2. +// namespaces). This is the two-tier promotion: Active then Enforcing. WS2. func TestGuardedResolver_ObserveOnlyGate_NamespaceEnforcing_ReturnsBaseMode(t *testing.T) { gate := webhook.NewWebhookModeGate() gate.SetMode(securityv1alpha1.WebhookModeObserveOnly) @@ -381,7 +306,6 @@ func TestGuardedResolver_ObserveOnlyGate_NamespaceEnforcing_ReturnsBaseMode(t *t // --- WS2: Enforcing mode advancement --- // buildAnnotatedProfile creates a provisioned RBACProfile in the given namespace. -// Used alongside annotated RBAC resources to test Enforcing advancement. func buildAnnotatedProfile(name, ns string) *securityv1alpha1.RBACProfile { return buildProvisionedProfile(name, ns) } @@ -412,52 +336,44 @@ func buildReconcilerForEnforcing(t *testing.T, objs ...runtime.Object) *controll } // reconcileBootstrapN calls Reconcile N times and asserts no error on each. -func reconcileBootstrapN(t *testing.T, r *controller.BootstrapController, n int, name, ns string) { +func reconcileBootstrapN(t *testing.T, r *controller.BootstrapController, n int) { t.Helper() for i := 0; i < n; i++ { - if _, err := r.Reconcile(context.Background(), ctrl.Request{ - NamespacedName: types.NamespacedName{Name: name, Namespace: ns}, - }); err != nil { + if _, err := r.Reconcile(context.Background(), ctrl.Request{}); err != nil { t.Fatalf("reconcile %d: %v", i, err) } } } -// Test 13 — Enforcing advance fires when all namespaces and cluster-scoped resources ready. +// Test 13 -- Enforcing advance fires when all namespaces and cluster-scoped resources ready. // When all RBACProfiles are provisioned AND all RBAC resources carry the ownership // annotation, BootstrapController advances WebhookMode from ObserveOnly to Enforcing // and advances the in-memory gate. WS2. INV-020. func TestBootstrapController_EnforcingAdvancesWhenAllNamespacesReady(t *testing.T) { - // A provisioned profile in seam-system, and an annotated Role there. profile := buildAnnotatedProfile("guardian-profile", "seam-system") role := makeAnnotatedRoleResource("some-role", "seam-system") r := buildReconcilerForEnforcing(t, profile, role) // First reconcile: ObserveOnly advance (profiles ready, sweep done). - reconcileBootstrapN(t, r, 1, controller.GuardianSingletonName, "seam-system") + reconcileBootstrapN(t, r, 1) - gdn := getGuardian(t, r) - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeObserveOnly { - t.Fatalf("precondition: expected ObserveOnly after first reconcile, got %q", gdn.Status.WebhookMode) + if r.Gate.Mode() != securityv1alpha1.WebhookModeObserveOnly { + t.Fatalf("precondition: expected ObserveOnly after first reconcile, got %q", r.Gate.Mode()) } // Second reconcile: Enforcing advance (all resources annotated, no ClusterRoles/CRBs). - reconcileBootstrapN(t, r, 1, controller.GuardianSingletonName, "seam-system") + reconcileBootstrapN(t, r, 1) - gdn = getGuardian(t, r) - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeEnforcing { - t.Errorf("WebhookMode = %q, want Enforcing when all namespaces ready", gdn.Status.WebhookMode) - } if r.Gate.Mode() != securityv1alpha1.WebhookModeEnforcing { - t.Errorf("gate = %q, want Enforcing", r.Gate.Mode()) + t.Errorf("gate = %q, want Enforcing when all namespaces ready", r.Gate.Mode()) } if !r.Registry.IsEnforcing("seam-system") { t.Error("expected seam-system IsEnforcing=true after Enforcing advance") } } -// Test 14 — Enforcing advance blocked when unannotated RBAC resource exists. +// Test 14 -- Enforcing advance blocked when unannotated RBAC resource exists. // Even with all profiles provisioned, if any RBAC resource in any namespace is // missing ontai.dev/rbac-owner=guardian, the global Enforcing advance must not fire. func TestBootstrapController_EnforcingBlockedWhenUnannotatedResourceExists(t *testing.T) { @@ -467,12 +383,8 @@ func TestBootstrapController_EnforcingBlockedWhenUnannotatedResourceExists(t *te r := buildReconcilerForEnforcing(t, profile, unannotated) // Two reconciles: first ObserveOnly, second evaluates enforcing. - reconcileBootstrapN(t, r, 2, controller.GuardianSingletonName, "seam-system") + reconcileBootstrapN(t, r, 2) - gdn := getGuardian(t, r) - if gdn.Status.WebhookMode == securityv1alpha1.WebhookModeEnforcing { - t.Error("WebhookMode must not advance to Enforcing when unannotated resources exist") - } if r.Gate.Mode() == securityv1alpha1.WebhookModeEnforcing { t.Error("gate must not advance to Enforcing when unannotated resources exist") } @@ -481,7 +393,7 @@ func TestBootstrapController_EnforcingBlockedWhenUnannotatedResourceExists(t *te } } -// Test 15 — Enforcing transition is one-way. +// Test 15 -- Enforcing transition is one-way. // Once global mode reaches Enforcing, BootstrapController must not revert it even if // the profile count changes. INV-020. func TestBootstrapController_EnforcingTransitionIsOneWay(t *testing.T) { @@ -491,22 +403,20 @@ func TestBootstrapController_EnforcingTransitionIsOneWay(t *testing.T) { r := buildReconcilerForEnforcing(t, profile, role) // Advance to Enforcing. - reconcileBootstrapN(t, r, 2, controller.GuardianSingletonName, "seam-system") + reconcileBootstrapN(t, r, 2) - gdn := getGuardian(t, r) - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeEnforcing { - t.Fatalf("precondition: expected Enforcing, got %q", gdn.Status.WebhookMode) + if r.Gate.Mode() != securityv1alpha1.WebhookModeEnforcing { + t.Fatalf("precondition: expected Enforcing, got %q", r.Gate.Mode()) } - // Simulate profile becoming unprovisioned — mode must not revert. + // Simulate profile becoming unprovisioned -- mode must not revert. profile.Status.Provisioned = false if err := r.Client.Status().Update(context.Background(), profile); err != nil { t.Fatalf("status update profile: %v", err) } - reconcileBootstrapN(t, r, 1, controller.GuardianSingletonName, "seam-system") + reconcileBootstrapN(t, r, 1) - gdn = getGuardian(t, r) - if gdn.Status.WebhookMode != securityv1alpha1.WebhookModeEnforcing { - t.Errorf("WebhookMode = %q after regression, want Enforcing (one-way ratchet)", gdn.Status.WebhookMode) + if r.Gate.Mode() != securityv1alpha1.WebhookModeEnforcing { + t.Errorf("gate = %q after regression, want Enforcing (one-way ratchet)", r.Gate.Mode()) } } diff --git a/test/unit/controller/cluster_rbacpolicy_controller_test.go b/test/unit/controller/cluster_rbacpolicy_controller_test.go index 51858f3..bafe6b9 100644 --- a/test/unit/controller/cluster_rbacpolicy_controller_test.go +++ b/test/unit/controller/cluster_rbacpolicy_controller_test.go @@ -3,7 +3,7 @@ // Tests verify the three-layer RBAC hierarchy provisioning contract: // - On TalosCluster creation: cluster-maximum PermissionSet + cluster-policy RBACPolicy // created in seam-tenant-{clusterName}, inheriting permissions from management-maximum. -// - Finalizer security.ontai.dev/cluster-rbac is added to TalosCluster. +// - Finalizer guardian.ontai.dev/cluster-rbac is added to TalosCluster. // - Second reconcile is idempotent (no duplicate objects, no error). // - On TalosCluster deletion: all component-labeled RBACProfiles deleted, then // cluster objects deleted, then finalizer removed. @@ -30,7 +30,7 @@ import ( securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/controller" - seamv1alpha1 "github.com/ontai-dev/seam-core/api/v1alpha1" + platformseamv1alpha1 "github.com/ontai-dev/platform/api/seam/v1alpha1" ) // buildClusterRBACScheme returns a scheme with core, security, and seam-core types. @@ -39,7 +39,7 @@ func buildClusterRBACScheme(t *testing.T) *runtime.Scheme { s := runtime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(s)) utilruntime.Must(securityv1alpha1.AddToScheme(s)) - utilruntime.Must(seamv1alpha1.AddToScheme(s)) + utilruntime.Must(platformseamv1alpha1.AddToScheme(s)) return s } @@ -61,8 +61,8 @@ func managementMaximumPermSet() *securityv1alpha1.PermissionSet { } // newTalosCluster returns a minimal InfrastructureTalosCluster in seam-system. -func newTalosClusterForRBACTest(name string) *seamv1alpha1.InfrastructureTalosCluster { - return &seamv1alpha1.InfrastructureTalosCluster{ +func newTalosClusterForRBACTest(name string) *platformseamv1alpha1.TalosCluster { + return &platformseamv1alpha1.TalosCluster{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "seam-system", @@ -79,7 +79,7 @@ func buildClusterRBACClient(t *testing.T, objs ...client.Object) client.Client { return fake.NewClientBuilder(). WithScheme(buildClusterRBACScheme(t)). WithObjects(all...). - WithStatusSubresource(&seamv1alpha1.InfrastructureTalosCluster{}). + WithStatusSubresource(&platformseamv1alpha1.TalosCluster{}). Build() } @@ -137,7 +137,7 @@ func TestClusterRBACPolicyReconciler_CreatesClusterObjects(t *testing.T) { } // TestClusterRBACPolicyReconciler_AddsFinalizer verifies that the finalizer -// security.ontai.dev/cluster-rbac is added to the TalosCluster. guardian-schema.md §18. +// guardian.ontai.dev/cluster-rbac is added to the TalosCluster. guardian-schema.md §18. func TestClusterRBACPolicyReconciler_AddsFinalizer(t *testing.T) { tc := newTalosClusterForRBACTest("prod") c := buildClusterRBACClient(t, tc) @@ -145,7 +145,7 @@ func TestClusterRBACPolicyReconciler_AddsFinalizer(t *testing.T) { reconcileClusterRBAC(t, r, "prod") - updated := &seamv1alpha1.InfrastructureTalosCluster{} + updated := &platformseamv1alpha1.TalosCluster{} if err := c.Get(context.Background(), types.NamespacedName{ Name: "prod", Namespace: "seam-system", }, updated); err != nil { @@ -153,13 +153,13 @@ func TestClusterRBACPolicyReconciler_AddsFinalizer(t *testing.T) { } found := false for _, f := range updated.Finalizers { - if f == "security.ontai.dev/cluster-rbac" { + if f == "guardian.ontai.dev/cluster-rbac" { found = true break } } if !found { - t.Errorf("finalizer security.ontai.dev/cluster-rbac not found on TalosCluster, got: %v", updated.Finalizers) + t.Errorf("finalizer guardian.ontai.dev/cluster-rbac not found on TalosCluster, got: %v", updated.Finalizers) } } @@ -221,12 +221,12 @@ func TestClusterRBACPolicyReconciler_ClusterMaximumInheritsFromManagementMaximum // finalizer removed. guardian-schema.md §18 deletion cascade. func TestClusterRBACPolicyReconciler_DeleteCascadesComponentProfiles(t *testing.T) { now := metav1.Now() - tc := &seamv1alpha1.InfrastructureTalosCluster{ + tc := &platformseamv1alpha1.TalosCluster{ ObjectMeta: metav1.ObjectMeta{ Name: "ccs-dev", Namespace: "seam-system", DeletionTimestamp: &now, - Finalizers: []string{"security.ontai.dev/cluster-rbac"}, + Finalizers: []string{"guardian.ontai.dev/cluster-rbac"}, }, } @@ -286,13 +286,13 @@ func TestClusterRBACPolicyReconciler_DeleteCascadesComponentProfiles(t *testing. // Finalizer removal: the fake client deletes the TalosCluster once all finalizers // are removed and DeletionTimestamp is set. Verify the object is no longer found, // which confirms the finalizer was removed successfully. - gone := &seamv1alpha1.InfrastructureTalosCluster{} + gone := &platformseamv1alpha1.TalosCluster{} if err := c.Get(context.Background(), types.NamespacedName{ Name: "ccs-dev", Namespace: "seam-system", }, gone); err == nil { // Object still exists -- check that our finalizer was removed. for _, f := range gone.Finalizers { - if f == "security.ontai.dev/cluster-rbac" { + if f == "guardian.ontai.dev/cluster-rbac" { t.Error("cluster-rbac finalizer must be removed after cascade delete completes") } } @@ -382,14 +382,14 @@ func TestClusterRBACPolicyReconciler_EnqueueAllTalosClusters(t *testing.T) { } // newTenantTalosCluster returns a minimal InfrastructureTalosCluster with role=tenant. -func newTenantTalosCluster(name string) *seamv1alpha1.InfrastructureTalosCluster { - return &seamv1alpha1.InfrastructureTalosCluster{ +func newTenantTalosCluster(name string) *platformseamv1alpha1.TalosCluster { + return &platformseamv1alpha1.TalosCluster{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "seam-system", }, - Spec: seamv1alpha1.InfrastructureTalosClusterSpec{ - Role: seamv1alpha1.InfrastructureTalosClusterRoleTenant, + Spec: platformseamv1alpha1.TalosClusterSpec{ + Role: platformseamv1alpha1.TalosClusterRoleTenant, }, } } @@ -456,7 +456,7 @@ func TestClusterRBACPolicyReconciler_TenantDeletion_DeletesConductorTenantProfil tc := newTenantTalosCluster("ccs-dev-del") now := metav1.Now() tc.DeletionTimestamp = &now - tc.Finalizers = []string{"security.ontai.dev/cluster-rbac"} + tc.Finalizers = []string{"guardian.ontai.dev/cluster-rbac"} ns := "seam-tenant-ccs-dev-del" diff --git a/test/unit/controller/permissionsnapshot_controller_test.go b/test/unit/controller/permissionsnapshot_controller_test.go index fca3a66..f565333 100644 --- a/test/unit/controller/permissionsnapshot_controller_test.go +++ b/test/unit/controller/permissionsnapshot_controller_test.go @@ -19,7 +19,7 @@ import ( securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/controller" - seamconditions "github.com/ontai-dev/seam-core/pkg/conditions" + seamconditions "github.com/ontai-dev/seam/pkg/conditions" ) // minimalSnapshot returns a PermissionSnapshot with the given name/namespace, diff --git a/test/unit/controller/rbacpolicy_controller_test.go b/test/unit/controller/rbacpolicy_controller_test.go index 40e55f5..3068137 100644 --- a/test/unit/controller/rbacpolicy_controller_test.go +++ b/test/unit/controller/rbacpolicy_controller_test.go @@ -196,7 +196,7 @@ func TestRBACPolicyReconciler_FinalizerBlocksDeletion(t *testing.T) { } hasFinalizer := false for _, f := range p.Finalizers { - if f == "security.ontai.dev/rbacpolicy" { + if f == "guardian.ontai.dev/rbacpolicy" { hasFinalizer = true } } diff --git a/test/unit/controller/seammembership_controller_test.go b/test/unit/controller/seammembership_controller_test.go index 0e088e7..2917870 100644 --- a/test/unit/controller/seammembership_controller_test.go +++ b/test/unit/controller/seammembership_controller_test.go @@ -27,7 +27,7 @@ import ( securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/controller" - seamv1alpha1 "github.com/ontai-dev/seam-core/api/v1alpha1" + seamv1alpha1 "github.com/ontai-dev/seam/api/v1alpha1" ) // --------------------------------------------------------------------------- diff --git a/test/unit/controller/tenant_snapshot_runnable_test.go b/test/unit/controller/tenant_snapshot_runnable_test.go index f2fb711..d9cf8ad 100644 --- a/test/unit/controller/tenant_snapshot_runnable_test.go +++ b/test/unit/controller/tenant_snapshot_runnable_test.go @@ -6,8 +6,8 @@ // 3. Patching lastAckedVersion, lastSeen, drift=false on the management snapshot. // 4. Setting the Compliant=True condition on the management snapshot. // -// Guardian role=tenant exclusively owns all security.ontai.dev operations. -// Conductor must never write security.ontai.dev resources. INV-004. +// Guardian role=tenant exclusively owns all guardian.ontai.dev operations. +// Conductor must never write guardian.ontai.dev resources. INV-004. // // guardian-schema.md §7, §8, §15. INV-004. package controller_test @@ -34,7 +34,7 @@ import ( // snapshotGVR is the GVR for PermissionSnapshot. Mirrors the private const in the runnable. var testSnapshotGVR = schema.GroupVersionResource{ - Group: "security.ontai.dev", + Group: "guardian.ontai.dev", Version: "v1alpha1", Resource: "permissionsnapshots", } @@ -61,7 +61,7 @@ func buildLocalScheme(t *testing.T) *runtime.Scheme { func makePermissionSnapshot(name, namespace, targetCluster, version string) *securityv1alpha1.PermissionSnapshot { return &securityv1alpha1.PermissionSnapshot{ TypeMeta: metav1.TypeMeta{ - APIVersion: "security.ontai.dev/v1alpha1", + APIVersion: "guardian.ontai.dev/v1alpha1", Kind: "PermissionSnapshot", }, ObjectMeta: metav1.ObjectMeta{ diff --git a/test/unit/database/startup_test.go b/test/unit/database/startup_test.go index 45c2ba1..710d316 100644 --- a/test/unit/database/startup_test.go +++ b/test/unit/database/startup_test.go @@ -1,9 +1,8 @@ // Package database_test covers RunWithRetry startup behaviour. // -// These tests verify the degraded-hold contract: when CNPG is unreachable, -// RunWithRetry sets the CNPGUnreachable condition on the Guardian singleton CR -// and retries indefinitely without crashing. It exits only when the context -// is cancelled or CNPG becomes reachable. +// Tests verify the degraded-hold contract: when CNPG is unreachable, +// RunWithRetry logs the condition and retries indefinitely without crashing. +// It exits only when the context is cancelled or CNPG becomes reachable. // // guardian-schema.md §3 Step 1, §16. package database_test @@ -16,36 +15,11 @@ import ( "testing" "time" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - securityv1alpha1 "github.com/ontai-dev/guardian/api/v1alpha1" "github.com/ontai-dev/guardian/internal/database" ) -// buildStartupScheme returns a scheme with all types needed for RunWithRetry tests. -func buildStartupScheme() *runtime.Scheme { - s := runtime.NewScheme() - _ = clientgoscheme.AddToScheme(s) - _ = securityv1alpha1.AddToScheme(s) - return s -} - -// guardianSingleton builds a minimal Guardian singleton CR used in startup tests. -func guardianSingleton() *securityv1alpha1.Guardian { - return &securityv1alpha1.Guardian{ - ObjectMeta: metav1.ObjectMeta{ - Name: "guardian", - Namespace: "seam-system", - }, - } -} - // TestRunWithRetry_ContextCancellationStopsRetry verifies that RunWithRetry does -// not crash when CNPG is permanently unreachable — it holds in degraded state and +// not crash when CNPG is permanently unreachable -- it holds in degraded state and // exits cleanly when the context is cancelled. This satisfies the "no crash" contract // from guardian-schema.md §3 Step 1. func TestRunWithRetry_ContextCancellationStopsRetry(t *testing.T) { @@ -65,7 +39,7 @@ func TestRunWithRetry_ContextCancellationStopsRetry(t *testing.T) { _, err := database.RunWithRetry(ctx, func() (database.ConnConfig, error) { return database.ConnConfig{}, nil - }, nil) + }) if err == nil { t.Fatal("expected RunWithRetry to return an error when context is cancelled") @@ -77,51 +51,3 @@ func TestRunWithRetry_ContextCancellationStopsRetry(t *testing.T) { t.Error("expected at least one OpenFunc attempt before context cancellation") } } - -// TestRunWithRetry_CNPGUnreachableSetsCondition verifies that when CNPG is unreachable, -// RunWithRetry sets the CNPGUnreachable=True condition on the Guardian singleton CR. -// This is the primary observability signal for the degraded-hold state. -// guardian-schema.md §3 Step 1, §16. -func TestRunWithRetry_CNPGUnreachableSetsCondition(t *testing.T) { - orig := database.OpenFunc - defer func() { database.OpenFunc = orig }() - - database.OpenFunc = func(_ database.ConnConfig) (*sql.DB, error) { - return nil, fmt.Errorf("connection refused: test injection") - } - - // Build a fake kube client with the Guardian singleton pre-populated. - s := buildStartupScheme() - singleton := guardianSingleton() - fakeKube := fake.NewClientBuilder(). - WithScheme(s). - WithObjects(singleton). - WithStatusSubresource(singleton). - Build() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) - defer cancel() - - // kube is non-nil so condition writes are attempted. - database.RunWithRetry(ctx, func() (database.ConnConfig, error) { //nolint:errcheck - return database.ConnConfig{}, nil - }, fakeKube) - - // Re-fetch the Guardian singleton and verify the condition was set. - g := &securityv1alpha1.Guardian{} - if err := fakeKube.Get(context.Background(), - client.ObjectKey{Name: "guardian", Namespace: "seam-system"}, g); err != nil { - t.Fatalf("could not get Guardian singleton after RunWithRetry: %v", err) - } - - cond := securityv1alpha1.FindCondition(g.Status.Conditions, database.ConditionTypeCNPGUnreachable) - if cond == nil { - t.Fatal("CNPGUnreachable condition was not set on Guardian singleton") - } - if cond.Status != metav1.ConditionTrue { - t.Errorf("expected CNPGUnreachable=True; got %s", cond.Status) - } - if cond.Reason != database.ReasonCNPGRetrying { - t.Errorf("expected reason %q; got %q", database.ReasonCNPGRetrying, cond.Reason) - } -} diff --git a/test/unit/webhook/declaring_principal_test.go b/test/unit/webhook/declaring_principal_test.go index 8791516..51ed917 100644 --- a/test/unit/webhook/declaring_principal_test.go +++ b/test/unit/webhook/declaring_principal_test.go @@ -119,7 +119,7 @@ func TestDeclaringPrincipal_ServiceAccountRecordedAsIs(t *testing.T) { srv, _ := newDeclaringPrincipalServer(false) obj := mustMarshal(map[string]interface{}{ - "apiVersion": "security.ontai.dev/v1alpha1", + "apiVersion": "guardian.ontai.dev/v1alpha1", "kind": "RBACPolicy", "metadata": map[string]interface{}{"name": "default-policy"}, }) @@ -162,7 +162,7 @@ func TestDeclaringPrincipal_HumanEmailRecordedAsIs(t *testing.T) { srv, _ := newDeclaringPrincipalServer(false) obj := mustMarshal(map[string]interface{}{ - "apiVersion": "infra.ontai.dev/v1alpha1", + "apiVersion": "seam.ontai.dev/v1alpha1", "kind": "PackExecution", "metadata": map[string]interface{}{"name": "exec-001"}, })