Request-access runbook: opening, reviewing, and absorbing access-request PRs¶
This runbook is the operator-facing procedure for the byreis request-access
contributor verb and the byreis rotate --add --from-request <PR> admin
verb. Together they form the in-band promotion path: a contributor proposes
that their age public key be added to a project's recipient set; an admin
reviews the proposal as a pull request against the registry repo, then
absorbs the proposed key into the recipient set through a normal rotation.
The two verbs preserve the asymmetric-access invariant: the contributor uses
their own GitHub identity and acquires no admin credential or registry-write
key at any step; the admin uses their existing keychain-backed admin
identity and never re-derives a contributor signing capability. The rotation
that absorbs the request is a normal rotate --add with the PR provenance
recorded in the audit log.
Read this end-to-end before opening or absorbing a request-access PR. The state-machine matrix and exit-code table in this document are the canonical references for the verb's accept/refuse decisions.
TL;DR¶
- Contributor side:
byreis request-access --key <age1...> --justification "..." --registry <owner/repo>opens a PR against the registry repo. The PR adds a single file atrequests/<your-handle>.yamlcontaining the proposed pubkey, your GitHub login, and a free-text justification. The PR is opened from your own fork using your own GitHub identity; no admin credential or registry-write key is acquired. - Admin side:
byreis rotate --add --from-request <registry>#<number> --project <id>fetches the PR's YAML, runs the 9-mode state-machine validation, prints the admin warning explaining the PR-author-vs-YAML byte-compare semantics, then fires the typed-fingerprint confirm gate before absorbing the proposed pubkey into a rotation. - Quota: at most 5 open
request-accessPRs per contributor identity per registry. Close stale PRs before opening a new one. - Public-registry caveat: if the registry repo is public, the linkage
handle -> age_pubkeyis publicly readable. See "Public-registry-repo disclosure surface" below.
Contributor procedure¶
Prerequisites¶
- A GitHub identity with
readaccess to the registry repo (the standard shape; the registry adopter MAY configure broader access, see the "Adopter configuration" section below). - A personal fork of the registry repo. If you do not have one, create it
with
gh repo fork <registry>before running the verb. The fork is the source-of-record for the PR's HEAD; same-repo PRs from a contributor with registry-write access are an adopter-configuration concern (see "Same-repo PR adopter footgun" below). - An
agekeypair. The verb takes only the public key (age1...); the private key never leaves your machine and is never sent to the registry. - A GitHub token in
BYREIS_GITHUB_TOKENorGH_TOKEN. The verb uses the same authentication source asbyreis submit; no new keychain credential is required, and no admin or registry-write capability is acquired.
Opening a request¶
Run, from any directory:
byreis request-access \
--key age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
--justification "team A onboarding for project foo" \
--registry myorg/byreis-admins
The verb:
- Refuses if your current mode is ADMIN or SUPER (the verb is contributor-only; admins do not need to open access requests).
- Reads your GitHub login from the token-bearer identity (or accepts an
explicit
--handle <login>override if you need to disambiguate). - Checks your open-PR quota against the registry; refuses with
ErrRequestAccessQuotaExceededif you already have 5 or more openrequest-accessPRs. - Builds a single
requests/<your-handle>.yamlfile with your pubkey, handle, justification, and an RFC3339 timestamp; verifies the YAML parses through the schema strict-decoder before pushing anything. - Creates a branch named
byreis/request-access-<handle>-<unix-time>on your fork, commits the YAML file with a fixed-template commit message (no operator-controlled body), and opens a PR against the registry's default branch.
The verb prints the PR URL on success. The next step is admin review.
What an admin will see and do¶
The admin runs byreis rotate --add --from-request <registry>#<number>
--project <id> against the same project they would otherwise rotate. The
admin's tool fetches your YAML at the PR HEAD, performs the state-machine
validation described below, displays the admin warning explaining the
byte-compare semantics, then asks the admin to type the full 64-character
SHA-256 fingerprint of your proposed pubkey to confirm.
If the validation refuses, the admin sees a sentinel error with a hint
pointing back to the action you need to take (e.g. "close the PR and reopen
with a clean commit set", "regenerate via byreis request-access"). The
admin does NOT have to merge the registry PR before running the rotation;
the rotation commit IS the endorsement, and the rotation audit row records
the PR URL, PR HEAD SHA, and your validated GitHub login as the proof of
provenance.
If the validation succeeds and the admin completes the fingerprint confirmation, your pubkey is added to the project's recipient set in a single rotation transaction. From the next rotation forward, you can decrypt the project's secrets (subject to the project's normal admin read-path discipline).
Updating or rotating your own pubkey¶
The runbook above is also the procedure for replacing your own pubkey: open
a new request-access PR with the new pubkey; the admin absorbs it via
rotate --add --from-request, then independently removes the old key via
rotate --remove <old-age1-key> (which fires the forward-secrecy warning).
Combining the two intents into a single transaction is also supported via
rotate --replace <old>=<new> after the new key is already in the
recipient set.
Admin procedure¶
Prerequisites¶
- ADMIN mode resolved cryptographically by
byreisstartup (admin private key present, perms 0600, registered in the admin set). The verb is denied-by-policy for CONTRIBUTOR before any registry fetch. BYREIS_GITHUB_TOKEN(orGH_TOKEN) with read access to the registry repo's PR list. The verb reads exclusively; no write capability against the registry repo is acquired through this verb.- A
--projectflag identifying which project to rotate. Absorbing a request-access PR against project A does not change project B's recipient set; rotations are per-project.
Running the absorption¶
The verb:
- Mode-gates first: CONTRIBUTOR is denied with no network contact.
- Parses the PR ref into
<owner>/<repo>#<number>form; rejects malformed values. - Fetches the PR HEAD SHA and the
requests/<handle>.yamlcontent at that SHA via a read-only GitHub call. The HEAD SHA is pinned for the force-push-race re-check at step 5. - Runs the state-machine validation described in State-machine matrix below. A refusal returns the matching sentinel with an actionable hint and exits without any Phase-1 work.
- Prints the verbatim
RequestAccessAdminWarningblock (see Admin warning below). - Displays a confirm prompt showing the PR author login, the YAML handle,
the sanitized justification, and the first 16 chars of the proposed
pubkey's SHA-256 fingerprint. The admin must type the full 64-char
fingerprint to proceed; mismatch refuses with
ErrRotationFingerprintMismatch. - Re-fetches the PR HEAD SHA and asserts byte-equal to the pinned value.
A drift means the contributor pushed between plan and execute; the
verb refuses with
ErrRequestAccessPRForcePushed. Re-run to re-fetch and re-review the new content. - Proceeds into the normal
rotate --addPhase-1 / Phase-2 pipeline with the validated pubkey injected into the add list. The rotation audit event records the PR URL, PR HEAD SHA, the validated YAML handle, and the validated PR author login as proof of provenance.
Admin warning¶
The verb prints this block verbatim before the fingerprint-confirm prompt. The exact wording is the single source of truth in the shipped code; this runbook reproduces it for operator reference, but the live emission is the authoritative copy:
WARNING: this rotation absorbs a recipient pubkey from a contributor's
request-access PR. Before confirming:
- The YAML's github_handle field is byte-compared to the PR opener's
GitHub login (`pull_request.user.login`). It is NOT compared to
the PR title, body, description, comments, or any commit-author email.
- Visually verify the PR opener IS the human you intend to grant access
to (run `gh pr view <PR>` to confirm the GitHub account is the
intended contributor). A login byte-match alone is not a substitute
for that visual check.
- Commit-author divergence (a PR whose commits are authored by a
different identity than the PR opener) AND force-push races between
plan and execute are caught structurally, but the typed-fingerprint
confirm below is your last line of defense — inspect the SHA-256
fingerprint of the recipient and type the full 64-char value to
proceed. Mismatch refuses the rotation.
What the tool catches structurally vs what you must verify manually¶
Caught structurally (you can rely on these without inspection):
- Closed, draft, merged, or bot-authored PRs are refused before validation.
- YAML
github_handlenot matchingpull_request.user.loginbyte-equal (post-lowercase) is refused withErrRequestAccessIdentityMismatch. - Any commit on the PR whose author login diverges from the PR opener is
refused with
ErrRequestAccessCommitAuthorDivergence. - Force-push between fetch and confirm (a content swap on the PR head
branch) is caught by the HEAD SHA pin: the SHA captured at plan time is
re-asserted byte-equal at execute time, and any drift is refused with
ErrRequestAccessPRForcePushed. - Fork-ownership transfer between fetch and confirm (a source swap, where
the PR head branch points at the same SHA but the fork repository has
changed hands) is caught by a separate byte-equal re-assertion of
pull_request.head.repo.owner.loginat execute time; any drift is refused withErrRequestAccessForkOwnershipChanged. The SHA pin alone is not sufficient for this case because a transferred fork can retain identical content, so the fork-owner login is checked independently. - YAML schema violations, unknown fields, non-ASCII handles, justifications
over 1000 bytes, and malformed
age1pubkeys are all refused at decode time withErrRequestAccessSchemaInvalid. - File-set changes outside
requests/<handle>.yamlare refused withErrRequestAccessPRFilePathInvalid.
You MUST verify manually before typing the fingerprint:
- Visually confirm the PR opener (
gh pr view <PR>) is the human you intend to grant access to. The structural checks prove thatpull_request.user.loginmatches the YAML handle byte-for-byte, but they do not prove that this GitHub account belongs to the person you think it does. - Confirm the project ID on your
--projectflag is the project you intend to rotate. The PR is project-agnostic; the rotation is per-project. - Confirm the proposed pubkey is one the contributor actually controls (out-of-band: a fingerprint exchange over a side-channel before they open the PR is a common pattern at scale).
State-machine matrix¶
The validation step accepts exactly one PR shape and refuses every other.
The matrix below is the canonical reference; the live behaviour is asserted
by the request_test.go table-driven test row-by-row.
# PR-state acceptance matrix for `byreis rotate --add --from-request <PR>`
# Source-of-truth GitHub API fields:
# pull_request.state ∈ {"open", "closed"}
# pull_request.draft bool
# pull_request.merged bool
# pull_request.user.login string (must equal yaml.github_handle byte-equal post-lowercase)
# pull_request.head.sha string (pinned at fetch-time, re-asserted at execute-time)
# pull_request.head.repo.owner.login string (pinned for fork-ownership-change detection)
accepted:
- {state: open, draft: false, merged: false, user.login: <non-empty, non-"ghost">}
refused_with_sentinel:
- condition: {state: closed, merged: true}
sentinel: ErrRequestAccessPRStateInvalid
hint: "this PR has already been merged; if you intend to re-rotate the same recipient, run rotate --add directly"
- condition: {state: closed, merged: false}
sentinel: ErrRequestAccessPRStateInvalid
hint: "this PR has been closed; the contributor must reopen or open a new request-access PR"
- condition: {state: open, draft: true}
sentinel: ErrRequestAccessPRStateInvalid
hint: "this PR is a draft; the contributor must mark it ready-for-review before admin absorption"
- condition: {user.login: "ghost" OR ""}
sentinel: ErrRequestAccessIdentityMismatch
hint: "the PR author's GitHub account is deleted or anonymous; refuse the request"
- condition: {yaml.github_handle != user.login (post-lowercase, post-ASCII-validation)}
sentinel: ErrRequestAccessIdentityMismatch
hint: "the YAML github_handle does not match the PR author's GitHub login"
- condition: {any commit author.login != user.login}
sentinel: ErrRequestAccessCommitAuthorDivergence
hint: "the PR contains commits whose author differs from the PR opener; the contributor must close and reopen with a clean commit set"
- condition: {head.sha at execute != head.sha at fetch}
sentinel: ErrRequestAccessPRForcePushed
hint: "the PR was force-pushed between plan and execute; re-run `byreis rotate --add --from-request <PR>` to re-fetch and review the new content"
- condition: {head.repo.owner.login at execute != head.repo.owner.login at fetch}
sentinel: ErrRequestAccessForkOwnershipChanged
hint: "the contributor's fork ownership changed between plan and execute; re-run to re-evaluate"
- condition: {files-changed contains anything outside requests/<handle>.yaml}
sentinel: ErrRequestAccessPRFilePathInvalid
hint: "the PR changes files outside the requests/ namespace; refuse"
- condition: {YAML strict-decode fails (unknown field, duplicate key, malformed)}
sentinel: ErrRequestAccessSchemaInvalid
hint: "the YAML payload does not match the schema; the contributor must regenerate via `byreis request-access`"
- condition: {YAML justification > 1000 bytes}
sentinel: ErrRequestAccessSchemaInvalid
hint: "justification exceeds 1000 bytes; the contributor must shorten and regenerate"
- condition: {YAML github_handle contains non-ASCII or invalid GitHub login chars}
sentinel: ErrRequestAccessSchemaInvalid
hint: "github_handle must be ASCII-only and conform to GitHub's login alphabet ([A-Za-z0-9-]{1,39})"
advisory_only (NOT refused, logged as warning):
- condition: {PR has been closed-then-reopened}
action: log warning, proceed if all other state checks pass
hint: "visually verify the reopen reason via `gh pr view`"
Exit codes¶
The contributor verb (byreis request-access) and the admin path
(byreis rotate --add --from-request) share the project's standard exit
classes:
ok(0) — verb completed; PR opened (contributor) or rotation absorbed (admin).permission-denied(3) — the caller is not in the required mode. Contributor verb denies ADMIN/SUPER; admin path denies CONTRIBUTOR.auth-error(4) — GitHub token is missing or invalid; admin path also surfaces this when the admin's keychain identity cannot be loaded or the admin is outside the project's pre-rotation recipient set (a different admin in the pre-rotation set must run the rotation).counter-reconcile(6) — the admin path observed a partial rotation state and refused to start a new rotation against it. Runbyreis admin rotation reconcileagainst the project to recover; then re-run the absorption.trust-error(7) — registry view is stale or unverified; refresh withbyreis registry refreshand retry. Also surfaces on rotation reversal probe defects (re-runbyreis admin rotation reconcile).decode-malformed(9) — the YAML payload failed schema strict-decode on the admin path. The contributor must regenerate viabyreis request-access.general-error(1) — anything else, includingErrRequestAccessPRStateInvalid,ErrRequestAccessPRForcePushed,ErrRequestAccessForkOwnershipChanged,ErrRequestAccessQuotaExceeded, andErrRotationFingerprintMismatch. Each of these arrives with an actionable hint; the hint identifies the next operator step.
Public-registry-repo disclosure surface¶
Some adopters configure the admin registry repo as a public GitHub
repository (for example, to use GitHub Pages or to make the admin set
visible to external auditors). For those adopters, the requests/
namespace is also publicly readable: every requests/<handle>.yaml
file (including the contributor's GitHub handle and age public key) is
visible to anyone with a browser.
The age public key itself is by construction a public key; the
cryptographic guarantees of age Model B are not weakened by its
publication. However, the linkage <github-handle> -> <age public key>
becomes a public fact. For adopters who treat their recipient set as
semi-confidential (for example, organisations that do not publicly
disclose which engineers have access to production), this linkage is an
information disclosure.
If your adopter is in that category, the operational guidance is:
- Configure the registry repo as a PRIVATE GitHub repository. The verb works identically against a private registry; the only difference is who can browse the repo's files.
- Treat the registry repo's access list as part of your asymmetric-access invariant: anyone who can read the registry can read every contributor's pubkey-to-handle linkage and every admin's pubkey.
- The byreis tool does not refuse to operate against a public registry; the public-vs-private choice is the adopter's, and the tool surfaces this notice exactly once, here in the runbook, rather than as a repeated CLI warning.
If you are unsure about your registry's visibility, run
gh repo view <registry> --json visibility before opening a
request-access PR.
Adopter configuration¶
Same-repo PR adopter footgun¶
The canonical request-access flow assumes the contributor opens the PR from a fork of the registry repo. The PR is then a fork-PR: the head repo is the contributor's fork, and the contributor has no registry-write capability.
Some adopters configure their registry repo so that all members of the
organisation have push access (a "small team, everyone is trusted"
shape). In that configuration, a contributor with registry-repo write
access could open a same-repo PR — pushing the requests/<handle>.yaml
branch directly to the registry repo rather than to a fork.
The byreis tool does NOT distinguish a fork-PR from a same-repo PR at the validation layer: both pass through the same state-machine matrix. The structural checks remain effective:
- The single-file scope check refuses any PR that modifies files outside
requests/<handle>.yaml, so a same-repo PR cannot smuggle in a recipient-set change to a separate file. - The PR-author-vs-YAML byte-compare refuses any PR whose
user.logindoes not match the YAML'sgithub_handle, so a same-repo PR cannot spoof a different contributor's identity.
However, the canonical adopter configuration is "contributors have ZERO registry-repo write; only fork-PR path", because that configuration also rules out direct branch manipulation of the registry repo by non-admins (a broader concern than the request-access flow). To pin that configuration:
- Configure the registry repo's branch-protection to require fork-PR
review for any change to
requests/*paths. - Restrict registry-repo push access to admins only; require
contributors to fork the repo for any change, including
request-access.
These are registry-repo configuration choices, not byreis settings. The tool's behaviour is unchanged either way; the operator's trust posture is the variable.
Quota tuning¶
The per-contributor open-PR quota (default 5) is a client-side
advisory limit enforced by the byreis request-access verb. It is not
a server-side enforcement; a contributor who runs an unmodified gh pr
create directly against the registry can still open arbitrarily many
PRs. The quota is calibrated to the routine case (one open PR per
in-flight pubkey rotation), not the adversarial case.
For the adversarial case, the registry repo's branch-protection rules
are the enforcement layer: configure them to require admin review on
all requests/* paths, and the volume of open PRs becomes a denial-of-
service nuisance at most.
The quota check is itself a best-effort early-fail: between the open-PR
count probe and the actual PR creation (a few network round-trips), the
same contributor — or a parallel byreis request-access invocation on
a different machine under the same account — can race additional PRs
past the limit. GitHub's server-side rate limits and the registry's
branch-protection rules are the authoritative quota enforcers; the
client-side check exists to give a cleaner error message in the common
case, not to bind an adversary.
Related reading¶
docs/forward-secrecy.md— whatbyreis rotate --removedoes and does not guarantee about pre-rotation ciphertext. A request-access absorption is an additive--add; it does not touch the forward-secrecy question unless the same rotation also removes a recipient.docs/rotation-runbook.md— recovering from a partial rotation viabyreis admin rotation reconcile, including the Phase-1/Phase-2 classification and the manual Phase-2 mid-flight recovery procedure. An absorption rotation that lands a partial state surfaces through the same recovery path.byreis doctor— general diagnostic verb; surfaces counter drift, unverified registry trust, and key/permission issues. Run before opening or absorbing a request-access PR if anything about the project's recipient set looks unexpected.