feat(rbac): implement hardened v1alpha1 RoleTemplate/RoleAssignment with admission, ceiling, and VWC bootstrap #11

Open
Daniel.Sy wants to merge 10 commits from ipceicis-9690-rbac-impl-local into main
Owner

Summary

Controller-util dependency assessment and controller hardening fixes (no controller-util added).

controller-util Assessment

Decision: do not adopt controller-util.

controller-util (PersistFailure, kcpws, mctest, kcppath) is not adopted in this PR. Rationale:

  • PersistFailure wraps status-patch + error propagation. Our existing patchStatus + caller-returns-error pattern is equivalent with fewer transitive dependencies.
  • kcpws / kcppath add KCP workspace path utilities not yet needed outside the bootstrap path.
  • mctest adds test helpers over envtest; the current fake-client approach is simpler and has no envtest dependency.
  • Adopting any of these solely for the findings below would bring transitive deps and cross-cutting conventions not yet reviewed across the codebase. The fixes are implementable without the library.

Changes

🔒 Security: stale-CRB deletion in block() must requeue on error (finding 1)

block() in the roleassignment reconciler previously logged a Delete error for the stale ClusterRoleBinding and continued to patch status Blocked, returning nil. This meant the work-queue did not retry: a transient API-server failure left the stale CRB alive while the controller believed it had revoked access.

Fix: block() now returns the error when Delete fails, causing the work-queue to requeue. Status is only patched after successful deletion.

Test added: TestReconcile_BlockDeleteError_RequeuesAndLeavesStale — injects a Delete interceptor error, asserts non-nil reconcile error and stale CRB still present.

♻️ Refactor: extract internal/naming package (finding 2)

Three controllers each contained an identical sha256-truncation naming helper with only the prefix differing. Extracted to internal/naming:

  • ClusterRoleName(rtName)edge-connect-rt-<name> (≤63 chars)
  • AssignmentCRBName(raName)edge-connect-ra-<name> (≤63 chars)
  • BindingCRBName(crbName)edge-connect-<name> (≤63 chars)

Generated names are unchanged. roletemplate no longer duplicates assignmentCRBName (import-cycle workaround now unneeded). Regression tests guard against algorithm drift.

Tests added: naming_test.go — length, exact-boundary, one-beyond-boundary, determinism, no-collision-across-prefixes, and known-name regression tests.

♻️ Refactor: replace subset labelsMatch with maps.Equal (finding 3)

labelsMatch was a subset check (only tested that desired keys were present). Stale labels added outside the controller were not removed on update. Replaced with maps.Equal in roleassignment and clusterrolebinding so the label set is replaced wholesale on drift.

Tests added: TestReconcile_StaleLabel_Removed in both packages.

♻️ Refactor: deduplicate condition/label constants (finding 4)

Local ConditionSynced, ConditionReady, ConditionTemplateResolved, LabelManagedBy, LabelManagedByValue, LabelSourceKind, LabelSourceName constants in all three controller packages now reference the canonical api/v1alpha1 package constants. Per-type setCondition wrappers retained — they are controller-specific and adding an external dependency solely for them is not warranted.

Not changed

  • No controller-util, kcpws, mctest, kcppath, or PersistFailure dependency added.
  • No functionality change outside the block() security fix.
  • No changes to poc-core-deploy or published artefacts.

Refs: IPCEICIS-9690

