Skip to content

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 27 Aug 02:48
· 48 commits to main since this release
Immutable release. Only release title and notes can be modified.
6859c79

Highlights

Revokes now target each grant's recorded grantor, so delegated grants and foreign-granted memberships finally converge; including for non-superuser executors; PostgresPolicyCandidate brings a propose–review–promote workflow for policy changes; memberships in PostgreSQL predefined (pg_*) and externally managed roles are declaratively manageable; and brownfield adoption is safer with preserve_undeclared_grants, protected owner privileges, and explicit approval for schema ownership transfers.

Upgrade notes

  • The approval digest encoding is now pgroles.io/approval-effect/v3, binding the target database's identity and default-privilege scopes into every approval. On the first reconcile after upgrading, every open plan is superseded and replaced by an equivalent plan under v3; recorded decisions do not carry over, so open plans need one fresh approval. Nothing executes in the meantime. Deliberately, a pg_upgrade (fresh system_identifier) or a blue-green cutover also invalidates any approval open across it — re-approve the fresh plan afterwards. (#180)
  • Policy content now has explicit size limits. Identifiers are capped at 63 characters and 63 bytes (where PostgreSQL silently truncates), and every list and map has a bound — 1024 roles, 4096 grants, 2048 memberships, and so on (full table in the manifest reference). A policy exceeding a limit is rejected on its next apply with a field-level error; each limit sits at least 20× above the largest policy known to run in production. (#182, #173)
  • Bundle plan JSON is now pgroles.bundle_plan.v2, and diff --format json changes shape the same way: default-privilege changes carry a tagged scope ({"type": "schema", "schema": "app"} or {"type": "global"}) in place of the bare schema string. Migration: read scope.schema where you read schema, and handle scope.type == "global" entries having no schema at all. The diff JSON output remains unversioned — treat its shape as unstable and pin the pgroles version if you parse it.
  • spec.mode: plan is renamed to spec.mode: observe. The old value keeps working with identical behaviour through a deprecation window (a ModeValueDeprecated condition, an operator-log warning, and the pgroles.deprecated.mode_plan metric); a future release removes it. Update manifests at your convenience.
  • password_valid_until must be a UTC whole-second timestamp (YYYY-MM-DDTHH:MM:SSZ). An offset or fractional-second timestamp is now a field-level validation error — it never converged before (see Fixed below).
  • Declared memberships in external: true roles now take effect. Previous releases accepted these declarations but did not include them in plans. Review the next plan before applying it; pgroles may now grant the declared memberships. Undeclared memberships remain untouched unless the membership rule sets exclusive: true. (#200)
  • Adopt-mode schema ownership transfers require explicit approval. If an adopt plan would change a schema's owner, pass --allow-schema-owner-transfers to pgroles apply or set spec.allow_schema_owner_transfers: true on an apply-mode PostgresPolicy. Without the opt-in, apply stops before making changes and the operator reports OwnerTransferBlocked; diff and observe mode still show the proposed transfer. (#201)
  • Database grants now name the connected database explicitly. object.name is required for type: database, and inspection rejects a name that differs from current_database() instead of planning SQL against an ACL it did not inspect. Operator policies report InvalidDatabaseTarget for a mismatch.

Added

  • Revokes target each grant's recorded grantor, so delegated grants converge. PostgreSQL's REVOKE removes only grants attributed to the revoker (a superuser acts as the object owner, or the bootstrap superuser for role memberships), so a privilege granted onward by a delegate — or a membership edge granted by another admin — survived every plain revoke, superuser executors included, and re-planned forever. Inspection now records the grantor of every ACL entry and membership edge (multiple edges per role/member pair are aggregated for diffing and revoked individually), and rendered revokes act as that grantor: SET ROLE grantor; REVOKE ...; for object privileges (restoring the connection's configured execution role afterwards), REVOKE ... GRANTED BY grantor for memberships. Delegated grants to PUBLIC converge the same way. The plan preflight verifies per grantor that the executor can become it and reports what is missing (UnsatisfiableRevoke: diff warns, apply blocks), checking authority phase by phase against the plan's own membership changes so an executable plan is not falsely rejected. Object-ACL grantor targeting works on every supported version; membership targeting uses the per-edge grantor PostgreSQL 16 records, and older servers keep plain membership revokes. Grantor-less plan digests and JSON output are unchanged. See limitations.
  • PostgresPolicyCandidate: propose, review, and promote policy content without touching the live policy. A candidate points at an existing PostgresPolicy and carries only proposed content; execution settings come from the parent. Candidates are immutable once created — the version reviewed is exactly the version approved; revise by filing a successor with replaces. Each open candidate gets its own PostgresPolicyPlan, computed with the parent's credentials and locks against post-enforcement state, reviewed and decided like any other plan — and candidate planning never writes: no SQL in any state, no generated-password Secrets. Merging an approved candidate's content makes its reviewed plan the one that executes: the operator adopts the candidate's plan (never minting an approval of its own) and executes only if the recomputed effects still match the approved digest; anything else is reported on the candidate (PromotedWithoutApproval, PromotionDigestMismatch, PromotionNotExecuted) rather than ignored. See the candidate docs for the kubectl and CI recipes. (#182, #173)
  • pgroles candidate covers the review side of the candidate workflow. create files a candidate from an ordinary manifest, validated locally through the same path as pgroles validate and created with generateName so concurrent proposals never collide; list, status, and diff show every candidate's phase and digest, expand one down to its plan's decision and promotion state, and print the SQL approving it would run. Deciding a plan stays kubectl-shaped on purpose: it is a status write gated by admission, so decidedBy records an authenticated identity. (#189)
  • Approvals are bound to the database they were reviewed against. Every plan records the server's physical identity (pg_control_system().system_identifier) and a logical fingerprint of the resolved host, port, and database; if either changes between approval and execution, the plan is superseded instead of executed. Set spec.connection.requirePhysicalIdentity: true to stop reconciliation entirely (TargetIdentityBlocked) when the identifier cannot be read. URL-mode connections bind the endpoint they resolve to, not only the Secret name — editing the URL inside a referenced Secret is no longer invisible to an open approval, while credential rotation still does not invalidate one. (#180, #185)
  • Manage memberships in PostgreSQL predefined roles and external: true roles. Membership rules can grant roles such as pg_monitor directly, without adding them to roles. By default pgroles manages only the declared members; set exclusive: true to revoke other ordinary members as well. PostgreSQL's built-in pg_* hierarchy is always preserved. generate exports predefined-role memberships, inspect reports them, and the preflight checks the executor's ADMIN OPTION and the role's availability on the connected PostgreSQL version. (#200)
  • Preserve existing grants while adopting a role. Set preserve_undeclared_grants: true on a role to add its declared privileges without removing other in-scope grants; explicit ensure: absent rules still revoke access. Remove the setting once the role's complete access has been captured in policy. (#201)
  • Owner-wide default privileges. default_privileges entries accept scope: {type: global} beside the schema: shorthand, emitting ALTER DEFAULT PRIVILEGES FOR ROLE ... with no IN SCHEMA clause — the layer that applies to every schema an owner creates objects in. Inspection reads the global layer for exactly the declared (owner, object type) pairs, reporting the effective default, and excludes owner self-entries so authoritative mode never revokes an owner's own default. See default privileges.
  • ensure: absent and a typed PUBLIC grantee. Grant and default-privilege entries accept ensure: absent, which revokes a privilege where it is held, and role: PUBLIC addresses the pseudo-role — closing the gap where PostgreSQL's built-in EXECUTE for PUBLIC on every function could never be taken away declaratively. Inspection reports PUBLIC's effective privileges, synthesizing acldefault(...) where the ACL is still NULL. PUBLIC is reconciled only where a rule names it, in every mode: unmentioned PUBLIC privileges are never revoked, and deleting a present PUBLIC rule does not revoke it — switch the rule to ensure: absent. additive ignores absence assertions with a warning; profiles reject them by name. See grants.
  • Persistent operator plan warnings. PostgresPolicy.status.plan_warnings records warnings such as an undeclared default_owner or a pending adopt-mode schema ownership transfer, so they remain visible after reconciliation logs expire. (#201)
  • Interactive PostgreSQL access guide. The documentation now includes a browser-based course covering roles, ownership, grants, default privileges, drift, offboarding, and security review, with runnable exercises that need no local PostgreSQL. (#197)

Changed

  • Plan retention is bounded per phase, so replan churn no longer evicts the record of what ran. The bounds are Applied 25 (never fewer than 30 days' worth, hard ceiling 200, measured by status.appliedAt), Failed and Rejected 10 shared, Superseded 3; live phases are never evicted, and pgroles.io/keep=true still exempts a plan. Each bound is operator-level configuration (PLAN_RETENTION_* environment variables, replacing the unusable max_plans parameter); an invalid value refuses operator startup. The same bounds govern terminal-candidate pruning, and keep on a candidate or its plan exempts the pair. (#194)
  • Planning every open candidate costs one database inspection instead of one each. The operator reads the database once over the union of the candidates' scopes and derives each candidate's own scoped inspection in memory — each candidate's wildcard expansion and diagnostics stay entirely its own, spec.target overrides still inspect for themselves, and a shared-read failure falls back per candidate so a proposal can never break enforcement. pgroles.candidate.inspections and pgroles.candidate.planning.duration report the cost. (#191)
  • Open candidates are bounded by a budget and a TTL. At most 32 open candidates per policy are planned per pass (oldest first, so a CI loop cannot evict proposals under review); the rest report CandidateBudgetExceeded until older ones are decided or expire — nothing is deleted. An open candidate nobody decides within 14 days becomes Superseded=True, reason=Expired. pgroles.io/keep=true exempts a candidate from the TTL but not the budget. (#191)
  • Generated password Secrets are created when the approved plan executes, not when it is proposed, so a rejected or never-approved plan no longer leaves a credential in the cluster. Deleting a generated Secret still rotates the password on the next apply — with a GeneratedSecretMissing warning Event first instead of rotating silently. (#181, #174)
  • spec.schemas, spec.roles, and spec.retirements are now map-lists (keyed by name), so server-side apply merges entries instead of replacing whole lists, and duplicate keys are rejected at kubectl apply time. memberships, grants, and default_privileges stay plain arrays. (#126)

Fixed

  • The operator no longer re-applies a non-convergent plan several times a second. A plan whose SQL succeeds without changing inspected state re-applied about once a second under approval: auto, because each replan minted a fresh plan and every plan status write woke the controller. The reconciler now detects that identical effects were just applied and still diff, reports Ready=False with reason NonConvergentPlan, and retries at the policy interval.
  • password_valid_until now converges. Offsets and fractional seconds could never compare equal to the inspected UTC whole-second rendering, and VALID UNTIL 'infinity'/'-infinity' inspected as an empty string — each re-planned an ALTER ROLE on every run. infinity now inspects as "no expiration", -infinity stays distinguishable so one apply repairs it, and validation accepts exactly the convergent form.
  • Never revoke an object's privileges from its owner. pgroles previously interpreted PostgreSQL's implicit owner privileges as ordinary grants and could plan revocations against the owner after another ACL was added. Owner access is now protected across every managed object type, including wildcard rules, and generated manifests omit these implicit privileges. (#201)
  • The Kyverno plan-decision policy no longer identifies the operator by a hardcoded ServiceAccount name — decision rules exempt callers holding the logical manage verb on the parent PostgresPolicy, checked with a SubjectAccessReview, so one policy covers any operator install and the standing bypass for anyone able to impersonate the hardcoded account is gone. Every newly terminal decision still records the authenticated identity; under approval: auto, decidedBy names the operator's ServiceAccount. (#187, #179)
  • A superseded plan names why it was superseded (effects changed, effects vanished, replaced, or the target moved) instead of always claiming the database changed, and superseding no longer tries to rewrite the decision recorded on the plan — the reviewer's record stays exactly as they left it. (#184, #185)
  • Plan lifecycle robustness: a crash between retiring a plan and creating its replacement can no longer leave a policy with nothing actionable; status.current_plan_ref is cleared before its plan is retired; a mode: observe policy retires its pending plan when drift disappears out of band; and a change set waiting out its failure-retry window reports PlanFailedRetryBackoff instead of "awaiting approval". (#185)
  • The plan approval documentation describes the mechanism that exists — a status-subresource decision with decidedBy — with working kubectl commands; kubectl get pgplan -o wide shows the digest a decision actually binds; and the operator's RBAC covers the candidate patch and delete it performs. (#182, #184)

Full Changelog: v0.9.0...v0.10.0