Policy and Access Control
DocStore has three layers of access control:
- IAP authentication — validates GCP Identity-Aware Proxy JWTs to establish identity.
- RBAC — per-repo role assignments that gate which HTTP methods each identity can call.
- OPA policy engine — Rego-based merge gates that can require reviews, CI checks, and OWNERS approval.
Authentication (IAP)
The server validates the X-Goog-IAP-JWT-Assertion header on every request (except GET /healthz). The JWT is RS256-signed by Google. Public keys are fetched from https://www.gstatic.com/iap/verify/public_key-jwk and cached for 1 hour. The identity is extracted from the email claim.
Local dev only: Set DEV_IDENTITY=you@example.com (or --dev-identity) on the server to bypass JWT validation. All requests are treated as that identity. This must never be set in production — production uses real IAP JWTs at https://docstore.dev.
RBAC roles
Each repo has an independent role table. Roles are:
| Role | Description |
|---|---|
reader |
Read-only access to all repo data |
writer |
Can commit to non-main branches, submit reviews, add comments |
maintainer |
All writer permissions + create branches, merge, rebase, delete branches, create releases |
admin |
All maintainer permissions + manage roles, delete releases, purge commits |
Role enforcement
The RBAC middleware (internal/server/middleware.go) checks roles for all /repos/{name}/-/ endpoints. The specific rules:
| Action | Minimum role |
|---|---|
Any GET |
reader |
POST /commit (to non-main branch) |
writer |
POST /commit (to main directly) |
maintainer |
POST /comment, DELETE /comment/* |
writer |
PATCH /branch/* (draft promotion) |
writer |
POST /branch, POST /merge, POST /rebase |
maintainer |
DELETE /branch/* |
maintainer |
POST /releases |
maintainer |
DELETE /releases/* |
admin |
GET /roles, PUT /roles/*, DELETE /roles/* |
admin |
Bootstrap admin
If BOOTSTRAP_ADMIN=alice@example.com is set on the server, that identity has admin access to any repo that has no admin assigned yet. Once a repo has at least one admin, the bootstrap flag is ignored for that repo.
Managing roles
# Via CLI (requires a workspace in the target repo):
ds roles # list roles
ds roles set bob@example.com writer # assign
ds roles delete bob@example.com # remove
# Via API:
PUT /repos/acme/platform/-/roles/bob@example.com
{"role": "writer"}
DELETE /repos/acme/platform/-/roles/bob@example.com
OPA policy engine
The policy engine runs on POST /repos/{name}/-/merge (and GET /repos/{name}/-/branch/{name}/status for dry-run evaluation). It evaluates all .rego files in .docstore/policy/ on the main branch.
Bootstrap mode
If no .rego files exist, the engine is nil and all merges are allowed. This avoids a chicken-and-egg problem when bootstrapping a repo before any policies are in place.
Policy file format
Each .rego file must declare package docstore.<name> and define an allow rule (and optionally a reason rule):
package docstore.require_review
import rego.v1
default allow = false
allow if {
# At least one approved review at or after the branch head.
some review in input.reviews
review.status == "approved"
review.sequence >= input.base_sequence
}
reason = "at least one approved review is required" if {
not allow
}
The policy name is derived from the last segment of the package path (e.g. require_review).
Policy input
Every policy evaluation receives an Input struct as input:
{
"actor": "alice@example.com",
"actor_roles": ["maintainer"],
"action": "merge",
"repo": "acme/platform",
"branch": "feature/x",
"draft": false,
"changed_paths": ["config.yaml", "docs/guide.md"],
"reviews": [
{"reviewer": "bob@example.com", "status": "approved", "sequence": 43}
],
"check_runs": [
{"check_name": "ci/build", "status": "passed", "sequence": 43},
{"check_name": "ci/test", "status": "passed", "sequence": 43}
],
"owners": {
"docs/": ["carol@example.com"],
"": ["alice@example.com", "bob@example.com"]
},
"head_sequence": 43,
"base_sequence": 30
}
Field details:
actor— Identity performing the merge.actor_roles— The actor's roles on this repo (always a list; typically one element).action— Always"merge"for policy evaluation.branch— Branch being merged.draft— Whether the branch is a draft.changed_paths— All file paths changed on the branch relative tobase_sequence.reviews— Reviews created athead_sequenceor any earlier sequence on this branch. Stale means the review was created before the current head.check_runs— Same staleness rule as reviews.owners— Map from path prefix to list of owner emails. Derived from OWNERS files (see below). The""key is the root OWNERS file.head_sequence— Current head sequence of the branch.base_sequence— The sequence onmainat which the branch was created or last rebased.
Evaluation rules
- Each policy file is evaluated independently. All must pass (
allow = true) for the merge to proceed. - Evaluation has a 5-second timeout per policy. A timed-out policy returns an error (HTTP 500), not a silent deny.
- The policy result for each file is returned in the merge/status response:
json {"name": "require_review", "pass": false, "reason": "at least one approved review is required"}
Policy caching
Compiled OPA policies are cached per repo. The cache is invalidated when:
- A commit is pushed directly to main (POST /repos/{name}/-/commit with branch=main).
- A merge is completed (POST /repos/{name}/-/merge).
OWNERS files
OWNERS files define code owners per directory. The server loads them from the materialized main tree when building the policy input.
Format
An OWNERS file is a plain text file, one email per line:
alice@example.com
bob@example.com
Place OWNERS files at any directory level:
OWNERS (root owners)
docs/OWNERS (owners for docs/ and below)
config/prod/OWNERS (owners for config/prod/ and below)
Longest-prefix matching
For each changed file path, the server finds the longest-prefix OWNERS file. For example, if docs/guide.md is changed and both OWNERS and docs/OWNERS exist, docs/OWNERS wins. The resolved owners for each prefix are included in input.owners.
Using OWNERS in policies
package docstore.require_owners_approval
import rego.v1
default allow = false
# Find the owners for a given path by longest-prefix match.
owners_for(path) := owners if {
prefixes := {prefix | input.owners[prefix]; startswith(path, prefix)}
prefix := max(prefixes)
owners := input.owners[prefix]
}
allow if {
every path in input.changed_paths {
required_owners := owners_for(path)
some review in input.reviews
review.status == "approved"
review.sequence >= input.base_sequence
review.reviewer in required_owners
}
}
reason = "all changed paths must be approved by their OWNERS" if {
not allow
}
Deploying policies
Add .rego files to .docstore/policy/ on the main branch:
mkdir -p .docstore/policy
cat > .docstore/policy/require_review.rego <<'EOF'
package docstore.require_review
...
EOF
ds checkout main
ds commit -m "add require_review policy"
The policy takes effect immediately on the next merge attempt (cache is invalidated by the commit to main).
Raw markdown — machine-readable source for this page.