## Summary Controller-util dependency assessment and controller hardening fixes (no controller-util added). ### controller-util Assessment **Decision: do not adopt controller-util.** controller-util (`PersistFailure`, `kcpws`, `mctest`, `kcppath`) is not adopted in this PR. Rationale: - `PersistFailure` wraps status-patch + error propagation. Our existing `patchStatus` + caller-returns-error pattern is equivalent with fewer transitive dependencies. - `kcpws` / `kcppath` add KCP workspace path utilities not yet needed outside the bootstrap path. - `mctest` adds test helpers over `envtest`; the current fake-client approach is simpler and has no envtest dependency. - Adopting any of these solely for the findings below would bring transitive deps and cross-cutting conventions not yet reviewed across the codebase. The fixes are implementable without the library. ### Changes #### 🔒 Security: stale-CRB deletion in `block()` must requeue on error (finding 1) `block()` in the roleassignment reconciler previously logged a Delete error for the stale `ClusterRoleBinding` and continued to patch status `Blocked`, returning `nil`. This meant the work-queue did not retry: a transient API-server failure left the stale CRB alive while the controller believed it had revoked access. **Fix**: `block()` now returns the error when `Delete` fails, causing the work-queue to requeue. Status is only patched after successful deletion. **Test added**: `TestReconcile_BlockDeleteError_RequeuesAndLeavesStale` — injects a `Delete` interceptor error, asserts non-nil reconcile error and stale CRB still present. #### ♻️ Refactor: extract `internal/naming` package (finding 2) Three controllers each contained an identical `sha256`-truncation naming helper with only the prefix differing. Extracted to `internal/naming`: - `ClusterRoleName(rtName)` — `edge-connect-rt-<name>` (≤63 chars) - `AssignmentCRBName(raName)` — `edge-connect-ra-<name>` (≤63 chars) - `BindingCRBName(crbName)` — `edge-connect-<name>` (≤63 chars) Generated names are **unchanged**. `roletemplate` no longer duplicates `assignmentCRBName` (import-cycle workaround now unneeded). Regression tests guard against algorithm drift. **Tests added**: `naming_test.go` — length, exact-boundary, one-beyond-boundary, determinism, no-collision-across-prefixes, and known-name regression tests. #### ♻️ Refactor: replace subset `labelsMatch` with `maps.Equal` (finding 3) `labelsMatch` was a subset check (only tested that desired keys were present). Stale labels added outside the controller were not removed on update. Replaced with `maps.Equal` in roleassignment and clusterrolebinding so the label set is replaced wholesale on drift. **Tests added**: `TestReconcile_StaleLabel_Removed` in both packages. #### ♻️ Refactor: deduplicate condition/label constants (finding 4) Local `ConditionSynced`, `ConditionReady`, `ConditionTemplateResolved`, `LabelManagedBy`, `LabelManagedByValue`, `LabelSourceKind`, `LabelSourceName` constants in all three controller packages now reference the canonical `api/v1alpha1` package constants. Per-type `setCondition` wrappers retained — they are controller-specific and adding an external dependency solely for them is not warranted. ### Not changed - No `controller-util`, `kcpws`, `mctest`, `kcppath`, or `PersistFailure` dependency added. - No functionality change outside the `block()` security fix. - No changes to `poc-core-deploy` or published artefacts. Refs: IPCEICIS-9690
- Define RoleTemplate and RoleAssignment CRD types with full spec/status/deepcopy
- Implement roletemplate and roleassignment reconcilers with unit tests
- Generate CRD manifests and KCP APIResourceSchema configs for both types
- Update main.go to register schemes and wire new controllers
- Add Helm chart templates for KCP APIResourceSchemas and update apiexport
- Add usage examples for RoleTemplate and RoleAssignment
- Update clusterrole RBAC permissions for new resource types

Ref: IPCEICIS-9690
Adds rbac-controller-arm64 and rbac-controller-amd64 patterns to
.gitignore to suppress the 53MiB untracked binary warning seen in PR #11.
Project convention puts Makefile builds under bin/ (already ignored);
these patterns cover ad-hoc local arch-suffixed builds at repo root.

Ref: IPCEICIS-9690
Patrick.Sy approved these changes 2026-07-22 13:59:21 +00:00
Dismissed
Patrick.Sy left a comment

Critical: Tenant-authored templates are materialized without permission validation

api/v1alpha1/roletemplate_types.go:90-133
internal/controller/roletemplate/roletemplate.go:131-166

spec.rules permits wildcards, and the reconciler copies the rules directly into a native ClusterRole. No admission webhook or controller-side permission-ceiling validation exists.

Any tenant permitted to create RoleTemplate and RoleAssignment can potentially request arbitrary permissions and bind them to arbitrary users or groups. Deferring validation while enabling materialization makes the controller a confused deputy.

The controller must reject templates exceeding an enforced permission ceiling before creating or updating a native ClusterRole.

High: spec.sealed is mutable and spoofable

api/v1alpha1/roletemplate_types.go:107-114
internal/controller/roletemplate/roletemplate.go:145-148

Any caller allowed to create or update RoleTemplate can:

spec:
  sealed: true

The controller then stamps the materialized role with the platform-preset label. There is no creator validation, immutability rule, or admission webhook.

Additionally, sealed templates remain fully mutable. Updating their rules causes the controller to update the native ClusterRole.

Do not treat spec.sealed as platform provenance. Remove it from user-controlled input or enforce platform-only creation, update, patch, and deletion before materialization is enabled.

High: Blocked assignments leave existing authorization active

internal/controller/roleassignment/roleassignment.go:83-106,221-229

When a previously ready template becomes missing or not ready, block() only updates status. It does not delete the existing native ClusterRoleBinding.

If the old ClusterRole remains, access remains effective despite the assignment reporting Blocked. If the role is deleted, the dangling binding can silently become effective again when a role with the same name appears.

