You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Brownfield adoption regularly hits one specific role shape: a legacy login (say pgloader_pg) that holds years of hand-issued direct grants on schemas pgroles is taking over. The right end state for that role during transition is:
memberships managed — adding it to managed groups should converge normally (already works: filter_external_role_changes only exempts memberships granted from an external role, diff.rs:237-239);
direct privileges left alone — its undeclared legacy grants must survive until they are migrated deliberately.
The third point has no expression today. Revoke and RevokeDefaultPrivilege fall through the external-role filter (is_external_role_change matches lifecycle, password, and from-role membership changes; everything else returns false, diff.rs:230-246). Under a global adopt policy — which exists precisely for brownfield and keeps revokes — every reconcile revokes the legacy role's undeclared grants on managed objects.
The available workarounds all give up something important:
Workaround
Cost
Global reconciliationMode: additive
Loses revoke convergence for every managed role — the whole policy degrades to grant-only for one legacy login
Declare every legacy grant in the manifest
Bulk-imports unreviewed access into the desired state; the manifest stops meaning "what we intend"
Leave the role out of the manifest entirely
Its grants on managed objects are still revoked (mode filtering is grantee-blind), and managed memberships for it can't be declared
Proposal
A per-role override that makes privilege reconciliation additive for one grantee while the global mode stays adopt (or authoritative):
# CLI manifestroles:
- name: pgloader_pgexternal: trueprivilege_reconciliation: additive # default: inherit the global mode
The override is a grantee-scoped post-filter, the same shape as filter_external_role_changes. Both revoke variants already carry the grantee (Revoke.role, RevokeDefaultPrivilege.grantee), so the filter is mechanically simple. With privilege_reconciliation: additive on role R:
Filtered:Revoke where role == R; RevokeDefaultPrivilege where grantee == R.
Unchanged: declared Grant / SetDefaultPrivilege to R still apply — declared grants converge, undeclared ones survive. Memberships (both directions) keep their existing semantics. Lifecycle stays governed by external. Schema ownership and owner-side default privileges are untouched.
Interaction with the global mode is loosen-only — the matrix:
Global mode
Role override
Effective for this grantee
authoritative / adopt
(unset)
revokes applied (today's behaviour)
authoritative / adopt
additive
revokes suppressed
additive
additive
no-op (already suppressed globally)
There is deliberately no per-role value that tightens a looser global mode (e.g. authoritative under global additive); that inversion would make the global mode unreadable as an upper bound on destructiveness.
Drift visibility
Suppressed changes today vanish silently (the mode filters and the external filter both drop changes with no trace; retirements under additive/adopt are the existing sharp edge of this). A per-role override is an explicit exception, so it should be observable:
the plan/change summary records a suppressed-revoke count attributed to the override;
diff output annotates the suppression rather than showing clean convergence.
This is what makes the feature a transition tool: the suppressed count is the migration backlog, visible on every reconcile, and reaching zero is the signal to remove the override.
Design questions
unmanaged as a second value? The transition doc floats additive/unmanaged. Suggest deferring unmanaged (skip grants and revokes for the grantee): declaring grants for a role whose privileges are unmanaged is a contradiction better rejected at validation, and no concrete use case needs it yet. The enum leaves room.
Couple to external: true? Suggest no — a still-managed legacy login mid-migration benefits identically. external and privilege_reconciliation compose but neither implies the other.
Wildcard normalization. Wildcard-grant satisfaction is computed from the desired expansion, so suppressed revokes shouldn't affect it — but the no-flapping property test should cover a role with the override holding undeclared grants in a wildcard-managed schema.
Retirement. Retiring a role that carries the override: retirement revokes are arguably deliberate removal and could bypass the override — or the combination is rejected at validation. Needs a decision; silently suppressing an explicit retirement would repeat the existing retirements-under-additive trap.
generate / export. Should brownfield export emit the override for roles it detects as… probably not — nothing detectable implies it. Omit.
Implementation sketch
manifest.rs: RoleDefinition.privilege_reconciliation: Option<RolePrivilegeReconciliation> (serde default, skip_serializing_if), validation for the retirement interaction (Q4).
diff.rs: new pass filter_role_privilege_overrides(changes, roles) beside filter_external_role_changes, called from the shared pipeline — both the CLI's four call sites and reconciler.rs already chain the filters, so one insertion point per pipeline. Returns the suppressed count for the summary.
crd.rs: mirror field on RoleSpec; regenerate all four committed CRD copies (crdgen --output-dir + the two k8s/ copies; check-crd-drift.sh enforces).
Summary/diff plumbing for the suppressed-revoke count.
Docs: manifest reference (new field, the loosen-only matrix), limitations page (suppressed drift is invisible to --exit-code unless surfaced), and a transition recipe in the adoption guide: adopt globally → external: true + privilege_reconciliation: additive on the legacy role → migrate grants into the manifest as they're reviewed → drop the override when the suppressed count reaches zero.
Tests: unit (filter matrix incl. loosen-only, grantee keying for both revoke variants), property (override never changes the grant set; suppressed set is exactly the undeclared-revoke set for that grantee), integration (undeclared grants survive reconcile under adopt; declared grants still converge; wildcard no-flap per Q3).
Non-goals
Per-role override tightening the global mode.
Object- or schema-scoped privilege exceptions (this is grantee-scoped only).
Any change to membership or lifecycle semantics — those remain external's job.
Problem
Brownfield adoption regularly hits one specific role shape: a legacy login (say
pgloader_pg) that holds years of hand-issued direct grants on schemas pgroles is taking over. The right end state for that role during transition is:external: truecovers this, Externally-managed roles declared inroles:get destructive ALTER/REVOKE under authoritative mode #123);filter_external_role_changesonly exempts memberships granted from an external role,diff.rs:237-239);The third point has no expression today.
RevokeandRevokeDefaultPrivilegefall through the external-role filter (is_external_role_changematches lifecycle, password, and from-role membership changes; everything else returnsfalse,diff.rs:230-246). Under a globaladoptpolicy — which exists precisely for brownfield and keeps revokes — every reconcile revokes the legacy role's undeclared grants on managed objects.The available workarounds all give up something important:
reconciliationMode: additiveProposal
A per-role override that makes privilege reconciliation additive for one grantee while the global mode stays
adopt(orauthoritative):Semantics
The override is a grantee-scoped post-filter, the same shape as
filter_external_role_changes. Both revoke variants already carry the grantee (Revoke.role,RevokeDefaultPrivilege.grantee), so the filter is mechanically simple. Withprivilege_reconciliation: additiveon roleR:Revokewhererole == R;RevokeDefaultPrivilegewheregrantee == R.Grant/SetDefaultPrivilegetoRstill apply — declared grants converge, undeclared ones survive. Memberships (both directions) keep their existing semantics. Lifecycle stays governed byexternal. Schema ownership and owner-side default privileges are untouched.Interaction with the global mode is loosen-only — the matrix:
authoritative/adoptauthoritative/adoptadditiveadditiveadditiveThere is deliberately no per-role value that tightens a looser global mode (e.g.
authoritativeunder globaladditive); that inversion would make the global mode unreadable as an upper bound on destructiveness.Drift visibility
Suppressed changes today vanish silently (the mode filters and the external filter both drop changes with no trace; retirements under
additive/adoptare the existing sharp edge of this). A per-role override is an explicit exception, so it should be observable:diffoutput annotates the suppression rather than showing clean convergence.This is what makes the feature a transition tool: the suppressed count is the migration backlog, visible on every reconcile, and reaching zero is the signal to remove the override.
Design questions
unmanagedas a second value? The transition doc floatsadditive/unmanaged. Suggest deferringunmanaged(skip grants and revokes for the grantee): declaring grants for a role whose privileges are unmanaged is a contradiction better rejected at validation, and no concrete use case needs it yet. The enum leaves room.external: true? Suggest no — a still-managed legacy login mid-migration benefits identically.externalandprivilege_reconciliationcompose but neither implies the other.generate/ export. Should brownfield export emit the override for roles it detects as… probably not — nothing detectable implies it. Omit.Implementation sketch
manifest.rs:RoleDefinition.privilege_reconciliation: Option<RolePrivilegeReconciliation>(serde default,skip_serializing_if), validation for the retirement interaction (Q4).diff.rs: new passfilter_role_privilege_overrides(changes, roles)besidefilter_external_role_changes, called from the shared pipeline — both the CLI's four call sites andreconciler.rsalready chain the filters, so one insertion point per pipeline. Returns the suppressed count for the summary.crd.rs: mirror field onRoleSpec; regenerate all four committed CRD copies (crdgen --output-dir+ the twok8s/copies;check-crd-drift.shenforces).--exit-codeunless surfaced), and a transition recipe in the adoption guide:adoptglobally →external: true+privilege_reconciliation: additiveon the legacy role → migrate grants into the manifest as they're reviewed → drop the override when the suppressed count reaches zero.adopt; declared grants still converge; wildcard no-flap per Q3).Non-goals
external's job.Related: #123 (introduced
external: true, explicitly leaving direct privileges managed), #94 (additive-mode scope for pre-existing roles).https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4