Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

docs/v1/estate-approvers.md

The estate's approvers

Superseded by docs/v2 chapter 9, "Who approves", which follows main. This page is kept for its history; where the two differ, chapter 9 is right. Corrected here (HOL-DOCFIND, 2026-11-01): an invite into an approver group needs a stepped-up passkey session; the console bearer is refused (holger-auth-6).

Every Holger estate boots the same server declaration. It is generated by holger_recipe::estate_ron and staged on the holger and holger-keep media by cargo xtask estate-media. That declaration turns on the intake chain with promotion.intake.approvals: 2. An artifact therefore reaches APPROVED only after two different people approve it, and neither of them may be the principal that imported it.

Who holds which duty

Who Role Duties
console (the bearer the installer feeds every box) Admin Importer, RepositoryAdmin, Auditor
group holger-admins (the first passkey admin) Admin Developer, SecurityApprover, RepositoryAdmin, Auditor. Never Importer.
group holger-writers Writer Developer, Importer
group holger-readers Reader Developer
group holger-security-approvers Reader Developer, SecurityApprover
group holger-security-auditors Reader Developer, Auditor

The console principal never approves, because it holds no SecurityApprover duty and because it is the importer of everything it uploads. With only the console login, nothing can be approved. Two approvals need two people.

Adding a second approver

The owner is the estate's first passkey admin and holds SecurityApprover. The second approver is added through the existing invite path:

  1. An admin mints an invite into the approver group. This needs the admin's own passkey session, stepped up (a fresh user-verified touch of the passkey). A machine credential, the console bearer included, is refused with holger-auth-6:
curl -sS -X POST https://<estate>/-/auth/passkey/invites \
  -H "Authorization: Bearer <admin session>" \
  -H 'Content-Type: application/json' \
  -d '{"display": "bert", "groups": ["holger-security-approvers"]}'

The answer carries the invite secret once. It is single-use and expires.

  1. The invitee opens the console's invite link with that secret and registers a passkey. The invite decides the group; the invitee cannot choose another one.

  2. The invitee logs in. From then on, two approvals are possible: the owner's and the invitee's. For a third person, mint another invite in the same way.

An admin may also invite by duty instead of naming the group: {"display": "bert", "duty": "security_approver"} lands in holger-security-approvers, and {"duty": "importer"} lands in holger-writers. The group is the one with the fewest duties that grants the duty, and an admin group is never picked.

The first passkey admin holds no Importer duty. With duties configured, only an Importer ingests into incoming (HTTP and gRPC), so the admin never imports and may approve every upload. Uploads come from the console bearer or from an invited Importer.

An invite never grants admin. An invite by role ("role": "reader") lands in holger-readers and gives no approval duty. The approver group's name sorts after holger-readers on purpose, so an invite by role cannot pick it.

Walking an artifact

  1. An Importer (the console bearer, or a writer) uploads to incoming. The artifact is INGESTED, and nothing serves it.
  2. The Importer advances it to SCANNED. The required scanners run there: vulnerability reads the local intel database at /data/holger/intel, and sbom reads the wheel. Then it advances to POLICY_CHECKED.
  3. Two SecurityApprovers advance it. The first vote is recorded (1 of 2), and the second makes it APPROVED. Each vote is an attested statement in the approval evidence slot.
  4. A SecurityApprover or the RepositoryAdmin seals it onward: SEALED, PROMOTED, AVAILABLE. Production then serves it, and production/simple/ lists it for pip.

The wire verb is POST /-/intake/advance?name=<file>&verb=advance (HTTP) or PromotionService.AdvanceArtifact (gRPC). tests/the_estate_declaration_runs_the_intake_chain.rs in server/lib walks exactly this with the shipped declaration.

The estate's name

One medium serves every estate. The declaration says https://repo.{estate} for the crates repository's base URL, and the box fills in the estate name that the ONE box's installer feeds it (the join reply's estate, such as test3.holger.rs). An unpaired boot drops the placeholder, and the default applies.

What is not on the estate

A box whose declaration grants no duty

A RON written before duties existed (or by hand without them) would give every Admin all five duties. The box's boot then takes the estate's table itself (server_lib::auth::estate_duties, called from server/cli/src/paired.rs): an Admin subject or group holds RepositoryAdmin and SecurityApprover and never Importer, other subjects and groups keep the duties their role implies, and the groups holger-importers, holger-approvers and holger-auditors are added for invites by duty. A declaration that grants any duty, as the shipped one does, is left exactly as written. The proof is server/lib/tests/an_estate_admin_invites_its_importer_and_approver.rs: on a fresh box the first admin invites an Importer and a second approver, and a wheel reaches AVAILABLE.