The blocked transition must remove the generated binding and clear its status reference.

High: Blocked assignments do not recover when templates become ready

internal/controller/roleassignment/roleassignment.go:260-265

The controller watches RoleAssignment and owned native bindings, but not RoleTemplate.

An assignment created before its template exists becomes Blocked. Creating or reconciling the template later does not enqueue the assignment, and the blocked path has no timed requeue.

Add a RoleTemplate watch mapping templates to referencing assignments, with tests for both readiness and invalidation transitions.

High: forkedFrom is accepted but not validated

api/v1alpha1/roletemplate_types.go:116-123
internal/controller/roletemplate/roletemplate.go:150-156

The implementation does not:

  • Resolve the referenced template.
  • Verify that it is sealed.
  • Inherit any rules.
  • Enforce that forked permissions are a subset.

It merely copies the claimed source into an annotation. This creates misleading provenance for arbitrary permissions. Reject forkedFrom until containment enforcement exists, or clearly make it non-authoritative.

Medium: Unsupported propagation modes still report Ready

api/v1alpha1/roleassignment_types.go:15-66
internal/controller/roleassignment/roleassignment.go:164-177

The CRD accepts SelectedWorkspaces and AllWorkspaces, but the reconciler applies only the local workspace and still reports the assignment phase as Ready.

A caller can request global propagation and receive overall success even though the requested state was not achieved. Unsupported modes should be rejected or leave the resource non-ready.

Medium: capabilityRequirements is ignored

api/v1alpha1/roletemplate_types.go:125-133

Templates with unmet capability requirements are materialized and marked Ready. This makes a security-relevant API field purely decorative.

Reject the field until implemented, or evaluate requirements and report a non-ready condition.

Medium: Removing sealed or forkedFrom leaves stale metadata

internal/controller/roletemplate/roletemplate.go:204-220

labelsMatch and annotationsMatch only verify desired entries. Extra existing entries are ignored.

Changing sealed from true to false does not trigger removal of the existing sealed label. Clearing forkedFrom similarly leaves the old annotation behind.

Compare and reconcile the complete controller-owned metadata set.

Medium: templateRef is mutable

api/v1alpha1/roleassignment_types.go:71-96

Changing templateRef silently changes the granted role. Native Kubernetes bindings deliberately make roleRef immutable to prevent this class of privilege change.

Add CRD validation making templateRef immutable. Users should replace the assignment to grant a different role.

Testing Gaps

Missing regression coverage includes:

  • Tenant-created sealed: true.
  • Mutation of sealed-template rules.
  • Permission-ceiling rejection.
  • Ready-to-blocked cleanup.
  • Blocked assignment recovery after template creation.
  • Removal of sealed and fork metadata.
  • Unimplemented propagation modes.
  • Unmet capability requirements.
