c2d21097
Rewrite repo governance around a gitolite-style settings repo
a73x 2026-08-09 16:19
Commit message
docs/superpowers/specs/2026-08-09-repo-governance-design.md
| Old | New | ||
|---|---|---|---|
| @@ -1,189 +1,176 @@ | |||
| 1 | # Repository Governance — Design | 1 | # Repository Governance — Design |
| 2 | 2 | ||
| 3 | Date: 2026-08-09 | 3 | Date: 2026-08-09 |
| 4 | Status: Superseded in part — needs revising against | 4 | Status: Draft — supersedes the hook/`refs/for`/in-band-policy design of the same |
| 5 | `2026-08-09-patch-revision-refs-design.md` | 5 | date. Depends on `2026-08-09-patch-revision-refs-design.md`. |
| 6 | |||
| 7 | That document adopts the design test "if a feature only works when the server is | ||
| 8 | `git-collab-server`, it is not a git-collab feature." Three parts of this design | ||
| 9 | fail it and are withdrawn: the `pre-receive` hook (Enforcement · At receive), the | ||
| 10 | `refs/for` contribution class and its conversion (Q3), and in-band policy on | ||
| 11 | `refs/collab/policy`, which was only enforceable by the hook. Governance returns | ||
| 12 | to a settings repository and per-repository read/write authorization. Q2 is | ||
| 13 | answered in the affirmative by that return; Q1 is unaffected. | ||
| 14 | |||
| 15 | The ref-class table goes down with the hook as an enforcement mechanism, but | ||
| 16 | survives as a description of what a host with per-ref control can apply — this | ||
| 17 | forge cannot. The threat model, the failure modes and the repository lookup key | ||
| 18 | carry into the revision unchanged. | ||
| 19 | 6 | ||
| 20 | ## Purpose | 7 | ## Purpose |
| 21 | 8 | ||
| 22 | Make the server's governance a property of the repositories it serves, rather | 9 | Govern the server the way gitolite does: a git repository holds the keys and the |
| 23 | than of files on the host. Who may reach the server, who may move a repository's | 10 | access rules, an admin pushes to it to reconfigure the server, and a push that |
| 24 | canonical state, and who may contribute to it all become git objects: reviewable, | 11 | would install a broken config is rejected rather than applied. Nothing is edited |
| 25 | attributable, and revertable. | 12 | on the host. |
| 13 | |||
| 14 | The addition is patches. Under a branch workflow, contribution requires write | ||
| 15 | access to `refs/heads/*`, so restricting a contributor means granting the | ||
| 16 | namespace and then carving `main` back out with deny rules — fragile, and easy | ||
| 17 | to get subtly wrong. With patches carried entirely under `refs/collab/*` | ||
| 18 | (revision-refs design), contribution is a single prefix grant and canonical | ||
| 19 | branches are never mentioned in a contributor's rule at all. | ||
| 26 | 20 | ||
| 27 | ## Deployment context | 21 | ## Deployment context |
| 28 | 22 | ||
| 29 | Single-tenant. One operator working from several machines, plus agent identities | 23 | Single-tenant. One operator across several machines, plus agent identities. The |
| 30 | that contribute code to individual repositories. The server runs as a pod; | 24 | server runs as a pod; out-of-band recovery is `kubectl exec`. The web UI is |
| 31 | out-of-band recovery is `kubectl exec`, and does not depend on the service being | 25 | internet-facing. |
| 32 | governed. The web UI is internet-facing. | ||
| 33 | 26 | ||
| 34 | ## Threat model | 27 | ## Threat model |
| 35 | 28 | ||
| 36 | The adversary is **a compromised agent credential**. It can open an SSH | 29 | The adversary is **a compromised agent credential**. It can open an SSH |
| 37 | connection and push. It must not be able to: | 30 | connection and push. It must not be able to move any repository's canonical |
| 38 | 31 | state, widen its own access, reach a repository it was not granted, or create | |
| 39 | - move any repository's canonical state, | 32 | repositories outside its own namespace. |
| 40 | - widen its own access, | ||
| 41 | - reach a repository it was not granted, | ||
| 42 | - create repositories. | ||
| 43 | |||
| 44 | Governance is deliberately **readable** by anyone who can read the repository it | ||
| 45 | governs. Hiding it would require excluding a ref from the fetch advertisement, | ||
| 46 | and a contributor is entitled to know the rules it is subject to. | ||
| 47 | |||
| 48 | An adversary with write access to the storage directory is out of scope, because | ||
| 49 | the SSH host private key already lives there (`main.rs:38`, | ||
| 50 | `<repos_dir>/.server/host_key`). Anyone who can reach that directory can | ||
| 51 | impersonate the server outright. If the host key moves out of `repos_dir`, revisit | ||
| 52 | this — the internet-facing HTTP server runs in the same process and uid | ||
| 53 | (`main.rs:87-102`), so it is the shortest path to that directory. | ||
| 54 | |||
| 55 | ## Governing invariant | ||
| 56 | 33 | ||
| 57 | **Governance lives on refs its subject cannot write.** | 34 | An adversary with write access to the storage directory is out of scope: the SSH |
| 35 | host private key already lives there (`main.rs:38`, | ||
| 36 | `<repos_dir>/.server/host_key`), so that access permits impersonating the server | ||
| 37 | outright. If the host key moves out of `repos_dir`, revisit this — the | ||
| 38 | internet-facing HTTP server runs in the same process and uid (`main.rs:87-102`). | ||
| 58 | 39 | ||
| 59 | A policy tracked in the working tree of the repository it governs fails this — | 40 | Agents running as local worktrees on the operator's machine are a **different** |
| 60 | write access would become admin access. A policy on a ref that ordinary pushes | 41 | threat and are out of scope here. Such an agent holds the operator's key and |
| 61 | cannot touch satisfies it, and keeps governance in the same repository as the | 42 | shell; no server-side control can distinguish it. Its containment is that its |
| 62 | thing governed, so there is no second repository to clone and no state to keep in | 43 | deliverable is a patch the operator reads before merging, plus per-agent signing |
| 63 | sync. | 44 | keys for after-the-fact attribution. That is a review boundary, and it is the |
| 45 | real one. | ||
| 64 | 46 | ||
| 65 | Satisfying the invariant requires the server to authorize **individual ref | 47 | ## Design test |
| 66 | updates**. | ||
| 67 | 48 | ||
| 68 | ## Model | 49 | > If a feature only works when the server is `git-collab-server`, it is not a |
| 50 | > git-collab feature. | ||
| 69 | 51 | ||
| 70 | ### Ref classes | 52 | Access control passes this test. A contributor's workflow is byte-identical on |
| 53 | every host — `git-collab sync` — and on this forge a push they should not be | ||
| 54 | making is refused. That is what branch protection is, and it is not part of the | ||
| 55 | collaboration protocol. | ||
| 71 | 56 | ||
| 72 | Every push falls into exactly one class, and the class determines who may make | 57 | Server-side *protocol* work fails the test. `refs/for/<branch>` conversion, |
| 73 | it. This replaces per-principal write flags entirely. | 58 | in-band policy interpretation, and anything that rewrites what a client pushed |
| 59 | are out of scope permanently, not deferred. | ||
| 74 | 60 | ||
| 75 | | Class | Refs | Who may write | | 61 | The distinction is what the client must know. A doorman is invisible to it; a |
| 76 | |---|---|---| | 62 | translator is not. |
| 77 | | Canonical | `refs/heads/*`, `refs/tags/*` | Delegates | | ||
| 78 | | Governance | `refs/collab/policy` | Delegates, threshold-signed | | ||
| 79 | | Collaboration | `refs/collab/issues/*`, `refs/collab/patches/*`, `refs/collab/archive/*` | Any reader | | ||
| 80 | | Contribution | `refs/for/<branch>` (virtual) | Any reader | | ||
| 81 | 63 | ||
| 82 | An unclassified ref is denied. `refs/collab/archive/*` is in the collaboration | 64 | ## The settings repository |
| 83 | class because `sync` pushes it (`sync.rs:226-243`) and `patch merge` writes to it | ||
| 84 | (`patch.rs:696`); omitting it would break both for every non-delegate. | ||
| 85 | 65 | ||
| 86 | A contributor never needs write access to a branch. A compromised agent | 66 | `settings.git` is an ordinary repository on the server, governed by the same |
| 87 | credential cannot move canonical state in any repository — there is no setting | 67 | rules as any other, and reconfigures the server when pushed to. |
| 88 | that grants it. | ||
| 89 | 68 | ||
| 90 | ### Contribution via `refs/for` | 69 | ``` |
| 70 | conf/access.conf repo blocks and access rules | ||
| 71 | keydir/<name>.pub one public key per file | ||
| 72 | keydir/<dir>/<name>.pub same, for a second device | ||
| 73 | ``` | ||
| 91 | 74 | ||
| 92 | `refs/for/<branch>` is virtual: no such ref exists in storage. A push to it is | 75 | ### Identity |
| 93 | converted server-side into a patch, or a new revision of the contributor's | ||
| 94 | existing patch for that branch. The refname names only the target branch, so a | ||
| 95 | contributor needs no local state beyond a remote. | ||
| 96 | 76 | ||
| 97 | The revision model, interdiff (`patch.rs:501`) and revision-anchored inline | 77 | The identity is the **basename** of the key file, with directories ignored, as in |
| 98 | comments (`state.rs:193`) already exist; this adds the push-side entry point. | 78 | gitolite. `keydir/laptop/alex.pub` and `keydir/desktop/alex.pub` are both `alex`. |
| 99 | Note that plain thread comments carry no revision (`state.rs:77`) — only inline | ||
| 100 | comments and reviews do. | ||
| 101 | 79 | ||
| 102 | ### Delegates | 80 | This is the whole answer to one operator with several machines: adding a device |
| 81 | is adding a file, and revoking one is `git rm`. No syntax, and the diff is | ||
| 82 | legible in review. | ||
| 103 | 83 | ||
| 104 | A repository's policy names its delegates and the threshold of delegate | 84 | On the wire the principal is still the key fingerprint (`ssh_key_principal`, |
| 105 | signatures required to amend it. A repository begins with one delegate, its | 85 | `session.rs:171`). The server maps fingerprint to name at authentication time, so |
| 106 | creator, and a single-delegate amendment is self-accepting, so ordinary | 86 | names appear in config and fingerprints appear on the connection. An unmapped |
| 107 | single-operator use involves no review step. | 87 | fingerprint is not a principal and is refused. |
| 108 | 88 | ||
| 109 | ### Identity | 89 | ### Rules |
| 110 | 90 | ||
| 111 | One namespace throughout: SSH key fingerprints, the string `ssh-keygen -lf` | 91 | ``` |
| 112 | prints, prefixed with `key:`. The same string appears in every list. | 92 | @admins = alex |
| 93 | @agents = claude-a claude-b | ||
| 113 | 94 | ||
| 114 | ## Policy document | 95 | repo settings |
| 96 | RW+ = @admins | ||
| 115 | 97 | ||
| 116 | `refs/collab/policy` carries a single TOML document. | 98 | repo tools |
| 99 | RW+ = @admins | ||
| 100 | RW refs/collab/ = @agents | ||
| 101 | R = @all | ||
| 117 | 102 | ||
| 118 | ```toml | 103 | repo agents/[a-z].* |
| 119 | delegates = ["key:SHA256:Gzb/mmBlyDmoWq3hMyPjb3SRWklPJ/yDAoD1K+ATvJA"] | 104 | C = @agents |
| 120 | threshold = 1 | 105 | RW+ = CREATOR |
| 106 | ``` | ||
| 121 | 107 | ||
| 122 | visibility = "public" # omitted ⇒ private | 108 | Line-oriented rather than TOML, deliberately. Evaluation is ordered first-match, |
| 123 | description = "build tooling" | 109 | and order is load-bearing; TOML tables do not express an ordered rule list |
| 110 | without an array-of-tables that reads far worse than the above. | ||
| 124 | 111 | ||
| 125 | [ui] | 112 | Semantics follow gitolite: a refex defaults to `refs/.*`, a bare name is |
| 126 | anonymous = true # omitted ⇒ false; browse the web UI | 113 | implicitly `refs/heads/`, refexes anchor at the start but not the end, `-` denies, |
| 114 | `@all` matches everything, and rules are gathered in order and filtered to the | ||
| 115 | accessing principal before the first match decides. | ||
| 127 | 116 | ||
| 128 | [http] | 117 | The permission set is `R`, `RW`, `RW+`, `C`. `C` is create, needed only for the |
| 129 | anonymous_clone = true # omitted ⇒ false; clone and download releases | 118 | wild-repo pattern below. Deletion is folded into `RW+` rather than given its own |
| 119 | letter; a single-tenant forge does not need the distinction, and it can be split | ||
| 120 | out later without changing any existing rule's meaning. | ||
| 130 | 121 | ||
| 131 | [access] | 122 | The `@agents` line above is the point of the whole design: an agent's grant is |
| 132 | read = ["key:SHA256:…agent"] # omitted ⇒ empty; "*" means any authenticated principal | 123 | one prefix, and `refs/heads/` never appears in it. |
| 133 | ``` | ||
| 134 | 124 | ||
| 135 | Every default is the closed value; an absent field denies. `RepoPolicy`'s current | 125 | ### Wild repositories |
| 136 | serde defaults are the opposite (`repos.rs:129` returns `["*"]`, `repos.rs:125` | ||
| 137 | returns `Public`), so the governance policy type defines its own closed defaults | ||
| 138 | rather than deriving them. | ||
| 139 | 126 | ||
| 140 | `access.read` is the only per-principal list, and governs authenticated access. | 127 | `repo agents/[a-z].*` with `C = @agents` lets an agent create its own repository |
| 141 | `visibility`, `ui.anonymous` and `http.anonymous_clone` govern the anonymous HTTP | 128 | without a central allocator, and `RW+ = CREATOR` gives it full control of what it |
| 142 | surface, where no principal exists — they are a separate axis, not a shorthand | 129 | created and nothing else. Two agents cannot collide, because the pattern places |
| 143 | for `access.read`. | 130 | each in its own path. |
| 144 | 131 | ||
| 145 | A policy push is authorized against the **previous** revision's `delegates` and | 132 | This is what removes the need for a server-level authority over repository |
| 146 | `threshold`, never its own, so a policy cannot authorize itself. Signatures are | 133 | creation. Creation is a rule like any other. |
| 147 | carried as commit signatures on the policy ref. | ||
| 148 | 134 | ||
| 149 | ## Enforcement | 135 | ## Enforcement |
| 150 | 136 | ||
| 151 | Authorization happens at two points, because the server learns the repository and | 137 | Two points, because the server learns the repository and verb at one moment and |
| 152 | the verb at one moment and the refs being written at a later one. | 138 | the refs at a later one. |
| 153 | |||
| 154 | ### At dispatch, once | ||
| 155 | 139 | ||
| 156 | Immediately after the requested path is resolved and before the command is | 140 | **At dispatch**, immediately after path resolution and before the command is |
| 157 | dispatched (`session.rs:506`), so every exec verb inherits it — `git-upload-pack`, | 141 | dispatched (`session.rs:506`), so every exec verb inherits it — `git-upload-pack`, |
| 158 | `git-receive-pack`, and each `collab-release` subcommand, including verbs added | 142 | `git-receive-pack`, and each `collab-release` subcommand including ones added |
| 159 | later. | 143 | later. This resolves repository-level access: `R` to fetch, any of `RW`/`RW+`/`C` |
| 160 | 144 | to push, `RW+` to upload or delete a release, `R` to list releases. | |
| 161 | | Operation | Rule | | 145 | |
| 162 | |---|---| | 146 | **At receive**, per ref, via a server-managed hook that applies the refex rules. |
| 163 | | Open SSH connection | Principal is a recognized identity (see Q1) | | 147 | The hook is installed and owned by the server, is never authored by a user, and |
| 164 | | Clone / fetch | Delegate, or in `access.read`, or public and anonymous | | 148 | does no protocol work — it answers "may this principal write this ref" and |
| 165 | | Any push | May read the repository — ref classes are checked later | | 149 | nothing else. This is the gitolite `update` hook, and it is the kind of hook the |
| 166 | | Release upload / delete | Delegate | | 150 | design test permits. |
| 167 | | Release list | May read the repository | | 151 | |
| 168 | | Create a repository | See Q2 | | 152 | Two adjacent changes are prerequisites. `run_git_command` (`session.rs:708`) |
| 169 | 153 | spawns `git-receive-pack` with the server's inherited environment and nothing | |
| 170 | Release *download* has no SSH verb; it is an HTTP route | 154 | per-request, so passing the principal is new. And the child's stderr is piped and |
| 171 | (`http/repo/releases.rs:72`) gated by `http.anonymous_clone`. The releases listing | 155 | never read (`session.rs:711-716`), so a rejection message is currently discarded |
| 172 | page is gated by `ui.anonymous` — two different knobs on one page, retained | 156 | and a chatty hook can fill the pipe buffer and wedge the child. |
| 173 | deliberately so a repository can publish artifacts without publishing a browsable | 157 | |
| 174 | UI. | 158 | ### Validation on push |
| 175 | 159 | ||
| 176 | ### At receive, per ref | 160 | A push to `settings.git` is validated before it takes effect: the config must |
| 177 | 161 | parse, every key file must be a well-formed public key, and the result must leave | |
| 178 | A server-managed `pre-receive` hook applies the ref-class table. It receives the | 162 | at least one principal with `RW+` on `settings`. A push failing any of these is |
| 179 | authenticated principal in its environment. Today `run_git_command` | 163 | rejected, and the previous configuration stays live. |
| 180 | (`session.rs:708`) spawns `git-receive-pack` with the server's inherited | 164 | |
| 181 | environment and nothing per-request, so the principal plumbing is new, as is hook | 165 | This deletes a failure mode rather than handling it. The superseded design had a |
| 182 | installation — there are no hooks anywhere in the tree. | 166 | malformed-policy path, a bounded-staleness window and two timeout values to |
| 183 | 167 | choose; none are needed, because a config that does not compile never becomes the | |
| 184 | Two adjacent changes are required for a hook to work at all: the child's stderr | 168 | running config. |
| 185 | is currently piped and never read (`session.rs:711-716`), so a hook's rejection | 169 | |
| 186 | text is discarded and a chatty hook can fill the pipe buffer and wedge the child. | 170 | Reading remains fallible at runtime — a genuine IO error on the config blob is |
| 171 | classified by `git2::ErrorCode`, not by message inspection, and closes the server | ||
| 172 | to writes rather than opening it. `NotFound` is an authoritative absence, not an | ||
| 173 | IO error. | ||
| 187 | 174 | ||
| 188 | ### Refusal behaviour | 175 | ### Refusal behaviour |
| 189 | 176 | ||
| @@ -191,71 +178,46 @@ Dispatch-time refusals — unknown repository, unreadable repository — return | |||
| 191 | identical `repository not found` reply and exit status, so a scoped credential | 178 | identical `repository not found` reply and exit status, so a scoped credential |
| 192 | cannot map what else exists. | 179 | cannot map what else exists. |
| 193 | 180 | ||
| 194 | Ref-class refusals are necessarily distinguishable: by the time a hook runs, the | 181 | Per-ref refusals are necessarily distinguishable: by the time the hook runs the |
| 195 | ref advertisement has already disclosed the repository's refs, and the rejection | 182 | advertisement has already disclosed the repository's refs. This is acceptable |
| 196 | surfaces as a per-ref status line. This is acceptable because the principal has | 183 | because the principal has already passed the read check for that repository. |
| 197 | already passed the read check for that repository — it learns nothing it was not | ||
| 198 | entitled to. | ||
| 199 | 184 | ||
| 200 | ### Repository lookup key | 185 | ### Repository lookup key |
| 201 | 186 | ||
| 202 | The key is the resolved path relative to the storage directory with one trailing | 187 | The resolved path relative to the storage directory with one trailing `.git` |
| 203 | `.git` removed, `/` separators preserved, matched byte-exactly. Nested paths are | 188 | removed, `/` separators preserved, matched byte-exactly. `private/tools.git` and |
| 204 | keys in full: `private/tools.git` and `tools.git` are distinct repositories with | 189 | `tools.git` are distinct repositories. |
| 205 | distinct policies. | ||
| 206 | |||
| 207 | Path resolution already accepts nested paths (`session.rs:967`), but `discover` | ||
| 208 | is non-recursive (`repos.rs:217`) and HTTP resolves by final component | ||
| 209 | (`repos.rs:241`), so nested repositories are currently unreachable over HTTP and | ||
| 210 | `tools.git` and `tools` in the same directory already collide there. Nested keys | ||
| 211 | therefore require reworking HTTP resolution and the URL scheme, not only the SSH | ||
| 212 | path. | ||
| 213 | |||
| 214 | ### Limits | ||
| 215 | 190 | ||
| 216 | Release storage is capped per repository in aggregate as well as per file. Only | 191 | Wild repositories make nested paths load-bearing rather than optional. Path |
| 217 | the per-file cap exists today (`releases.rs:123`, one global `max_release_size`); | 192 | resolution already accepts them (`session.rs:967`), but `discover` is |
| 218 | without an aggregate cap, filling the storage volume is a reachable way to force | 193 | non-recursive (`repos.rs:217`) and HTTP resolves by final component |
| 219 | the IO-error path in Failure modes. | 194 | (`repos.rs:241`), so nested repositories are unreachable over HTTP today and |
| 195 | `tools.git` and `tools` already collide there. HTTP resolution and the URL scheme | ||
| 196 | must be reworked as part of this. | ||
| 220 | 197 | ||
| 221 | ## Bootstrap | 198 | ## Bootstrap |
| 222 | 199 | ||
| 223 | The server reads its storage location and listener addresses before it can read | 200 | The server reads its storage location and listener addresses before it can read |
| 224 | any git object, so these become startup flags: `--repos-dir`, `--http-bind`, | 201 | any git object, so these become startup flags: `--repos-dir`, `--http-bind`, |
| 225 | `--ssh-bind`. They are config-file fields today (`config.rs:7-18`); the existing | 202 | `--ssh-bind`. They are config-file fields today (`config.rs:7-18`); the existing |
| 226 | `--config` flag and the file itself are removed. | 203 | `--config` flag and `server.toml` are removed. `site_title` and |
| 204 | `max_release_size` move into `settings.git`. | ||
| 227 | 205 | ||
| 228 | Of the remaining `server.toml` fields, `site_title` and `max_release_size` become | 206 | An init container creates `settings.git` with one key in `keydir/` and a minimal |
| 229 | git objects. `authorized_keys` holds a *path* to an OpenSSH-format file read on | 207 | `conf/access.conf` granting that principal `RW+` on `settings`. From there every |
| 230 | every auth attempt (`session.rs:417`); whether that file survives at all depends | 208 | change is a push. `authorized_keys` (`session.rs:417`, re-read on every auth |
| 231 | on Q1, and moving it into a git object also replaces the per-attempt reload. | 209 | attempt) is replaced by `keydir/`, which also removes the per-attempt file read. |
| 232 | 210 | ||
| 233 | Server-level authority, if it exists (Q2), is rooted at a pinned genesis commit | 211 | ## Not stolen |
| 234 | passed as `--genesis <oid>`. The admin chain is a ref whose each revision must be | ||
| 235 | signed by a threshold of the previous revision's admins; the pinned genesis | ||
| 236 | distinguishes the operator's chain from one an attacker substitutes. Key | ||
| 237 | rotation, adding a machine, and removing a compromised credential are signed | ||
| 238 | commits on that chain, and the out-of-band residue is one hash. | ||
| 239 | 212 | ||
| 240 | ## Failure modes | 213 | - The Perl implementation, and any runtime dependency on gitolite. `gitolite |
| 241 | 214 | access <repo> <user> <perm> [ref]` would work as an out-of-process oracle, but | |
| 242 | | Condition | Behaviour | | 215 | a Perl install in the pod is a poor trade against a few hundred lines of Rust |
| 243 | |---|---| | 216 | for a single-tenant forge. |
| 244 | | Policy ref absent | Delegates only | | 217 | - `authorized_keys` regeneration and forced commands. We authenticate in-process |
| 245 | | Object read fails (IO) | Last known good, bounded; then closed | | 218 | via russh (`session.rs:411`) and never hand off to a shell, so the file gitolite |
| 246 | | Document malformed | Deny that repository | | 219 | generates has no role here. |
| 247 | 220 | - A unix user per install, and gitolite's `@all`-repo administrative commands. | |
| 248 | The stale window is bounded by both elapsed time and consecutive failures; | ||
| 249 | exceeding either closes the repository. Unbounded staleness would leave a | ||
| 250 | superseded policy in force after a revocation lands, with no visible symptom. | ||
| 251 | Both bounds need values before implementation. | ||
| 252 | |||
| 253 | Classification is by `git2::ErrorCode`, not by inspecting messages: a `NotFound` | ||
| 254 | on the ref or blob is an authoritative absence and closes; only a genuine IO | ||
| 255 | error is eligible for the stale path. | ||
| 256 | |||
| 257 | Server-level authority, where it exists, is evaluated before any repository | ||
| 258 | policy is read and is unaffected by all of the above. | ||
| 259 | 221 | ||
| 260 | ## Testing | 222 | ## Testing |
| 261 | 223 | ||
| @@ -265,57 +227,44 @@ on generation (`tests/common/mod.rs:704`), and asserts success on every git | |||
| 265 | invocation (`:558`), so multiple repositories, multiple principals, a bare-repo | 227 | invocation (`:558`), so multiple repositories, multiple principals, a bare-repo |
| 266 | commit helper and a non-panicking push wrapper are prerequisites. | 228 | commit helper and a non-panicking push wrapper are prerequisites. |
| 267 | 229 | ||
| 268 | - A contributor pushes `refs/for/main`; a patch revision appears. | 230 | - An agent granted `RW refs/collab/` pushes a patch and its revisions, and a full |
| 269 | - The same credential pushing `refs/heads/main` is refused. | 231 | `git-collab sync` completes. |
| 270 | - The same credential pushing `refs/collab/policy` is refused. | 232 | - The same credential pushing `refs/heads/main` is refused, and the refusal |
| 271 | - The same credential pushing `refs/collab/issues/*` and `refs/collab/archive/*` | 233 | message reaches the client. |
| 272 | succeeds, and a full `git-collab sync` completes. | 234 | - The same credential pushing `settings` is refused. |
| 273 | - A delegate pushes `refs/heads/main` and `refs/collab/policy` successfully. | 235 | - Two keys with different basenames in `keydir/` are distinct principals; two |
| 274 | - A policy push signed by a key named only in the *new* revision's delegates is | 236 | files sharing a basename across directories are the same principal. |
| 275 | refused. | 237 | - A push to `settings.git` whose config does not parse is rejected and the prior |
| 276 | - A repository naming neither `access.read` nor the principal is invisible: | 238 | config stays live. |
| 277 | clone, release download and every HTTP route refuse identically. | 239 | - A push to `settings.git` that leaves no principal with `RW+` on `settings` is |
| 278 | - A policy document parsed from the empty string denies everything. | 240 | rejected. |
| 279 | - `private/tools.git` and `tools.git` resolve to distinct policies. | 241 | - An agent creates `agents/claude-a` under the wild pattern and has `RW+` on it; |
| 280 | - A malformed policy denies that repository. | 242 | a second agent cannot write it. |
| 281 | - A policy push takes effect on the next request, with no restart. | 243 | - A repository granting a principal nothing is invisible to it: clone, release |
| 282 | - A rejected ref-class push surfaces the hook's message to the client. | 244 | download and every HTTP route refuse identically. |
| 245 | - `private/tools.git` and `tools.git` resolve to distinct rule sets. | ||
| 246 | - A config push takes effect on the next request, with no restart. | ||
| 283 | 247 | ||
| 284 | ## Open questions | 248 | ## Open questions |
| 285 | 249 | ||
| 286 | **Q1 · Enrolled keys or certificates.** | 250 | **Enrolled keys or certificates.** This design assumes `keydir/`. Certificates |
| 287 | *Enrolled keys*: a git-controlled key set; revocation is a commit; credentials do | 251 | would suit short-lived agent credentials — expiry without a revocation commit, |
| 288 | not expire. *Certificates*: a CA public key pinned at startup; credentials expire | 252 | no roster to store — at the cost of the readable key roster that makes `keydir/` |
| 289 | without a revocation commit, which suits short-lived agents; no roster to store. | 253 | worth stealing. `russh` verifies a certificate before dispatching to the handler |
| 290 | `russh` verifies a certificate before dispatching to the handler | 254 | (`server/encrypted.rs:788-810`), but dispatch is either/or and the current handler |
| 291 | (`server/encrypted.rs:788-810`), but dispatch is either/or — the current handler | ||
| 292 | implements only `auth_publickey` (`session.rs:411`), so certificates are rejected | 255 | implements only `auth_publickey` (`session.rs:411`), so certificates are rejected |
| 293 | by the default implementation until `auth_openssh_certificate` is implemented, and | 256 | by the default implementation until `auth_openssh_certificate` is implemented, |
| 294 | pinning the CA is the handler's job since `verify_signature` only proves the | 257 | and pinning the CA is the handler's job — `verify_signature` only proves the |
| 295 | certificate is self-consistent. Decides whether a key set exists to be stored. | 258 | certificate is self-consistent. |
| 296 | 259 | ||
| 297 | **Q2 · Whether server-level authority exists.** | 260 | The two compose: certificates for agents, `keydir/` for the operator's devices. |
| 298 | It is needed only because repository creation consumes a shared namespace: two | 261 | That is probably the answer, but it needs the certificate path built first. |
| 299 | principals cannot both create `tools.git`. *Keep it*: a genesis-rooted admin chain | ||
| 300 | on a ref in one otherwise-empty repository. *Drop it*: namespace repositories by | ||
| 301 | key, so creation never collides and nothing central is required, at the cost of | ||
| 302 | longer paths and changes to path resolution, HTTP resolution and the URL scheme. | ||
| 303 | Decides whether anything central survives, and interacts with the lookup key | ||
| 304 | above. | ||
| 305 | |||
| 306 | Q1 constrains Q2: with certificates and no key set, the admin chain is the only | ||
| 307 | remaining reason for a central repository. | ||
| 308 | |||
| 309 | **Q3 · Where `refs/for` conversion lives.** | ||
| 310 | A `pre-receive` hook can accept or reject a ref update; it cannot rewrite a | ||
| 311 | virtual ref into a patch. Converting the push requires code that owns the received | ||
| 312 | objects — either a hook that writes the patch DAG itself and then rejects the | ||
| 313 | virtual ref, or a receive-pack wrapper. This is unassigned and is the largest | ||
| 314 | unknown in the design. | ||
| 315 | 262 | ||
| 316 | ## Out of scope | 263 | ## Out of scope |
| 317 | 264 | ||
| 318 | - Multi-tenant delegation and repository handover. | 265 | - Multi-tenant delegation and repository handover. |
| 319 | - Web UI authentication. HTTP remains anonymous and read-only (`http/mod.rs:19-59`); | 266 | - Web UI authentication. HTTP remains anonymous and read-only |
| 320 | all writes are over SSH. | 267 | (`http/mod.rs:19-59`); all writes are over SSH. |
| 321 | - Signature verification of anything other than the policy ref and the admin chain. | 268 | - Signature verification of anything beyond the collab event DAG, which is |
| 269 | unchanged. Access control is by key identity at the transport, not by event | ||
| 270 | signature. | ||