v0.10.0-alpha.1
Pre-releaseAdded
-
PostgresPolicyCandidate: propose and review policy content without touching the live policy. A candidate points at an existingPostgresPolicyand carries only proposed content — roles, grants, memberships. Everything about execution (interval, mode, approval and, unlessspec.targetoverrides it for a preview, the connection) comes from the policy it points at. Once created, a candidate cannot be edited — the API server rejects the write — so the version reviewed is exactly the version approved. To revise a proposal, file a successor:apiVersion: pgroles.io/v1alpha1 kind: PostgresPolicyCandidate metadata: generateName: orders-change- spec: policyRef: name: orders replaces: orders-change-x7k2p # marks the earlier draft superseded content: roles: - name: reporting_reader login: true
-
Candidates are planned inside the parent policy's reconcile. Each open candidate gets its own
PostgresPolicyPlan, computed with the parent's credentials and locks against post-enforcement database state, and reviewed and decided exactly like any other plan. Candidate planning never writes: no SQL in any state, and no generated-password Secrets. While the parent is failing or has a plan of its own awaiting a decision, candidates wait withReady=False, reason=BlockedByActivePolicy; an active ephemeral grant that touches a candidate's effects sends its plan back for fresh review withOverlayOverlap. (#182, #173) -
Promotion: merging an approved candidate's content makes its reviewed plan the one that executes. When a policy's content digest matches an approved open candidate, the operator adopts that candidate's plan — it never mints an approval of its own — and executes only if the effects recomputed under the lock still match the digest that was approved. Anything that is not a clean promotion is reported on the candidate rather than ignored: merged without approval (
PromotedWithoutApproval, the ordinary manual flow takes over), content edited after approval (PromotionDigestMismatch, nothing executes and the message says the merged spec is not being enforced), or a parent inmode: observe(PromotionNotExecuted). The candidate docs give thekubectland CI recipes for the whole flow. (#182, #173) -
pgroles candidatecovers the review side of the candidate workflow, so proposing and reading a change no longer means hand-rolling jsonpath.createfiles a candidate from the ordinary manifest your PR promotes — validated locally through the same path aspgroles validate, so a proposal the API server would reject on size bounds fails on your machine with the same field-level message, and created withgenerateNameso two people filing against one policy never collide.listshows every candidate for a policy with its phase, digest, plan and condition reasons;statusexpands one of them down to its plan's decision, who made it, whether it is still current, and what promotion had to say;diffprints the SQL approving it would run, reading the gzipped ConfigMap when the plan is too large to inline. Each command fails with a specific reason rather than printing something misreadable — a plan that stores only a truncated preview is an error, not a short diff. Deciding a plan stayskubectl-shaped on purpose: it is a status write gated by admission sodecidedByrecords an authenticated identity, and a CLI verb would blur who authenticated it. (#189) -
Approvals are bound to the database they were reviewed against, not just the Secret that reaches it. Every plan records the server's physical identity (
pg_control_system().system_identifier, the storage lineage) and a logical fingerprint of the resolved host, port and database, and both are part of the approval digest. If either changes between approval and execution — or the physical identifier was readable at approval and is not at execution — the plan is superseded instead of executed. Setspec.connection.requirePhysicalIdentity: trueto stop reconciliation entirely (TargetIdentityBlocked) when the identifier cannot be read, e.g. on engines that only speak the PostgreSQL protocol. (#180, #173) -
Owner-wide default privileges.
default_privilegesentries acceptscope: {type: global}beside the existingschema:shorthand, emittingALTER DEFAULT PRIVILEGES FOR ROLE ...with noIN SCHEMAclause. PostgreSQL keeps default privileges in two layers, and only the global one applies to every schema an owner creates objects in — including schemas no policy manages. Inspection reads the global layer for exactly the(owner, object type)pairs a manifest declares, reporting the effective default so a database with no explicitpg_default_aclrow still compares against what PostgreSQL will apply. Owner self-entries are excluded, because everyALTER DEFAULT PRIVILEGESmaterializes the owner's implicit self-grant into the stored row and reporting it would make authoritative mode revoke the owner's own default on the next reconcile. Global changes are counted on their own line indiffoutput, and in a bundle only the document owning the owner role may declare them. See default privileges. -
ensure: absentand a typedPUBLICgrantee. PostgreSQL grantsEXECUTEon every function toPUBLICwithout writing an ACL entry, so no combination of positive grants could take it away — aSECURITY DEFINERroutine stayed callable by every role. Grant entries and default-privilege entries now acceptensure: absent, which revokes a privilege where it is held, androle: PUBLICaddresses the pseudo-role (rendered unquoted, never as the identifier"PUBLIC"). Inspection reports PUBLIC's effective privileges, synthesizingacldefault(...)where the ACL is still NULL, so a fresh database plans the revoke it needs. Pair an object-level absence rule with a global default-privilege one to cover both today's objects and tomorrow's. PUBLIC is reconciled only where a rule names it, in every mode: a PUBLIC privilege no rule mentions is never revoked, and deleting apresentPUBLIC rule does not revoke it — switch the rule toensure: absent.additiveignores absence assertions with a warning, since it never revokes;adoptandauthoritativeapply them. A profile is an additive template, so a profile grant or default privilege that setsensure: absentis rejected by name instead of expanding to its opposite. Preflight warns ondiffand dry runs, and blocks a real apply, when the executor cannot act as a default-privilege owner or cannot revoke on objects it does not own — a PUBLIC revoke without that authority silently changes nothing and would otherwise re-plan forever. See grants and default privileges.
Changed
-
Plan retention is bounded per phase, so replan churn no longer evicts the record of what ran. Terminal plans were trimmed as one pool of 10 by creation time.
Supersededis generated churn — every replan supersedes its predecessor — so on an active policy it filled the pool and deleted theAppliedplans, which are the audit record of what actually executed against the database. The least informative state was evicting the most informative one. The bounds are nowApplied25 (never fewer than 30 days' worth, hard ceiling 200),FailedandRejected10 shared,Superseded3;Pending,ApprovedandApplyingare live and never evicted. The age floor makes the retained span a stated period rather than a function of how often a policy applies, and the ceiling stops that promise becoming unbounded growth.pgroles.io/keep=truestill exempts a plan from every bound. Each bound is operator-level configuration —PLAN_RETENTION_APPLIED,PLAN_RETENTION_APPLIED_MIN_AGE,PLAN_RETENTION_APPLIED_CEILING,PLAN_RETENTION_DECIDED,PLAN_RETENTION_SUPERSEDEDon the operator environment, replacing themax_plansparameter that nothing could ever set — and an invalid value refuses operator startup with the variable named. Deliberately not aPostgresPolicyfield: retention caps object growth in the cluster, and the per-object need is what thekeeplabel is for. TheAppliedbounds measure — and order — bystatus.appliedAt, not object creation, so a plan that waited on a reviewer is not already outside its floor the moment it executes. They also govern terminal-candidate pruning: deleting a candidate cascades to the plan it owns, so a promoted candidate owning anAppliedplan is held to theAppliedbounds instead of the flat terminal-candidate bound, andpgroles.io/keep=trueon either the candidate or its plan exempts the pair. (#194) -
Bundle plan JSON is now
pgroles.bundle_plan.v2. Default-privilege changes and their ownership keys carry a taggedscope({"type": "schema", "schema": "app"}or{"type": "global"}) in place of the bareschemastring, which could not express a global rule. Migration: readscope.schemawhere you readschema, and handlescope.type == "global"entries having no schema at all. -
diff --format jsoncarries the same taggedscopeon default-privilege changes. Unlike bundle output it has noschema_versionfield to bump, so nothing announces the change in the payload itself. Migration: the same one as above — readscope.schemawhere you readschema, and handlescope.type == "global"entries having no schema. This output is a bare array of changes and stays unversioned for now, so treat its shape as unstable and pin the pgroles version if you parse it. -
Database grants now name the connected database explicitly.
object.nameis required fortype: database, and inspection rejects a name that differs fromcurrent_database()instead of planning SQL against an ACL it did not inspect. Operator policies reportInvalidDatabaseTargetfor a mismatch. -
spec.mode: planis renamed tospec.mode: observe, with a deprecation window. "Plan" now names exactly one thing, thePostgresPolicyPlanresource; theApprovalIgnoredreasonPlanModeNeverExecutesis nowObserveModeNeverExecutes. The old value keeps working:mode: planstays an accepted schema value with identical behaviour, so a GitOps controller re-applying an existing manifest is unaffected by the upgrade. A policy using it reports aModeValueDeprecatedcondition, warns in the operator log, and counts towardpgroles.deprecated.mode_plan.
Upgrade: changemode: plantomode: observein your manifests at your convenience — a future release removes theplanvalue, and that removal will be the breaking change. -
BREAKING: policy content now has explicit size limits. Identifiers (role, schema, owner, member names) are capped at 63 characters and 63 bytes — the point past which 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). The bounds apply to
PostgresPolicy, to candidates, and topgroles validatealike, and they are what makes candidate immutability enforceable by the API server.
Upgrade: a policy exceeding a limit is rejected on its next apply with a field-level error. Each limit sits at least 20× above the corresponding count in the largest policy known to run in production; previously the same policy would eventually have hit an opaqueetcdserver: request is too large. (#182, #173) -
BREAKING: the approval digest encoding is now
pgroles.io/approval-effect/v3. v2 binds the target identity above, and v3 additionally carries a default-privilege rule's scope as a taggedscopevalue instead of a bareschemastring, which could not express an owner-wide rule.
Upgrade: on the first reconcile after upgrading, every open plan is superseded and replaced by an equivalent plan under v3, and recorded decisions do not carry over — open plans need one fresh approval. Nothing executes in the meantime. Deliberately, apg_upgrade(freshsystem_identifier) or a blue-green cutover also moves the identity and invalidates any approval open across it; re-approve the fresh plan afterwards. (#180) -
URL-mode connections bind the endpoint they resolve to, not only the Secret name and key — editing the URL inside a referenced Secret is no longer invisible to an open approval. Credentials stay excluded, so password and token rotation still do not invalidate approvals. (#180, #185)
-
Generated password Secrets are created when the approved plan executes, not when it is proposed. A plan that is rejected or never approved no longer leaves a credential in the cluster. Existing Secrets are read and reused, and
approval: autobehaves as before. Deleting a generated Secret still rotates the password on the next apply — the policy now warns first with aGeneratedSecretMissingEvent instead of rotating silently. (#181, #174) -
Planning every open candidate now costs one database inspection instead of one each. Candidate plans are computed inside the parent policy's reconcile, holding the parent's advisory lock, so the cost of reviewing proposals used to be charged to enforcement of the live policy: ten open candidates meant ten full inspections before the policy itself could be reconciled. The operator now computes each candidate's inspection scope from its content, reads the database once over the union of those scopes, and derives each candidate's own scoped inspection from that read in memory. Deriving is not sharing the policy's answer — a candidate's scope, wildcard expansion and diagnostics are still entirely its own, and a scope the shared read does not cover is refused rather than answered narrowly. Candidates with a
spec.targetoverride are a different database and still inspect for themselves, as does every candidate if the shared read fails: a proposal must never break enforcement.pgroles.candidate.inspectionsandpgroles.candidate.planning.durationreport whether the cost is actually flat in the candidate count. (#191) -
Open candidates are bounded by a budget and a TTL, not just by retention. Retention prunes candidates that are already finished; these bound work that has not happened yet, since planning an open candidate happens inside the parent's reconcile while it holds its locks. At most 32 open candidates per policy are planned in one pass — the oldest, so a CI loop filing a candidate per push cannot evict proposals already under review — and the rest report
Ready=False, reason=CandidateBudgetExceededuntil older ones are decided or expire. Nothing is deleted: an over-budget candidate is somebody's proposal, not garbage. Separately, an open candidate nobody decides within 14 days of filing becomesSuperseded=True, reason=Expired, on the grounds that it is abandoned rather than under review, and retention then prunes it normally.pgroles.io/keep=trueexempts a candidate from the TTL and from retention pruning, but not from the budget: a kept candidate past the budget reportsCandidateBudgetExceededlike any other, so the label cannot be used to enlarge the bound. (#191) -
spec.schemas,spec.rolesandspec.retirementsare now map-lists (keyed byname,nameandrole), so server-side apply merges entries instead of replacing whole lists, and a manifest with duplicate keys is rejected atkubectl applyinstead of failing later at reconcile time.memberships,grantsanddefault_privilegesstay plain arrays: their natural keys are composite or legitimately repeat. (#126)
Fixed
- The Kyverno plan-decision policy no longer identifies the operator by ServiceAccount name. Both decision rules now exempt callers holding the logical
manageverb on the parentPostgresPolicy, checked with aSubjectAccessReview— the same shape the ephemeral-access policy already used for controller-owned writes — and the operator's ClusterRole grants it. The hardcodedsystem:serviceaccount:pgroles-system:pgroles-operatorwas wrong in both directions and silently so: it stalls plans when it names an account the operator does not run as, and it is a standing bypass of the approve-verb check for anyone able to create or impersonate that account in a cluster that installs the operator elsewhere. One policy now covers any number of operators under any names and namespaces, nothing install-specific is templated into it, andk8s/security/plan-decision-kyverno.yamlapplies unmodified. The exemption covers the approve check only — every newly terminal decision still records the identity the API server authenticated, controllers included, so underapproval: autodecidedBynow names the operator's ServiceAccount while the condition'sAutoApprovedreason carries the mechanism. Grantmanageonpostgrespoliciesto controllers only. (#187, #179) - The plan approval documentation now describes the mechanism that exists — a decision written to the plan's status subresource together with
decidedBy— with workingkubectlcommands for both approval and rejection. The CLI commands it used to invent are gone; what genuinely remains unbuilt is confined to one callout. (#184, #173) - A superseded plan names why it was superseded — effects changed, effects vanished, replaced by a newer plan, or the target moved — instead of always claiming the database changed. A moved target reports the specific identity reason. (#184)
kubectl get pgplan -o wideshows the change digest a decision actually binds (Digestcolumn), not just the SQL preview hash. (#184)- Superseding a plan no longer tries to rewrite the decision recorded on it — a write the plan CRD itself rejects, which left decided plans stuck actionable against a real API server. A plan is voided by its phase, with the cause on a
Superseded=Truecondition, and the decision record stays exactly as the reviewer left it. (#185, #182) - The operator's RBAC now covers the candidate
patch(adoption) anddelete(retention) it actually performs. (#182) - A crash between retiring a plan and creating its replacement can no longer leave a policy with nothing actionable: the old plan is retired only after the replacement is visible, on every path. (#185)
status.current_plan_refis cleared before the plan it points at is retired, so an interrupted reconcile leaves a findable pending plan rather than a dangling reference. (#185)- A
mode: observepolicy now retires its pending plan when drift disappears out of band, instead of reportingInSyncwhilecurrent_plan_refpoints at a stale plan. (#185) - A change set waiting out its failure-retry window is reported as
PlanFailedRetryBackoffinstead of "awaiting approval" beside a Failed plan with no decision to make. (#185)
What's Changed
- ci: attach release binaries before the release is frozen by @hardbyte in #170
- ci: publish the irreversible step last, and gate the chart on CI too by @hardbyte in #171
- chore(deps): bump nanoid from 3.3.11 to 3.3.18 in /docs by @dependabot[bot] in #161
- chore(deps-dev): bump brace-expansion from 1.1.12 to 1.1.18 in /docs by @dependabot[bot] in #155
- docs: correct stale claims, cut duplication, and orient the tutorials by @hardbyte in #172
- docs: end-state documentation for candidate planning and hardened approval (#173) by @hardbyte in #176
- feat: key-aware CRD list semantics for named spec arrays by @hardbyte in #175
- feat(operator): approve plans by semantic effect digest, not rendered SQL by @hardbyte in #177
- feat(operator): revalidate pending plans instead of freezing them by @hardbyte in #183
- feat(operator): write-once plan decisions with a recorded decider by @hardbyte in #186
- Approval hardening end to end: target identity, deferred Secrets, candidates and promotion, mode: observe by @hardbyte in #188
- perf(operator): bound candidate planning cost with a shared inspection, a budget and a TTL (#191) by @hardbyte in #193
- fix(operator): bound plan retention per phase and make it configurable by @hardbyte in #195
- fix(chart): exempt the plan-decision operator by authorization, not by name by @hardbyte in #196
- Add global default privileges and PUBLIC grantees by @ftxqxd in #192
- Repoint repository references from hardbyte to thepartly by @hardbyte in #198
- Repo transfer prep, dependency updates, and v0.10.0-alpha.1 release prep by @hardbyte in #199
New Contributors
Full Changelog: v0.9.0...v0.10.0-alpha.1