coverage~bigbes/sr-ht-spec3cb1c03dauthn/doc.go

Coverage
100.0% 1/1 statements
Δ
Blob
3670a64
Uncovered nothing — every instrumented line ran
1 // Package authn answers one question for spec.sr.ht — "who is making this
2 // request?" — and builds the git provenance that answers "who made this write?"
3 // forever after.
4 //
5 // The design has exactly two principals that carry authority:
6 //
7 // - the human owner, recognised by the shared `sr.ht.unified-login.v1`
8 // cookie carrying the instance's [sr.ht] owner-name;
9 // - an agent, recognised by a bearer token.
10 //
11 // Everything else is anonymous. Single-user does not mean "no authorization";
12 // it relocates it onto agents, which is why Principal distinguishes those two
13 // and nothing finer. The boundary that bounds an agent's damage is the refs rule
14 // (agents may only write proposals/*), and that lives in gitx; nothing here
15 // replaces it.
16 //
17 // # One agent credential plane
18 //
19 // An agent is recognised by a tokens.sr.ht working token — PlaneInstance:
20 // signed by the instance, expiring, owned by a meta.sr.ht account, and carrying
21 // a grant set (ActionPropose, ActionRead). It is validated by sr-ht-ecore's
22 // bearer package, and it is the only credential this service authenticates an
23 // agent with.
24 //
25 // spec used to mint its own as well — the agent_token row: one instance-wide
26 // shared secret, hashed at rest, with no owner, no expiry and no grants. That
27 // plane is gone. Issuance is centralised in tokens.sr.ht, so there is one door
28 // and nothing behind it: a credential this plane refuses is refused, rather than
29 // being offered to a second store that might say yes. A well-formed token from
30 // another issuer (a meta.sr.ht PAT — bearer.ErrNotOurs) used to fall through to
31 // that store and now fails at the door, which is the same answer one hash lookup
32 // later, said honestly.
33 //
34 // The instance plane names an owner where the local secret had none.
35 // Principal.Owner means "the human this agent acts for", which on this
36 // single-owner instance is always [sr.ht] owner-name — a token belonging to
37 // anybody else is refused rather than admitted as a second identity, because
38 // every consumer of that field (the provenance committer, the refs rule's
39 // principal kind, the coreauth AuthContext) is written for one human.
40 //
41 // Grants are orthogonal to the refs rule and to provenance, and replace neither.
42 // A grant says what an instance token was minted for; the refs rule still says
43 // where an agent may point a ref, and provenance is still mandatory on every
44 // agent write.
45 //
46 // The cookie and the bearer planes are deliberately asymmetric:
47 //
48 // - A cookie that is missing, forged, expired or unreadable yields an
49 // anonymous principal and never an error. Browsing must keep working.
50 // - A bearer token that is present but unknown, revoked or corrupt is a hard
51 // failure. An agent that presented an explicit credential must not be
52 // silently downgraded to a reader; it would then fail confusingly at the
53 // write instead of clearly at the door.
54 //
55 // Provenance is the other half. Agent identity and session ID are mandatory on
56 // every agent write, and are recorded in the commit itself so that a plain
57 // `git log` on any clone carries the audit trail:
58 //
59 // Author: claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
60 // Committer: bigbes <bigbes@gmail.com>
61 //
62 // Add storage model section
63 //
64 // X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
65 // X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809
66 //
67 // A write missing either field is rejected rather than defaulted: a commit
68 // stamped with a guessed session is worse than no commit, because it launders
69 // unattributable output as attributed.
70 //
71 // This package owns no storage and opens no connections. The "user" row an
72 // instance token's owner resolves to is reached through UserLookup, and the
73 // tokens.sr.ht validator through BearerValidator. authn never imports db and
74 // never calls core-go's auth.LookupUser itself, so the dependency arrow keeps
75 // pointing downward and the whole package stays testable with no Postgres and no
76 // daemon to talk to.
77 package authn
78
79 import (
80 "errors"
81
82 "sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
83 )
84
85 // Sentinel errors. Callers compare with errors.Is. The split that matters is
86 // permanent (the credential is bad — 401/403) versus transient (the backend
87 // could not answer — 503); IsAuthFailure draws it.
88 //
89 // Everything the credential itself can be wrong about is now spelled by
90 // sr-ht-ecore's bearer package — ErrInvalid, ErrNotOurs, ErrRevoked — because
91 // there is one issuer and one validator. The sentinels below are what this
92 // service adds on top of that answer.
93 var (
94 // ErrNoToken is returned when a bearer credential was expected but the
95 // request carried no Authorization header, or one in another scheme.
96 ErrNoToken = errors.New("no agent token presented")
97
98 // ErrNoAgentPlane is returned when a bearer credential is presented to a
99 // resolver that was built without the tokens.sr.ht plane — an instance whose
100 // config.ini has no [tokens.sr.ht] origin. It is a wiring failure and not a
101 // credential failure, so it is deliberately not an IsAuthFailure: telling an
102 // agent its token is bad when the truth is that this service cannot check
103 // any token would send it off to re-provision a perfectly good credential.
104 ErrNoAgentPlane = errors.New("no agent credential plane is configured")
105
106 // ErrNotAgent is returned when agent provenance is demanded of a principal
107 // that is not an agent — the human push path builds no trailers.
108 ErrNotAgent = errors.New("principal is not an agent")
109
110 // ErrMissingProvenance marks an agent write that omits the agent identity,
111 // the session ID, or the base revision. Mandatory on every agent write; the
112 // design is explicit that these are not defaultable.
113 ErrMissingProvenance = errors.New("missing agent provenance")
114
115 // ErrInvalidProvenance marks provenance whose values are present but
116 // unusable: control characters or angle brackets that would forge a git
117 // signature line or inject an extra trailer, an over-long field, or a base
118 // revision that is not a hex object name.
119 ErrInvalidProvenance = errors.New("invalid agent provenance")
120
121 // ErrMissingConfig is returned by InstanceFromConfig when the instance
122 // config lacks a key the provenance identities are built from. It is a
123 // startup failure, not a request failure.
124 ErrMissingConfig = errors.New("missing instance config key")
125
126 // ErrNotInstanceOwner marks a valid tokens.sr.ht working token whose owner is
127 // somebody other than the instance owner. 403: the credential verifies and
128 // the holder is who they say they are, there is simply nothing on this
129 // single-owner instance to grant them. Deliberately not an IsAuthFailure —
130 // presenting it again will not help and neither will logging in.
131 ErrNotInstanceOwner = errors.New("token owner is not the instance owner")
132
133 // ErrMissingGrant marks an instance token that authenticated fine but does
134 // not carry the action being attempted. 403, for the reason
135 // bearer.ErrForbidden is: what the holder needs is a wider grant, not another
136 // login.
137 //
138 // It is raised by Principal.Authorize, at the layer that knows the action —
139 // never by the resolver, which runs before the router and so knows none.
140 ErrMissingGrant = errors.New("token does not grant this action")
141 )
142
143 // IsAuthFailure reports whether err is a permanent credential failure — the
144 // caller should answer 401/403 — as opposed to a transient backend failure,
145 // which should answer 503 and be retried. Everything not in this set is
146 // transient by definition, which is the fail-closed direction: a backend outage
147 // never reads as a valid credential.
148 //
149 // bearer.ErrNotOurs joined the set when the local plane left it. A meta.sr.ht
150 // PAT used to fall through to spec's own store, where it missed; with one door
151 // there is nothing to fall through to, and "that credential was issued by
152 // somebody whose tokens this service does not take" is as permanent a refusal as
153 // a signature that does not verify. bearer.ErrUnavailable is pointedly absent —
154 // see StatusFor, which is what surfaces should map with.
155 16 func IsAuthFailure(err error) bool {
156 16 return errors.Is(err, ErrNoToken) ||
157 16 errors.Is(err, bearer.ErrInvalid) ||
158 16 errors.Is(err, bearer.ErrNotOurs) ||
159 16 errors.Is(err, bearer.ErrRevoked)
160 16 }