GitOps privilege synchronization

GitOps privilege synchronization makes a version-controlled manifest the single authoritative source of who may touch which database object, and reduces every live grant change to a reviewed, mergeable, replayable git operation. Without it, privilege edits happen out of band — a hotfix GRANT at 2am, a console click in the cloud IAM panel — and the database’s real access surface silently diverges from anything a human ever approved. When that gap is discovered during an audit rather than at merge time, there is no ticket, no reviewer, and no diff to explain it, and the finding lands on the platform team that owns the estate. This section shows how to close that gap by binding the privilege state to git the same way infrastructure teams bind cluster state: declare intent in a file, review the change as a pull request, plan it in CI, and apply it only after merge.

The GitOps privilege synchronization loop A version-controlled privilege manifest opens a pull request; CI runs a dry-run plan against live catalogs and posts the diff on the pull request; a reviewer approves; merge to the main branch triggers a gated, idempotent apply of GRANT and REVOKE statements; a reconcile step re-diffs the live database against the manifest, and any residual drift reopens a pull request, closing the loop. residual drift → new PR Manifest in git source of truth Pull request CI plan dry-run diff on PR Review & approve Merge to main Gated apply idempotent GRANT / REVOKE Reconcile re-diff live vs manifest
Figure 1 — The synchronization loop: the manifest drives a reviewed pull request, CI plans the change, merge gates an idempotent apply, and a reconcile step feeds any residual drift back as a new pull request.

Prerequisites and scope

This model assumes you already produce a canonical privilege manifest and can read live catalog state cheaply. Concretely, it expects PostgreSQL 13+ or MySQL 8.0+, psycopg v3 for catalog reads and apply, and pydantic v2 to load and validate the manifest as typed data rather than free-form YAML. The CI runner needs two credentials: a read-only role holding SELECT on the system catalogs for the plan stage, and a separately-scoped role that can GRANT and REVOKE for the apply stage — never the same secret, so a compromised pull-request build cannot mutate access. Applying the plan requires the idempotent DDL contract from Idempotent Privilege Apply; this section assumes those grant and revoke statements already converge on repeat runs and focuses on the git-driven control flow that decides when they run.

The manifest itself is the deliverable of the extraction side of the estate. How its schema is validated on the way in — column drift, renamed objects, engine-specific privilege names — is the job of Schema Validation Pipelines; GitOps synchronization consumes a manifest those pipelines have already declared well-formed.

How the loop works

The pipeline is four cooperating stages bound to git lifecycle events. A change to the manifest opens a pull request; CI computes a plan and posts it as the reviewable artifact; a human approves; merge to the protected branch is the only event that authorizes an apply.

1. Load the manifest as typed, validated data

The manifest is the contract, so it is parsed into pydantic models before anything reads the database. Validation failures fail the pull request build, which means a malformed grant never reaches a plan, let alone an apply.

from datetime import datetime
from pydantic import BaseModel, Field, field_validator

class GrantSpec(BaseModel):
    principal: str
    obj: str                       # schema-qualified, e.g. "sales.orders"
    privilege: str                 # SELECT, INSERT, EXECUTE, ...
    grantable: bool = False

    @field_validator("privilege")
    @classmethod
    def known_privilege(cls, v: str) -> str:
        allowed = {"SELECT", "INSERT", "UPDATE", "DELETE", "EXECUTE", "USAGE"}
        u = v.strip().upper()
        if u not in allowed:
            raise ValueError(f"unsupported privilege: {v!r}")
        return u

class PrivilegeManifest(BaseModel):
    environment: str = Field(pattern=r"^(dev|staging|prod)$")
    generated_at: datetime
    grants: list[GrantSpec]

    def desired(self) -> set[tuple[str, str, str, bool]]:
        return {(g.principal, g.obj, g.privilege, g.grantable) for g in self.grants}

def load_manifest(path: str) -> PrivilegeManifest:
    import yaml
    with open(path) as fh:
        return PrivilegeManifest.model_validate(yaml.safe_load(fh))

Because desired() returns a set of hashable tuples, the plan stage can compute a difference in near-linear time and the result is order-independent — two runs over an unchanged manifest produce identical output, which is what makes the CI plan diffable across pull requests.

2. Compute the plan against live catalogs

The plan stage reads the live grant set with psycopg v3 and diffs it against the manifest. It is strictly read-only — it holds a connection whose role has only SELECT on the catalogs — and it never writes. Its whole product is a structured plan describing what an apply would do.

import psycopg

OBSERVED_SQL = """
SELECT grantee, table_schema || '.' || table_name AS obj,
       privilege_type, is_grantable::bool
FROM information_schema.role_table_grants
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
  AND grantee <> 'PUBLIC'
ORDER BY grantee, obj, privilege_type;
"""

def observed(dsn: str) -> set[tuple[str, str, str, bool]]:
    with psycopg.connect(dsn, autocommit=True) as conn:
        conn.execute("SET transaction_read_only = on")
        with conn.cursor() as cur:
            cur.execute(OBSERVED_SQL)
            return {(g, o, p, bool(gr)) for g, o, p, gr in cur.fetchall()}

