Skip to content

v0.10.0-alpha.1

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 21 Aug 06:28
· 1 commit to main since this release
Immutable release. Only release title and notes can be modified.
161067b

Added

  • PostgresPolicyCandidate: propose and review policy content without touching the live policy. A candidate points at an existing PostgresPolicy and carries only proposed content — roles, grants, memberships. Everything about execution (interval, mode, approval and, unless spec.target overrides 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

    (#182, #173)

  • 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 with Ready=False, reason=BlockedByActivePolicy; an active ephemeral grant that touches a candidate's effects sends its plan back for fresh review with OverlayOverlap. (#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 in mode: observe (PromotionNotExecuted). The candidate docs give the kubectl and CI recipes for the whole flow. (#182, #173)

  • pgroles candidate covers the review side of the candidate workflow, so proposing and reading a change no longer means hand-rolling jsonpath. create files a candidate from the ordinary manifest your PR promotes — validated locally through the same path as pgroles validate, so a proposal the API server would reject on size bounds fails on your machine with the same field-level message, and created with generateName so two people filing against one policy never collide. list shows every candidate for a policy with its phase, digest, plan and condition reasons; status expands one of them down to its plan's decision, who made it, whether it is still current, and what promotion had to say; diff prints 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 stays kubectl-shaped on purpose: it is a status write gated by admission so decidedBy records 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. Set spec.connection.requirePhysicalIdentity: true to 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_privileges entries accept scope: {type: global} beside the existing schema: shorthand, emitting ALTER DEFAULT PRIVILEGES FOR ROLE ... with no IN SCHEMA clause. 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 explicit pg_default_acl row still compares against what PostgreSQL will apply. Owner self-entries are excluded, because every ALTER DEFAULT PRIVILEGES materializes 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 in diff output, and in a bundle only the document owning the owner role may declare them. See default privileges.

  • ensure: absent and a typed PUBLIC grantee. PostgreSQL grants EXECUTE on every function to PUBLIC without writing an ACL entry, so no combination of positive grants could take it away — a SECURITY DEFINER routine stayed callable by every role. Grant entries and default-privilege entries now accept ensure: absent, which revokes a privilege where it is held, and role: PUBLIC addresses the pseudo-role (rendered unquoted, never as the identifier "PUBLIC"). Inspection reports PUBLIC's effective privileges, synthesizing acldefault(...) 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 a present PUBLIC rule does not revoke it — switch the rule to ensure: absent. additive ignores absence assertions with a warning, since it never revokes; adopt and authoritative apply them. A profile is an additive template, so a profile grant or default privilege that sets ensure: absent is rejected by name instead of expanding to its opposite. Preflight warns on diff and 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. Superseded is generated churn — every replan supersedes its predecessor — so on an active policy it filled the pool and deleted the Applied plans, 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 now Applied 25 (never fewer than 30 days' worth, hard ceiling 200), Failed and Rejected 10 shared, Superseded 3; Pending, Approved and Applying are 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=true still 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_SUPERSEDED on the operator environment, replacing the max_plans parameter that nothing could ever set — and an invalid value refuses operator startup with the variable named. Deliberately not a PostgresPolicy field: retention caps object growth in the cluster, and the per-object need is what the keep label is for. The Applied bounds measure — and order — by status.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 an Applied plan is held to the Applied bounds instead of the flat terminal-candidate bound, and pgroles.io/keep=true on 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 tagged scope ({"type": "schema", "schema": "app"} or {"type": "global"}) in place of the bare schema string, which could not express a global rule. Migration: read scope.schema where you read schema, and handle scope.type == "global" entries having no schema at all.

  • diff --format json carries the same tagged scope on default-privilege changes. Unlike bundle output it has no schema_version field to bump, so nothing announces the change in the payload itself. Migration: the same one as above — read scope.schema where you read schema, and handle scope.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.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.

  • spec.mode: plan is renamed to spec.mode: observe, with a deprecation window. "Plan" now names exactly one thing, the PostgresPolicyPlan resource; the ApprovalIgnored reason PlanModeNeverExecutes is now ObserveModeNeverExecutes. The old value keeps working: mode: plan stays 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 a ModeValueDeprecated condition, warns in the operator log, and counts toward pgroles.deprecated.mode_plan.
    Upgrade: change mode: plan to mode: observe in your manifests at your convenience — a future release removes the plan value, 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 to pgroles validate alike, 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 opaque etcdserver: 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 tagged scope value instead of a bare schema string, 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, a pg_upgrade (fresh system_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: auto behaves as before. Deleting a generated Secret still rotates the password on the next apply — the policy now warns first with a GeneratedSecretMissing Event 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.target override 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.inspections and pgroles.candidate.planning.duration report 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=CandidateBudgetExceeded until 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 becomes Superseded=True, reason=Expired, on the grounds that it is abandoned rather than under review, and retention then prunes it normally. pgroles.io/keep=true exempts a candidate from the TTL and from retention pruning, but not from the budget: a kept candidate past the budget reports CandidateBudgetExceeded like any other, so the label cannot be used to enlarge the bound. (#191)

  • spec.schemas, spec.roles and spec.retirements are now map-lists (keyed by name, name and role), so server-side apply merges entries instead of replacing whole lists, and a manifest with duplicate keys is rejected at kubectl apply instead of failing later at reconcile time. memberships, grants and default_privileges stay 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 manage verb on the parent PostgresPolicy, checked with a SubjectAccessReview — the same shape the ephemeral-access policy already used for controller-owned writes — and the operator's ClusterRole grants it. The hardcoded system:serviceaccount:pgroles-system:pgroles-operator was 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, and k8s/security/plan-decision-kyverno.yaml applies unmodified. The exemption covers the approve check only — every newly terminal decision still records the identity the API server authenticated, controllers included, so under approval: auto decidedBy now names the operator's ServiceAccount while the condition's AutoApproved reason carries the mechanism. Grant manage on postgrespolicies to 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 working kubectl commands 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 wide shows the change digest a decision actually binds (Digest column), 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=True condition, and the decision record stays exactly as the reviewer left it. (#185, #182)
  • The operator's RBAC now covers the candidate patch (adoption) and delete (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_ref is 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: observe policy now retires its pending plan when drift disappears out of band, instead of reporting InSync while current_plan_ref points at a stale plan. (#185)
  • A change set waiting out its failure-retry window is reported as PlanFailedRetryBackoff instead 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