Skip to content

Policy and Access Control

DocStore has three layers of access control:

  1. IAP authentication — validates GCP Identity-Aware Proxy JWTs to establish identity.
  2. RBAC — per-repo role assignments that gate which HTTP methods each identity can call.
  3. 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 to base_sequence.
  • reviews — Reviews created at head_sequence or 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 on main at 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.