def plan(desired: set, live: set) -> dict[str, list]:
    """missing = grant it; excess = revoke it. Deterministic ordering."""
    return {
        "grant": sorted(desired - live),
        "revoke": sorted(live - desired),
    }

The plan dictionary is serialized to JSON and posted as a pull-request comment (and uploaded as a build artifact), so the diff a reviewer approves is byte-for-byte the diff CI will apply. This is the same read-side discipline used by Continuous Control Monitoring, which polls the identical catalog views on a schedule to detect drift between merges rather than only at merge time.

3. Gate the apply on merge

Apply runs only on the main branch, only after the plan has been approved, and only with the write-scoped credential. The apply reuses the same plan() function so it cannot drift from what was reviewed, then delegates each statement to the idempotent DDL layer inside one transaction.

import os
import psycopg

def apply(dsn: str, planned: dict[str, list]) -> int:
    if os.environ.get("GITHUB_REF") != "refs/heads/main":
        raise SystemExit("apply is only permitted on main")
    applied = 0
    with psycopg.connect(dsn) as conn:          # transactional: all-or-nothing
        with conn.cursor() as cur:
            for grantee, obj, priv, grantable in planned["revoke"]:
                cur.execute(f'REVOKE {priv} ON {obj} FROM "{grantee}"')
                applied += 1
            for grantee, obj, priv, grantable in planned["grant"]:
                opt = " WITH GRANT OPTION" if grantable else ""
                cur.execute(f'GRANT {priv} ON {obj} TO "{grantee}"{opt}')
                applied += 1
        conn.commit()
    return applied

Revokes run before grants so a tightened privilege set never has a transient window where both the old broad grant and the new narrow one coexist. Because the block is one transaction, a mid-batch failure rolls the whole apply back rather than leaving a half-synchronized state — the recovery semantics of that failure path belong to Rollback and Recovery.

Idempotency and safety contract

The loop is safe to run repeatedly because each stage is convergent. The plan is a pure function of (manifest, live state): given the same two inputs it always emits the same grant and revoke lists, so re-running the pull-request build never changes what a reviewer sees. The apply is idempotent at the statement level — REVOKE of an already-absent grant and GRANT of an already-present one are both no-ops in PostgreSQL — so replaying a merged plan against a database that already converged produces zero net change. Together these mean the reconcile step can run on a timer without risk: it recomputes the plan, and if the plan is empty the system is synchronized and nothing happens. When the plan is not empty after a merge — someone changed a grant out of band — reconcile opens a fresh pull request carrying that delta, so even manual changes are pulled back through review rather than silently overwritten.

The one hard rule that makes the whole model trustworthy is credential separation: the plan stage physically cannot write (its role lacks GRANT), and the apply stage runs only on the protected branch. A pull request from a fork therefore can compute a plan but can never touch production access.

Compliance alignment

Binding privilege changes to git turns everyday version control into a continuous evidence stream. Each control below maps to an artifact the loop produces without extra work.

Control Requirement Evidence the loop produces
SOC 2 CC6.1 Access aligned with authorization The merged manifest is the authorization of record; the pull request shows who approved each grant
SOC 2 CC6.3 Access changes are authorized and reviewed Branch protection + required review means no grant reaches main without an approver on the pull request
SOC 2 CC8.1 Changes are tracked and controlled Git history plus the CI plan comment give a per-change before/after diff and reviewer identity
PCI DSS Req. 7 Least-privilege, documented access The manifest is the documented least-privilege state; the apply log proves the live database matches it

The evidence set is git history (who changed what, who approved it, when it merged) joined to the pipeline logs (the plan that was reviewed and the apply that ran). Because both are immutable and timestamped, an auditor can replay any historical access decision end to end — the continuous, per-change version of the periodic access review that Continuous Control Monitoring formalizes.

Troubleshooting

Symptom Root cause Remediation
Plan shows spurious revoke of PUBLIC grants The observed query includes implicit PUBLIC grants the manifest never lists Filter grantee <> 'PUBLIC' in the catalog read, or model PUBLIC explicitly in the manifest so it round-trips
Apply succeeds but reconcile still reports drift The apply role lacks GRANT OPTION on the object owner, so grants silently no-op Run apply as the object owner or a role with WITH GRANT OPTION; assert the post-apply plan is empty and fail if not
Plan on the pull request differs from what apply does Plan and apply read different environments or manifest revisions Pin both stages to the merge commit SHA and the same DSN; compute the plan once and pass the artifact to apply
MySQL grants apply but roles stay inert A granted role is not a default role, so the session exercises none of its privileges Resolve mysql.default_roles and issue SET DEFAULT ROLE in the apply, or the manifest describes access the database does not enforce
Fork pull request fails at the plan stage The read-only catalog secret is not exposed to fork builds Keep it that way — run the plan for forks against a sanitized replica, and never expose the write credential to any pull-request build

Up: Remediation & Synchronization Pipelines