Declarative access control for data warehouses. Define roles and grants as YAML in a config directory of your choosing — policies/ by convention, but any path you point the tool at — and let the tool reconcile your database to match — the same plan/apply model as Terraform, but for GRANT/REVOKE.
Instead of access changes living in someone's memory, a stale Slack thread, or a one-off GRANT run manually during an incident and never cleaned up, this tool makes access control auditable, version-controlled, and enforced by CI instead of by tribal knowledge.
Database permissions drift silently. An on-call engineer runs a manual GRANT to unblock an incident, nobody remembers to revoke it, and months later a security audit stumbles onto access nobody can explain. This tool closes that loop: every access change goes through a reviewed pull request, plan shows exactly what will change before it happens, and a scheduled drift check catches anything that slips outside that process.
<config-dir>/*.yml (desired state) ← any folder, passed via --config
vs
live database grants (actual state)
↓
diff_engine computes the delta
↓
plan → show the diff, change nothing
apply → execute GRANT/REVOKE to close the gap
plan — previews the diff, changes nothing:
apply — executes the reconciliation:
- Single declarative policy file. All roles, grants, denies, and PII tags live in one reviewable config file, inside whatever directory you point
--configat (policies/by default). - Role inheritance. Roles can inherit from other roles via
member_of, with effective grants resolved recursively (and guarded against circular inheritance). - Deny always wins. Explicit
denyrules override any broader grant, enforced at diff time — not just declared as intent. - Column-level tagging. Sensitive columns (e.g. PII) are tagged once in
tags:and referenced by name from any role, rather than repeated everywhere. - Safe by default. Every run is a dry-run unless
--applyis explicitly passed. Destructive changes touching a role's own schema are blocked outright; revokes touching an active database session require an explicit--force. - Wildcard expansion.
tables: allgrants are resolved against the live schema at diff time, so newly added tables are picked up automatically. - Unmanaged-role protection. The tool never touches roles it doesn't manage — only roles declared in your config directory are ever granted or revoked.
- No fixed config location.
--configaccepts any directory or single file on disk. This repo happens to keep its own policies inpolicies/, but that's just a convention, not a requirement — e.g.--config teams/data-eng/rbac/works exactly the same way. - CI-native. Every plan and apply runs through GitHub Actions, with the diff posted directly as a PR comment for review.
- Reusable workflows.
plan,apply, anddrift_checkare built as reusable GitHub Actions workflows (workflow_call), so any repo can invoke them with a thin wrapper instead of copy-pasting CI logic. - Scheduled drift detection. A recurring job independently checks the live database against policy, catching manual out-of-band changes and alerting to Slack or GitHub Issues based on severity.
- Pluggable adapter interface. Built against PostgreSQL first; the adapter interface (
DBAdapter) is designed so other warehouses (Snowflake, BigQuery) can be added without touching the diff engine.
# Install dependencies
cp .env.example .env
pip install -r requirements.txt
# Preview what would change (read-only, safe to run anytime)
python -m provisioner.cli plan --config policies/ --conn-string "$DATABASE_URL"
# Apply the changes
python -m provisioner.cli apply --config policies/ --conn-string "$DATABASE_URL" --applypolicies/ above is just this repo's own folder for its policy YAML — the --config flag isn't tied to that name. Point it at whatever directory (or single file) you keep your roles/grants in:
python -m provisioner.cli plan --config path/to/your-folder/ --conn-string "$DATABASE_URL"
python -m provisioner.cli apply --config path/to/your-folder/ --conn-string "$DATABASE_URL" --applygitops-rbac-provisioner/
├── .github/
│ └── workflows/
│ ├── plan.yml # reusable workflow: diff + PR comment
│ ├── apply.yml # reusable workflow: reconcile on merge to main
│ └── drift_check.yml # reusable workflow: scheduled drift detection
├── config/
│ └── guardrail.yml # Basic config for tool
├── provisioner/
│ ├── cli.py # entrypoint: plan / apply / check-drift
│ ├── state_loader.py # parses & validates config.yml into desired state
│ ├── diff_engine.py # core reconciliation logic
│ ├── sql_generator.py # diff -> GRANT/REVOKE SQL
│ ├── executor.py # dry-run vs real execution, commit/rollback
│ ├── pr_commenter.py # posts the plan diff to the PR
│ ├── notifier.py # Slack + GitHub issue alerts for drift
│ └── adapters/
│ ├── base.py # abstract DBAdapter interface
│ └── postgres_adapter.py # concrete Postgres implementation
├── policies/ # default --config path; point elsewhere if you prefer
│ └── policy.yml # roles, grants, denies, and PII tags
├── tests/
├── .env.example # sample DB connection / credential vars
├── .gitignore # ignores .env, __pycache__, .venv, etc.
├── README.md # setup, usage, and architecture overview
├── requirements.txt
├── seed.sql # bootstrap schema/roles for local/dev testing
└── pyproject.toml
The file below can live anywhere — this repo defaults to policies/policy.yml, but any path passed to --config works the same way.
version: 1
roles:
analysts:
description: "Data analysts - read access to non-PII marts"
member_of: []
grants:
- schema: marts
tables: all
privileges: [SELECT]
exclude_columns_tagged: [pii]
- schema: staging
tables: [orders, products]
privileges: [SELECT]
finance:
description: "Finance team - read/write on payments schema"
member_of: []
grants:
- schema: payments
tables: all
privileges: [SELECT, INSERT, UPDATE]
- schema: marts
tables: [revenue_summary]
privileges: [SELECT]
interns:
description: "Interns - restricted read-only sandbox access"
member_of: []
grants:
- schema: sandbox
tables: all
privileges: [SELECT]
deny:
- schema: patients
tables: all
columns_tagged: [pii]
admin:
description: "Full platform administrators"
member_of: [analysts, finance]
grants: []
tags:
pii:
- schema: public
table: patients
columns: [name, nin, phone]
- schema: public
table: users
columns: [email, phone_number]admin inherits everything analysts and finance are granted — no need to redeclare it. Anyone can add a new role by dropping a new entry into roles:; the engine has no hardcoded knowledge of role names.
- Dry-run by default.
plannever writes to the database.applyrequires--applyto actually execute. - Self-schema protection. A role can never have its own declared schema revoked as a side effect of a diff.
- Active-session protection. Revoking access from a role with an open database session requires
--force. - Unmanaged-role isolation. Roles that exist in the database but aren't declared in your config directory are never modified.
- Atomic apply. All statements in an
applyrun commit together, or none do — a failure partway through rolls back the whole batch.
All three workflows are implemented as reusable workflows (on: workflow_call) rather than standalone jobs. Each accepts inputs for the policy path, Python version, and target environment, and takes the database connection string as a secret — so any repository (or any environment within this repo) can call them from a thin wrapper workflow instead of duplicating the underlying CI logic.
| Workflow | Trigger | Purpose |
|---|---|---|
plan.yml |
Called from a wrapper on pull requests touching your config directory (policies/** by default — configurable in the caller workflow) |
Computes the diff, posts it as a PR comment for review |
apply.yml |
Called from a wrapper on push to main |
Reconciles the live database to match the merged policy |
drift_check.yml |
Called from a wrapper on a schedule (every 6 hours) | Independently checks for drift outside the PR flow, alerts on Slack/GitHub Issues by severity (critical if PII columns affected, else warning or info) |
Common inputs exposed by each reusable workflow:
| Input | Default | Description |
|---|---|---|
config-path |
policies/ |
Path to the policy directory or file to evaluate — set this to wherever your policies actually live; it isn't required to be policies/ |
python-version |
3.12 |
Python version used to run the provisioner |
environment |
production |
Target GitHub environment (controls which secrets/protection rules apply) |
Secrets:
| Secret | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | Connection string for the target database |
Because they're reusable, each workflow can be invoked with a short caller workflow in the consuming repo, passing config-path, python-version, and environment as needed and forwarding DATABASE_URL via secrets: inherit or an explicit secrets: block.
Each workflow is scoped to the minimum GitHub permissions it needs (issues: write for plan/drift_check; none for apply).
- Postgres adapters, behind the existing
DBAdapterinterface - Role creation as part of the diff (currently, roles must already exist in the target database)
- Column-level masking via generated views, for warehouses without native column-level
GRANT
- Implement other adapters
- Grant and revoke rate limiting
- Observability
Apache License