### Critical: Tenant-authored templates are materialized without permission validation `api/v1alpha1/roletemplate_types.go:90-133` `internal/controller/roletemplate/roletemplate.go:131-166` `spec.rules` permits wildcards, and the reconciler copies the rules directly into a native `ClusterRole`. No admission webhook or controller-side permission-ceiling validation exists. Any tenant permitted to create `RoleTemplate` and `RoleAssignment` can potentially request arbitrary permissions and bind them to arbitrary users or groups. Deferring validation while enabling materialization makes the controller a confused deputy. The controller must reject templates exceeding an enforced permission ceiling before creating or updating a native `ClusterRole`. ### High: `spec.sealed` is mutable and spoofable `api/v1alpha1/roletemplate_types.go:107-114` `internal/controller/roletemplate/roletemplate.go:145-148` Any caller allowed to create or update `RoleTemplate` can: ```yaml spec: sealed: true ``` The controller then stamps the materialized role with the platform-preset label. There is no creator validation, immutability rule, or admission webhook. Additionally, sealed templates remain fully mutable. Updating their rules causes the controller to update the native `ClusterRole`. Do not treat `spec.sealed` as platform provenance. Remove it from user-controlled input or enforce platform-only creation, update, patch, and deletion before materialization is enabled. ### High: Blocked assignments leave existing authorization active `internal/controller/roleassignment/roleassignment.go:83-106,221-229` When a previously ready template becomes missing or not ready, `block()` only updates status. It does not delete the existing native `ClusterRoleBinding`. If the old `ClusterRole` remains, access remains effective despite the assignment reporting `Blocked`. If the role is deleted, the dangling binding can silently become effective again when a role with the same name appears. The blocked transition must remove the generated binding and clear its status reference. ### High: Blocked assignments do not recover when templates become ready `internal/controller/roleassignment/roleassignment.go:260-265` The controller watches `RoleAssignment` and owned native bindings, but not `RoleTemplate`. An assignment created before its template exists becomes `Blocked`. Creating or reconciling the template later does not enqueue the assignment, and the blocked path has no timed requeue. Add a `RoleTemplate` watch mapping templates to referencing assignments, with tests for both readiness and invalidation transitions. ### High: `forkedFrom` is accepted but not validated `api/v1alpha1/roletemplate_types.go:116-123` `internal/controller/roletemplate/roletemplate.go:150-156` The implementation does not: - Resolve the referenced template. - Verify that it is sealed. - Inherit any rules. - Enforce that forked permissions are a subset. It merely copies the claimed source into an annotation. This creates misleading provenance for arbitrary permissions. Reject `forkedFrom` until containment enforcement exists, or clearly make it non-authoritative. ### Medium: Unsupported propagation modes still report `Ready` `api/v1alpha1/roleassignment_types.go:15-66` `internal/controller/roleassignment/roleassignment.go:164-177` The CRD accepts `SelectedWorkspaces` and `AllWorkspaces`, but the reconciler applies only the local workspace and still reports the assignment phase as `Ready`. A caller can request global propagation and receive overall success even though the requested state was not achieved. Unsupported modes should be rejected or leave the resource non-ready. ### Medium: `capabilityRequirements` is ignored `api/v1alpha1/roletemplate_types.go:125-133` Templates with unmet capability requirements are materialized and marked `Ready`. This makes a security-relevant API field purely decorative. Reject the field until implemented, or evaluate requirements and report a non-ready condition. ### Medium: Removing `sealed` or `forkedFrom` leaves stale metadata `internal/controller/roletemplate/roletemplate.go:204-220` `labelsMatch` and `annotationsMatch` only verify desired entries. Extra existing entries are ignored. Changing `sealed` from true to false does not trigger removal of the existing sealed label. Clearing `forkedFrom` similarly leaves the old annotation behind. Compare and reconcile the complete controller-owned metadata set. ### Medium: `templateRef` is mutable `api/v1alpha1/roleassignment_types.go:71-96` Changing `templateRef` silently changes the granted role. Native Kubernetes bindings deliberately make `roleRef` immutable to prevent this class of privilege change. Add CRD validation making `templateRef` immutable. Users should replace the assignment to grant a different role. ### Testing Gaps Missing regression coverage includes: - Tenant-created `sealed: true`. - Mutation of sealed-template rules. - Permission-ceiling rejection. - Ready-to-blocked cleanup. - Blocked assignment recovery after template creation. - Removal of sealed and fork metadata. - Unimplemented propagation modes. - Unmet capability requirements.
- API simplification: RoleTemplate spec reduced to rules/displayName/description;
  removed sealed, forkedFrom, capabilityRequirements, propagation fields
- API: RoleAssignment spec reduced to immutable templateRef + subjects (User|Group)
- Schema: CEL x-kubernetes-validations for wildcard/escalation rejection on PolicyRule
  and templateRef immutability on RoleAssignment
- Security: synchronous fail-closed validating admission webhook (RoleTemplateValidator,
  RoleAssignmentValidator) with configurable platform permission ceiling, wildcard
  rejection, escalation-verb (bind/escalate/impersonate) rejection, RBAC-resource
  rejection, reserved-name-prefix protection
- Controller defence-in-depth: ceiling validation in RoleTemplate reconciler before
  ClusterRole materialisation; invalid templates enter Error phase without ClusterRole
- Revoke-first ordering: on RoleTemplate mutation, controller deletes dependent CRBs
  before updating ClusterRole rules to eliminate authorisation window
- Blocked assignment cleanup: block() now deletes stale generated CRB and clears
  resolved status fields immediately
- RoleTemplate watch: RoleAssignment controller watches RoleTemplate objects; dependent
  assignments re-enqueue automatically when template readiness changes
- Stale label cleanup: ClusterRole labels/annotations built from scratch each reconcile;
  no sealed/forked-from metadata propagated
- Updated deepcopy: removed PropagationSpec, RoleTemplateReference, CapabilityRequirement
- Generated: CRDs, KCP APIResourceSchemas, Helm chart templates/values aligned
- Helm: ValidatingWebhookConfiguration + webhook Service templates added (fail-closed)
- Examples/docs: updated to simplified API, removed propagation/sealed/forkedFrom examples
- Tests: comprehensive coverage for ceiling allow/deny, wildcard/escalation rejection,
  revoke-first mutation, blocked-CRB deletion, template-watch recovery, idempotent prefix

