Skip to content

Role-scoped privilege reconciliation: privilege_reconciliation: additive for legacy logins under a global adopt policy #159

Description

@hardbyte

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:

  • lifecycle external — pgroles must not create, alter, drop, or password-manage it (external: true covers this, Externally-managed roles declared in roles: get destructive ALTER/REVOKE under authoritative mode #123);
  • 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 manifest
roles:
  - name: pgloader_pg
    external: true
    privilege_reconciliation: additive   # default: inherit the global mode
# CRD spelling
roles:
  - name: pgloader_pg
    external: true
    privilegeReconciliation: additive

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Related: #123 (introduced external: true, explicitly leaving direct privileges managed), #94 (additive-mode scope for pre-existing roles).

https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions