English | Русский
Declare who may do what in one reviewed YAML file. Lint it against least-privilege rules, compile it to Vault policies, Kubernetes RBAC and GitLab membership, and detect drift from what is actually configured.
Access that lives in click-ops UIs cannot be reviewed, diffed or audited. Here every change is a merge request: reviewers see exactly which person gets which role on which team and environment, CI rejects risky grants, and the same file generates the configuration for all three systems.
All people, teams, tickets and tenants are synthetic. This is a reference design for a least-privilege access model, not data or code from any employer.
Requires Python 3.10+. Not on PyPI yet, install from a checkout:
git clone https://github.com/ftonita/access-as-code.git && cd access-as-code
python -m venv .venv && source .venv/bin/activate
pip install -e .
access-as-code init --out access # starter files in ./access (common.yml + one team file)
access-as-code lint access # exit 0 = OK, 1 = rule errors, 2 = bad input
access-as-code matrix access # who has which roles where, at a glance
access-as-code compile access --out build # what would be applied to Vault / Kubernetes / GitLabOr play with the bundled example, a company split into two directions: examples/company/ (clean, a good template) and, in the older flat format, examples/access.bad.yml (one planted problem per rule).
Lint failed? access-as-code explain AAC003 tells you how to fix that rule.
Who edits what? Tech support edits the YAML following the support cheat sheet (copy-paste recipes for the usual tickets). Security owns common.yml. Each direction lead reviews their own file. CI does the rest.
# web.yml - direction "web"
version: 2
teams:
sites:
members: # everyone in the team: login -> corporate email
alice: alice@corp.example
bob: bob@corp.example
access: # what EVERY member gets, per environment
dev: developer
stage: developer
prod: { role: viewer, ticket: SEC-101 }
extra: # one-off / temporary access
- { who: alice, role: deployer, env: prod, ticket: SEC-110, expires: 2026-12-15 }
- { who: [bob, carol], role: viewer, env: stage }| Concept | Meaning |
|---|---|
| role | a named bundle of rights for each system (defined once in common.yml): "developer" means the same in Vault, Kubernetes and GitLab |
| members | the people of the team. Removing a name revokes the team-wide access; move it to left: to also make lint catch leftovers in extra |
| access | the team matrix: environment -> role (or { role, ticket }). Applies to every member |
| extra | individual grants: who (one name or a list), role, env, optional ticket, expires, reason. The team that owns the resource is the one that lists the entry, even if the person belongs to another team |
members: # login: corporate email
alice: alice@corp.example
bob: bob@corp.exampleRules of thumb: production always needs a ticket; a grant to a person in production needs expires (max 90 days); the dangerous break-glass role needs both and at most 7 days.
Pass a directory and every *.yml in it is merged, so each direction gets its own file and its own reviewers:
access/
common.yml # environments, production_environments, roles owner: security
web.yml # teams: sites, portal owner: web leads
infr.yml # teams: devops, sys-adm owner: infra leads
- Commands take either a file or the directory:
access-as-code lint access/. - A team, person, group or role is defined exactly once, in one file; defining it twice is an error that names both files. Environments and
production_environmentsare merged. - Order of files does not matter. Messages carry the file name:
AAC003 error: grant #4 (...) [web.yml]: production grant without a ticket. - Access that spans directions is written in the file that owns the resource: if alice (web) needs stage in devops, the entry goes under
devops.extraininfr.yml. The infra leads approve it (see examples/CODEOWNERS); without a ticket lint warns about cross-team access (AAC008). - Files in a directory must be
version: 2. A single file may contain everything (common+ teams), which is fine for a small company. - Editor help: every example starts with
# yaml-language-server: $schema=...; the schema lives in schema/access.v2.schema.json (access-as-code schemaregenerates it), so VS Code / JetBrains with a YAML plugin autocomplete and underline mistakes while typing.
PATH is a file or a directory.
| Command | What it does | Exit code |
|---|---|---|
init [--out access] |
starter files (a directory; a single file if --out ends in .yml); never overwrites |
0 / 2 |
validate PATH |
schema and structure only | 0 / 2 |
lint PATH [--strict] [--format text, json or github] |
least-privilege rules; --strict fails on warnings too; github prints annotations attached to the offending file in the PR |
0 / 1 |
explain AAC003 |
what a rule means and how to fix it | 0 / 2 |
matrix PATH |
table team x environment: which roles, how many people | 0 |
who PATH PERSON |
all effective access of one person, where it comes from, tickets and expiry | 0 / 2 |
changes BASE HEAD [--format text, markdown or json] |
who gained or lost access between two catalogs (e.g. the base branch and a PR), plus accounts to create or remove | 0 / 2 |
compile PATH [--out build] |
write Vault / Kubernetes / GitLab artifacts and people.json |
0 |
export-state PATH --vault --kubernetes --gitlab [--out snapshot.json] |
read-only export of the actual state from live systems | 0 / 2 |
diff PATH --actual snapshot.json |
desired vs actual state (areas missing from the snapshot are reported as not checked) | 0 no drift / 1 drift |
review PATH [--days 30] |
expiring and elevated grants | 0 |
schema |
JSON Schema of version 2 files (editor validation) | 0 |
rules |
list all rules | 0 |
demo-state PATH [--out actual.json] |
fabricate a drifted snapshot for experiments | 0 |
Every command that depends on time takes --today YYYY-MM-DD (default: the real date). Exit code 2 means the input could not be read or failed the schema; the message names the file and the exact path, e.g. web.yml: teams.sites.extra[1]: 'env' is a required property.
Dates and the examples. Example grants expire in Oct-Dec 2026. Run them with
--today 2026-10-08(as this README and CI do) or they will start to fail as "expired" later.
Full copy-paste versions with the CI messages you will see are in docs/SUPPORT.md. In short, every request is a small edit of one team block:
| Ticket says... | Edit |
|---|---|
| New employee | add login: email to members of the team (the address is where their account credentials are sent) |
| Employee left | move the name from members to left; delete their lines in extra |
| Temporary prod access | add a line to extra with ticket and expires |
| Emergency access | the same with role break-glass, expires within 7 days, a reason |
| Extend / remove access | change expires / delete the line |
| New team | add a block under teams: (in the file of its direction; the Vault mount kv-<team> and Kubernetes namespaces <team>-<env> must exist) |
| New role | security adds it to common.yml |
- Names (
teams,environments, roles, people, groups) are lowercase letters, digits and dashes. - Team baseline = group
<team>-team. Version 2 turnsmembers+accessinto a group of that name (visible in lint messages asgroup:sites-team); do not define a group with that name yourself. - Person id = login in the target systems. It becomes the
Username in Kubernetes RoleBindings (your OIDC username), the member in Vault identity groups and the key in GitLabmembers.json. If your IdP usesa.smith, rename or map it in your apply step. - Naming convention for targets: Vault policy/group
<team>-<env>-<role>, KV pathkv-<team>/data|metadata/<env>/*, Kubernetes namespace<team>-<env>, RoleBindingaac-<level>bound to the built-in ClusterRoleview/edit/admin. - AAC007 matches role names
deployerandapprover. If you call them differently, the rule will not fire (seeSOD_ROLESinsrc/access_as_code/lint.py). - A group grant gives every member the role; two grants to the same person in the same place merge (the highest GitLab/Kubernetes level wins, Vault gets one policy per role).
| ID | Severity | Rule |
|---|---|---|
| AAC001 | error | No role may hold the Vault sudo capability. |
| AAC002 | error | References resolve: grants to known teams, environments, roles and subjects; group members to people; people to teams. |
| AAC003 | error | Production grants need a ticket; direct person grants also need an expiry (max 90 days). |
| AAC004 | error | Elevated roles (delete / Kubernetes admin) in production need a ticket and expire within 7 days. |
| AAC005 | error | Offboarded people hold no access, directly or via a group. |
| AAC006 | error | Expired grants must be removed. |
| AAC007 | error | Separation of duties: nobody is both deployer and approver on one team in production, even through groups. |
| AAC008 | warning | Cross-team access needs a ticket. |
| AAC009 | warning | Duplicate grants. |
| AAC010 | info | Unused roles and groups. |
| AAC011 | warning | Grants expiring within 14 days are due for review. |
| AAC012 | error | With require_email: true, every active person has a corporate email. |
| AAC013 | error | Two people never share one email address. |
| AAC014 | error | With email_domains, emails belong to one of the listed domains. |
lint exits 1 on errors (--strict: also on warnings), so it works as a required merge-request check. --today pins the date for reproducible runs.
$ access-as-code lint examples/access.bad.yml --today 2026-10-08
AAC001 error: role 'super-ops' grants the Vault 'sudo' capability
AAC003 error: grant #3 (bob -> deployer on payments/prod): direct production grant without an expiry
AAC005 error: grant #0 (group:payments-devs -> developer on payments/dev): 'gone' is offboarded
AAC007 error: 'alice' is both deployer and approver on payments/prod
...
14 error(s), 5 warning(s), 3 info (exit 1)
$ access-as-code lint examples/company --today 2026-10-08
AAC011 warning: grant #5 (erin -> break-glass on devops/prod) [infr.yml]: expires on 2026-10-12
0 error(s), 1 warning(s), 0 info (exit 0)
$ access-as-code matrix examples/company --today 2026-10-08
team dev stage prod
devops developer x2 developer x2 approver x1, break-glass x1, deployer x1, viewer x2
sys-adm developer x2 developer x2 approver x1, deployer x1, viewer x2
sites developer x2 developer x2, viewer x1 approver x1, deployer x1, viewer x2
portal developer x2 developer x2 approver x1, deployer x1, viewer x2
$ access-as-code who examples/company carol --today 2026-10-08
carol: team portal, active
portal/dev developer via group:portal-team ticket=- expires=-
portal/prod deployer via direct ticket=SEC-112 expires=2026-12-01
portal/prod viewer via group:portal-team ticket=SEC-102 expires=-
portal/stage developer via group:portal-team ticket=- expires=-
sites/stage viewer via direct ticket=SEC-130 expires=-
$ access-as-code compile examples/company --today 2026-10-08 --out build
wrote 39 files to build
Generated, for example build/vault/policies/sites-prod-deployer.hcl:
path "kv-sites/data/prod/*" {
capabilities = ["create", "read", "update"]
}
path "kv-sites/metadata/prod/*" {
capabilities = ["list", "read"]
}plus vault/groups.json (identity group -> policy + members), one Kubernetes RoleBinding per namespace and level (sites-dev / edit) and gitlab/members.json (highest level per person per team).
Compile emits effective access only: expired grants, offboarded people and invalid grants never reach the output, even if lint was skipped.
$ access-as-code diff examples/company --today 2026-10-08 --actual actual.json
changed vault_policies:devops-dev-developer content differs
extra vault_policies:legacy-admin configured but not declared
changed vault_groups:devops-dev-developer missing members ['erin']
changed vault_groups:devops-prod-break-glass extra members ['zed']
missing kubernetes:devops-dev/edit declared but not configured
changed gitlab:sites carol: declared 'reporter', actual 'maintainer'
6 difference(s) (exit 1)
export VAULT_ADDR=https://vault.example.com VAULT_TOKEN=... # needs list/read on sys/policies/acl and identity/*
export GITLAB_URL=https://gitlab.example.com GITLAB_TOKEN=... # read_api scope, member of the team groups
access-as-code export-state access --vault --kubernetes --gitlab --out snapshot.json
access-as-code diff access --actual snapshot.json- Read-only: only GET/LIST requests and
kubectl get. Tokens are read from the environment and never written to the snapshot. Use a dedicated read-only token. - Vault: all ACL policies except
default/root, and all identity groups with their entity names (entity name must equal the person id).VAULT_CACERTis honoured. - Kubernetes:
kubectlwith your current context. By default only RoleBindings labelledmanaged-by=access-as-code(whatcompilegenerates) are read; add--k8s-allto also catch hand-made RoleBindings toview/edit/admin. - GitLab: direct members of the group
<prefix><team>(--gitlab-group-prefix 'acme/'); guest/owner levels are exported as is, so an owner shows up as drift. A team without a group is reported as missing. -
- Safety: HTTPS is required (plain
http://only for localhost), redirects are never followed,VAULT_NAMESPACEis honoured. A group member whose Vault entity was deleted appears as<unknown-entity:id>; Kubernetes Group/ServiceAccount subjects appear asgroup:<name>/serviceaccount:<ns>:<name>so over-privilege shows up as extra members; GitLab inherited (parent-group) members are not listed.
- Safety: HTTPS is required (plain
- A missing GitLab group is an error (wrong prefix or token?) unless
--gitlab-missing-ok. - Pass only the systems you can reach: the others are listed as
not checked, not as drift.
This is covered by tests against a fake Vault/GitLab and a fake kubectl, not against real instances.
To reproduce the sample output without any live system: access-as-code demo-state examples/company --today 2026-10-08 --out actual.json fabricates plausible hand-made changes. In real use you export the same four sections from your systems on a schedule and fail the job on any difference. Easiest way to learn the format: compile writes the "desired" side, and demo-state writes a complete example snapshot. Shape of the snapshot:
{
"vault_policies": { "sites-dev-developer": "<HCL text, as in build/vault/policies/*.hcl>" },
"vault_groups": { "sites-dev-developer": ["alice", "bob"] },
"kubernetes": { "sites-dev/edit": ["alice", "bob"] },
"gitlab": { "sites": { "alice": "maintainer" } }
}A snapshot with none of the four sections is rejected (exit 2). A section that is absent from the snapshot is not checked; an empty one ({}) means "nothing configured" and reports everything declared as missing.
- The
access/directory lives in a repo with CODEOWNERS: one owner group per file (security forcommon.yml, the direction leads for their file). - Merge request: CI runs
lint(blocking) and showsreview. - After merge:
compile, apply with your tooling (Terraform Vault provider,kubectl apply, GitLab API). - Nightly:
export-statethendiff; page on drift.
Some systems need local accounts (Vault userpass, local GitLab users, Kubernetes client certificates...). To create them without keeping a password, token or any other secret in the repository, a person has a corporate address and the automation sends the credentials to that mailbox, personally and once:
# common.yml
require_email: true # AAC012: every active person needs an address
email_domains: [corp.example] # AAC014: and only a corporate one
# web.yml
members:
alice: alice@corp.example # login: address (bob: with no address is allowed unless require_email is on)compilewritespeople.json: for every person who has effective access it listsemail,team, thesystemsthat need an account (vault,kubernetes,gitlab, derived from the roles) and theaccessthey get. It contains no secrets. The newchangescommand lists the new accounts to create and the accounts no longer needed in a PR, so the automation knows whom to onboard and offboard.- The sending step is yours (it needs your mail relay and your account-creation tooling) and this tool never handles a password. Recommended pattern: generate a one-time password or an invitation/reset link, send it to
people.json->emailfrom the pipeline, force a change at first login, and keep the secret in no log, artifact or variable. - Lint protects the destination: a private domain (AAC014) or a mailbox shared by two people (AAC013) fails the PR, and messages name the person, never the address. The address is personal data: keep the access repository private and treat
people.jsonas confidential.
Copy examples/ci/github-actions.yml to .github/workflows/access.yml of your access repository (5 lines of configuration). It calls the reusable workflow .github/workflows/access-check.yml, which on every PR that touches access/:
- validates and lints; findings show up as annotations on the offending file and block the merge (
strict: truealso blocks on warnings); - writes a job summary with the team x environment matrix, expiring/elevated grants, who gained or lost access (and which accounts to create or remove) and a
diffof the generated Vault / Kubernetes / GitLab configuration between the base branch and the PR, so a reviewer sees what merging would really change; - checks that the catalog compiles. It is read-only and needs no secrets (
permissions: contents: read).
Make the check required (branch protection / ruleset) and add CODEOWNERS so each direction's file needs its own leads' approval. This repository dogfoods it in .github/workflows/access-pr.yml. Pin the uses: ref to a tag or commit SHA if you want to control updates.
The original single-file format (version: 1: people, groups and a list of grants, each with subject, role, team, env) is still supported and produces exactly the same output; see examples/access.yml and examples/access.bad.yml. New work should use version 2, which is shorter and can be split by direction. Both are internally turned into the same model.
error: ... is a required property- the file failed the schema;access-as-code validateprints every problem with its path.- Lint passes locally but fails in CI - dates: pin both with
--today, or fix the grant that is about to expire (AAC011 warns 14 days ahead). - Does
compilechange my systems? No. It only writes files; applying them (Terraform,kubectl apply, GitLab API) is your step. - Can I skip a rule for one grant? No, by design. Change the rule in code via a reviewed PR or fix the grant.
Reproduce with pip install -e ".[dev]" && pytest (106 tests): every lint rule (positive, negative and boundary cases such as an expiry exactly 90 days away or a grant expiring today), schema rejection, group resolution, deterministic compilation, exclusion of expired/offboarded access, drift of every kind, CLI exit codes.
Not verified: applying the output to real Vault, Kubernetes or GitLab instances; the HCL, RoleBinding and member-level formats follow the public documentation but were never loaded into those systems. export-state was tested against fakes only; objects Vault returns for namespaces other than the token's, nested GitLab subgroups and inherited memberships are not exported. Vault policy paths assume KV v2 mounts named kv-<team>. The merge of several files and the version 2 flattening are covered by tests (duplicate definitions, ordering, error messages with file names). Roles are per-team and per-environment; finer scoping (single paths, time-of-day) is out of scope.