Ref: IPCEICIS-9690
BLOCKER-1 — isPlatformCaller: replace suffix heuristic with exact full-SA username match.
Added PlatformServiceAccount string field to WebhookConfig; any caller whose
req.UserInfo.Username exactly matches the configured value is treated as the
platform identity. Empty string (default) disables the bypass entirely — no
caller is platform. Wire --platform-service-account CLI flag in main.go; set it
to system:serviceaccount:<ns>:rbac-controller-manager in the Helm Deployment
via webhook.platformServiceAccount values key.

BLOCKER-2 — Denial formatting: replace bare admission.Denied(formatString) with
admission.Denied(fmt.Sprintf(formatString, args...)). Replace json.Marshal itoa
with strconv.Itoa. Add handlers_test.go (14 table-driven tests) covering all
Handle() branches including suffix-spoofing regression, format-string regression,
empty-SA bypass-disabled, and subject-index interpolation verification.

BLOCKER-3 — Webhook TLS provisioning:
- Add deploy/charts/rbac-controller/templates/webhook-cert.yaml: cert-manager
  self-signed Issuer + Certificate (conditional on webhook.tls.certManager.enabled,
  default true); secret name derived from webhookCertSecretName helper.
- Update ValidatingWebhookConfiguration: add cert-manager.io/inject-ca-from
  annotation when certManager.enabled=true so CA-injector keeps caBundle in sync;
  fall back to manual caBundle when certManager disabled.
- Update deployment.yaml: add webhook-certs Secret volumeMount at certDir path,
  pass --webhook-cert-dir and --webhook-port args, and --platform-service-account.
- Add webhookCertSecretName helper to _helpers.tpl; support existingSecret override.
- Update values.yaml: add webhook.tls.certManager.{enabled,duration,renewBefore}
  and webhook.tls.existingSecret; add webhook.platformServiceAccount.

BLOCKER-4 — Revoke-first error propagation: replace l.Error(err, ...) log-and-
continue with return reconcile.Result{}, fmt.Errorf(..., err) so that a
transient CRB deletion failure causes a retry rather than a ClusterRole update
with stale CRBs still in place. Add TestReconcile_RevokeFirstError_BlocksClusterRoleUpdate:
uses interceptor.Funcs to inject a Delete error on ClusterRoleBinding and
asserts (a) reconciler returns a wrapped error, (b) ClusterRole rules are NOT
updated, (c) stale CRB still exists.

testutil: add NewClientWithInterceptor helper (wraps fake.WithInterceptorFuncs).
Test count: 42 → 70 (28 new tests across webhook/handler/roletemplate suites).

Ref: IPCEICIS-9690
BLOCKER — CEL cost budget fix:
- PolicyRule: add MaxItems bounds on all arrays (apiGroups:20, resources:50,
  verbs:20, resourceNames:50); without bounds the Kubernetes CEL cost-budget
  estimator cannot assign finite cost to x-kubernetes-validations rules and
  rejects the CRD/APIResourceSchema with 'CEL cost budget exceeded'
- RoleTemplateSpec.rules: add MaxItems=50
- DisplayName/Description: add MaxLength=256/2048 respectively
- RoleAssignment: subjects MaxItems=100, templateRef MaxLength=253 (DNS limit),
  Subject.Name MaxLength=512
- Regenerate config/crd/*.yaml, config/kcp/*.yaml (all three schemas),
  Helm chart apiresourceschema templates with MaxItems/MaxLength in every layer
- Bump schemaNamePrefix s32df7c63f86e → scel50items001 (KCP schema objects are
  immutable; old schemas orphaned, delete manually after APIExport migration)

Schema regression test — internal/schematest/schema_test.go:
- Reads generated CRD YAML files at test time and asserts maxItems on
  spec.rules, PolicyRule.{apiGroups,resources,verbs,resourceNames}, subjects,
  maxLength on templateRef, displayName, description, and CEL rule count ≥4
- Catches generation drift (controller-gen without markers) and the CEL
  cost-budget regression before reaching the API server

KCP VWC placement fix (observation resolved):
- REMOVE ValidatingWebhookConfiguration from rbac-controller chart
  (management-cluster VWC does NOT protect KCP virtual workspace traffic;
  deploying it there was misleading and non-functional for APIExport resources)
- ADD ValidatingWebhookConfiguration to rbac-controller-kcp chart, deployed in
  the KCP provider workspace (root:controllers:rbac). KCP's
  apis.kcp.io/ValidatingWebhook admission plugin dispatches to VWC objects in
  the provider workspace for virtual workspace requests — this is the correct
  and only effective placement.
- KCP VWC uses url-based clientConfig pointing to the management-cluster
  webhook Service (accessible same-cluster via svc.cluster.local), not
  service-reference (which would resolve in the management-cluster apiserver,
  not KCP). Configurable via webhook.serviceURL in kcp chart values.
- webhook.caBundle must be set manually (cert-manager CA-injection unavailable
  in KCP workspace); instructions added to values.yaml.
- When webhook.serviceURL is empty, the template renders a prominent comment
  warning that admission is inactive rather than a silently-broken VWC.
- rbac-controller chart retains cert-manager cert/service/deployment webhook
  wiring (the server still runs there); only the VWC is moved.

Minor: fix ValidateRoleName doc comment (was 'ValidateRuleName'/'callerIsplatform')

Test evidence:
- go test ./... -race: 68 tests, all PASS
- go build, go vet: clean
- helm lint both charts: 0 failures
- helm template kcp --set webhook.serviceURL=https://...: VWC rendered with
  url + caBundle and failurePolicy:Fail
- helm template kcp (no serviceURL): comment-only output, no broken VWC object
- schema_test.go: all three assertions PASS (maxItems present in CRD+KCP yaml)

Ref: IPCEICIS-9690
VWC bootstrap reconciler (internal/vwcbootstrap):
- Bootstrap.Start() registers as plain manager.Runnable via GetLocalManager().Add()
  (avoids multicluster.Aware requirement for non-cluster-aware work)
- Reads ca.crt from mounted webhook-certs Secret at CAPath
- Creates ValidatingWebhookConfiguration in KCP provider workspace with
  url-based clientConfig (management-cluster service), failurePolicy:Fail,
  CREATE+UPDATE only, ClusterScope, sideEffects:None, timeoutSeconds:10
- Periodic CA rotation detection: re-reads ca.crt every 30s, updates VWC
  caBundle when changed without pod restart
- Ready flag (atomic.Bool): pod notReady until first sync → Service has no
  endpoints → fail-closed on startup (KCP rejects requests while VWC absent)
- FakeClient field for test injection (no rest.Config needed in tests)
- SyncOnce() exported for unit tests
- 7 unit tests: create, CA-rotation update, no-op, missing CA, readiness
  before/after, structural correctness (CREATE+UPDATE, Cluster scope, etc.)

Permission ceiling (internal/ceiling):
- New package ceiling.Config/AllowedRule with ValidateForTenant(rules)
- ErrEmpty sentinel: nil or empty Config → all tenant operations denied
- LoadFromFile/LoadFromBytes for deterministic YAML loading
- 12 unit tests covering nil/empty ceiling, in/out-of-ceiling rules, partial
  violations, verb-level ceiling enforcement

PlatformUsername rename (requirement 5):
- PlatformServiceAccount → PlatformUsername throughout
- Reflects KCP X.509 CN identity, not Kubernetes SA format
- Added IsPlatformCaller() exported method for test and controller use
- Exact match only; empty disables all bypasses
- Chart: platformServiceAccount → platformUsername with correct CN docs

Fail-closed ceiling (requirement 6):
- Empty/nil ceiling → ValidateCeiling() returns ErrEmpty → all non-platform
  tenant RoleTemplate creates/updates denied at webhook and controller
- Webhook: ValidateRulesHard (wildcards/escalation/RBAC, applies to ALL) then
  ValidateCeiling (applies to non-platform callers only)
- Platform identity bypasses ValidateCeiling, never bypasses ValidateRulesHard
- Controller defence-in-depth: calls ValidateRulesHard + ValidateCeiling
  (treats all objects as non-platform — fail-closed from reconciler perspective)
- Ceiling loaded via --ceiling-config flag from chart-generated ConfigMap
- WebhookConfig.Ceiling *ceiling.Config replaces PermissionCeiling []CeilingEntry
- New test: TestRTValidator_Deny_EmptyCeiling_AllTenantsDenied
- New test: TestRTValidator_Deny_RuleOutsideCeiling (non-CEL-blocked rule)
- New test: TestRTValidator_Platform_BypassesCeiling
- New test: TestCeilingDeny_RuleOutsideCeiling, TestCeilingAllow, ErrEmpty

KCP VWC architecture (requirement 3 + chart cleanup):
- Remove Helm-managed ValidatingWebhookConfiguration from rbac-controller-kcp
  chart (Helm VWC replaced by controller-managed bootstrap)
- Remove webhook.serviceURL/caBundle/timeoutSeconds from kcp chart values
  (controller reads ca.crt from mounted Secret, URL from --webhook-service-url)
- Add manage-vwc ClusterRole: get/create/update/patch on
  admissionregistration.k8s.io/validatingwebhookconfigurations (no delete —
  VWC persists for fail-closed behaviour during controller outages)
- Add manage-vwc ClusterRoleBinding for controller identity
- Add vwcName value to kcp chart; must match --vwc-name CLI flag

Management chart (requirement 4):
- Add --webhook-service-url flag (required; controller exits without it)
- Add --vwc-name flag
- Add --platform-username flag (renamed)
- Add --ceiling-config flag when ceiling.rules is set
- Add ceiling.configPath and ceiling.rules values
- Create configmap-ceiling.yaml template (conditional on ceiling.rules)
- Mount ceiling ConfigMap as /etc/rbac-controller volume
- Retain cert-manager Issuer/Certificate, webhook Service, Secret mount
- Update values.yaml: serviceURL REQUIRED, platformUsername with CN docs

Test count: 70 → 89 (19 new tests across ceiling/vwcbootstrap/webhook suites)

Ref: IPCEICIS-9690
Finding 1 — --ceiling-config crash on empty rules:
- deployment.yaml: gate --ceiling-config flag on ceiling.rules (not ceiling.configPath).
  When ceiling.rules=[] (default), no flag is emitted, no ConfigMap is created, no
  volume is mounted → controller starts fail-closed without a missing-file crash.
  Previously configPath was always non-empty so the flag was always emitted even
  when no ConfigMap/volume existed.
- Add 3 Helm template regression tests (internal/charttest/chart_test.go):
  TestHelmTemplate_EmptyCeiling_NoCrash: asserts no --ceiling-config + no volume
  TestHelmTemplate_NonEmptyCeiling_AllPresent: asserts flag + ConfigMap + volume
  TestHelmTemplate_ManageVWC_ClusterRole_ExactRules: asserts no delete/wildcard,
    resourceNames present, correct verbs (get/update/patch/create)

Finding 2 — platform ceiling bypass removed (webhook/reconciler parity):
- handlers.go: remove if !callerIsPlatform guard around ValidateCeiling; the
  ceiling now applies to ALL callers including the platform controller.
  v1alpha1 design: ceiling must cover platform preset rules; an empty ceiling
  denies everyone. This eliminates the incoherence where the webhook admitted
  platform writes that the controller's defence-in-depth would then reject.
- Only retained platform-specific allowance: exact PlatformUsername match
  bypasses the reserved-name-prefix guard (can create edge-connect-rt-* names).
  Hard denials and ceiling apply uniformly.
- policy.go: update package doc and WebhookConfig.Ceiling comment to reflect
  no ceiling bypass; operators must include platform preset rules in the ceiling.
- handlers_test.go: TestRTValidator_Platform_BypassesCeiling renamed to
  TestRTValidator_Platform_AlsoSubjectToCeiling; now asserts DENIED for
  platform rule outside ceiling (parity test). Added
  TestRTValidator_Platform_AllowedWhenRuleInCeiling: platform + in-ceiling rule
  + reserved name → ALLOWED (name bypass still works).

Finding 3 — manage-vwc ClusterRole comment accuracy:
- clusterrole-content.yaml: replace misleading comment with accurate explanation:
  Rule 1 (scoped): get/update/patch on resourceNames=[vwcName] — resourceNames
    scoping works for these verbs.
  Rule 2 (unscoped create): minimum unavoidable — resourceNames cannot filter
    create because the object has no name at create time. Documents risk bound:
    controller only ever creates one named VWC.
  Removed 'create' from the scoped rule (it was duplicated in both rules; the
  scoped rule now has only get/update/patch).

93 tests pass — go test ./... -race clean, gofmt clean, go vet clean,
helm lint both charts clean, all 3 chart regression tests PASS (not SKIP).

Ref: IPCEICIS-9690
Amend four locations that still claimed PlatformUsername bypasses the
permission ceiling — a statement invalidated by f8d9035:

  internal/webhook/handlers.go
    RoleTemplateValidator doc: step 3 now states ceiling applies to ALL
    callers; platform bypasses reserved-name prefix only.

  internal/webhook/policy.go
    WebhookConfig.PlatformUsername: clarify bypass is prefix-guard only;
    hard denials and ceiling are uniform for all callers.
    ValidateCeiling doc: remove stale "Must NOT be called for platform"
    note; replace with accurate "called for ALL callers" statement.

  cmd/rbac-controller/main.go
    Package comment Platform-identity section: replace ceiling-bypass
    claim with correct prefix-only bypass description.
    --platform-username flag help: same correction.

  deploy/charts/rbac-controller/values.yaml
    platformUsername comment: replace ceiling-bypass claim with accurate
    prefix-guard-only description; add note that ceiling/hard denials
    apply uniformly; note platform rules must be in ceiling config.

  internal/webhook/handlers_test.go
    Test comment on TestRTValidator_Platform_AllowedWhenRuleInCeiling:
    replace "not the ceiling bypass" with canonical wording.

All four stale comments now consistently state: exact PlatformUsername
match bypasses reserved-name-prefix guard only; ceiling and hard denials
apply to all callers without exception.

Verification: go test ./... -race PASS (93 tests), go vet PASS,
gofmt PASS, go build PASS, helm lint both charts PASS.

Ref: IPCEICIS-9690
Daniel.Sy changed title from feat: add RoleTemplate and RoleAssignment RBAC controller prototype to feat(rbac): implement hardened v1alpha1 RoleTemplate/RoleAssignment with admission, ceiling, and VWC bootstrap 2026-07-24 21:46:39 +00:00
Daniel.Sy force-pushed ipceicis-9690-rbac-impl-local from 2b59caa016 to c803d29e09
Some checks failed
ci-main / test (pull_request) Failing after 2m33s
2026-07-24 21:59:28 +00:00
Compare
Daniel.Sy force-pushed ipceicis-9690-rbac-impl-local from c803d29e09
Some checks failed
ci-main / test (pull_request) Failing after 2m33s
to fd17b13fed
Some checks failed
ci-main / test (pull_request) Failing after 2m3s
ci-charts / charts (push) Successful in 9s
ci / goreleaser (push) Failing after 2m11s
2026-07-27 10:44:02 +00:00
Compare
fix(rbac): 🔒 requeue on stale-CRB delete failure; extract naming pkg and deduplicate constants
Some checks failed
ci-main / test (pull_request) Failing after 2m0s
44749a8745
Security: block() in roleassignment reconciler now returns an error (and
triggers work-queue requeue) when deletion of the stale generated
ClusterRoleBinding fails.  Previously the error was logged and the controller
patched status Blocked while stale access might still be active.

Refactors (no behaviour change outside the security fix):
- internal/naming: new package with ClusterRoleName, AssignmentCRBName,
  BindingCRBName; replaces three duplicated sha256-truncation helpers in
  roletemplate, roleassignment, and clusterrolebinding controllers.
- labelsMatch subset check replaced with maps.Equal (exact equality) in
  roleassignment and clusterrolebinding so stale managed labels are removed.
- Local ConditionSynced, ConditionReady, ConditionTemplateResolved,
  LabelManagedBy, LabelManagedByValue, LabelSourceKind, LabelSourceName
  constants replaced with the canonical api/v1alpha1 constants; local
  setCondition wrappers retained (per-type, no external dependency added).

Tests added:
- TestReconcile_BlockDeleteError_RequeuesAndLeavesStale (security invariant)
- TestReconcile_StaleLabel_Removed (maps.Equal stale-label cleanup)
- internal/naming: length, boundary, collision, determinism, regression tests

No controller-util, kcpws, mctest, kcppath, or PersistFailure dependency
added.  Rationale: controller-util brings transitive deps and cross-cutting
error-handling conventions not yet adopted across the codebase; the local
setCondition helpers are equivalent and the security fix does not require the
utility library.

Refs: IPCEICIS-9690
chore(deps): 📦 promote gopkg.in/yaml.v3 and sigs.k8s.io/yaml to direct deps
All checks were successful
ci-main / test (pull_request) Successful in 4m9s
495cee6f33
go mod tidy promotes both packages from indirect to direct because
internal/ceiling, internal/charttest, and internal/schematest import
them explicitly.  CI go-mod-check was failing because the go.mod
declared them as indirect.

Closes IPCEICIS-9690
All checks were successful
ci-main / test (pull_request) Successful in 4m9s
This pull request has changes conflicting with the target branch.
  • go.mod
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin ipceicis-9690-rbac-impl-local:ipceicis-9690-rbac-impl-local
git switch ipceicis-9690-rbac-impl-local

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff ipceicis-9690-rbac-impl-local
git switch ipceicis-9690-rbac-impl-local
git rebase main
git switch main
git merge --ff-only ipceicis-9690-rbac-impl-local
git switch ipceicis-9690-rbac-impl-local
git rebase main
git switch main
git merge --no-ff ipceicis-9690-rbac-impl-local
git switch main
git merge --squash ipceicis-9690-rbac-impl-local
git switch main
git merge --ff-only ipceicis-9690-rbac-impl-local
git switch main
git merge ipceicis-9690-rbac-impl-local
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
DevFW-CICD/rbac-controller-manager!11
No description provided.