coverage~bigbes/sr-ht-spec64cae3afauthn/principal.go

Coverage
97.3% 36/37 statements
Δ
-2.7
Blob
2bdf509
Uncovered L226-L227
1 package authn
2
3 import (
4 "context"
5 "fmt"
6
7 "sourcecraft.dev/bigbes/sr-ht-ecore/grants"
8 )
9
10 // Kind enumerates the principals spec.sr.ht distinguishes. There are three
11 // values but only two of them carry authority: the design's "authorization is
12 // about agents, not people" collapses every human other than the instance owner
13 // into the anonymous case, because there is no second human in the model to
14 // grant anything to.
15 type Kind string
16
17 const (
18 // KindAnonymous is an unauthenticated request — no cookie, an unreadable
19 // cookie, or a cookie belonging to somebody who is not the instance owner.
20 // It is a normal, expected state: the read plane is anonymous-capable.
21 KindAnonymous Kind = "anonymous"
22
23 // KindOwner is bigbes: the unified-login cookie resolved to the username in
24 // [sr.ht] owner-name. This is the principal whose git push *is* the
25 // approval, and the only one that may approve a proposal.
26 KindOwner Kind = "owner"
27
28 // KindAgent is a bot holding a tokens.sr.ht working token. It may propose
29 // and it may read; the refs rule in gitx is what stops it touching the
30 // approved branch.
31 KindAgent Kind = "agent"
32 )
33
34 // Plane names the credential plane an agent authenticated on.
35 //
36 // What the field draws is not "which of two stores said yes" but "in which
37 // vocabulary, if any, was this credential scoped". Only PlaneInstance carries a
38 // tokens.sr.ht grant set, so a check that reads Grants has to know whether there
39 // were any to read — and reading the zero set of a plane that was never scoped
40 // in that grammar would refuse a caller for lacking a permission its credential
41 // could not have been minted with.
42 //
43 // Empty for every principal that is not an agent, and for the one agent that is
44 // not credential-backed: `specsrht doc propose`, which runs as the operator on
45 // the daemon's own host and names an agent for provenance rather than
46 // authenticating one. Resolver never produces an agent with an empty plane —
47 // every agent it resolves came through the tokens.sr.ht validator.
48 type Plane string
49
50 const (
51 // PlaneInstance is a tokens.sr.ht working token: signed, expiring, owned by
52 // a meta.sr.ht account, and carrying the grant set Authorize checks.
53 PlaneInstance Plane = "instance"
54
55 // PlaneMeta is a meta.sr.ht personal access token, scoped in meta's own OAuth
56 // vocabulary (ScopeRead) rather than in tokens.sr.ht's, and reachable on
57 // /query alone.
58 //
59 // It exists because api.sr.ht forwards one client credential to every service
60 // a federated query touches, so a /query that refused the credential the rest
61 // of the instance uses could never be federated. MetaAuth's own comment
62 // carries the argument; what matters here is the scope of the exception —
63 // nothing on the REST, MCP or push paths can produce this plane, because none
64 // of them holds a MetaAuth, so a PAT is not a way around the grants those
65 // surfaces require.
66 //
67 // A principal on this plane is KindAgent and never KindOwner, even though the
68 // token belongs to the instance owner's own meta account. KindOwner is the
69 // human at a browser, and it is the only principal that may approve a proposal
70 // or manage a webhook; promoting a credential that any process holding a
71 // string can present into that role would hand the approved branch to whatever
72 // is holding it.
73 PlaneMeta Plane = "meta"
74 )
75
76 // Principal is the resolved identity of a request. It is a value type with no
77 // pointers into request state, so it can be stashed in a context, logged, and
78 // passed to service/ without aliasing surprises.
79 //
80 // This is what the API layer and gitx's refs rule branch on, and it is
81 // deliberately the narrowest thing that supports both: which kind, and — for an
82 // agent — the two provenance fields that every agent write must carry, plus
83 // which credential plane it came in on and what that credential permits.
84 //
85 // It is not comparable with ==: Grants holds a set. Compare the fields that
86 // matter, or the String() rendering. The set itself is immutable once parsed —
87 // grants.Grants has no mutating method — so copies sharing it is not the
88 // aliasing this type's value semantics are guarding against.
89 type Principal struct {
90 // Kind is which of the three principals this is. The zero value is the
91 // anonymous case, so a Principal read out of a context that never had one
92 // set is safe rather than privileged.
93 Kind Kind
94
95 // Owner is the instance owner username (no leading '~') this principal acts
96 // as or on behalf of: itself for KindOwner, the human an agent writes for
97 // for KindAgent. Empty for KindAnonymous.
98 Owner string
99
100 // Agent is the agent identity string, e.g. "claude-code/spec-writer".
101 // KindAgent only. It may be empty on a read — it is demanded at the write,
102 // which is the only place the design requires it.
103 Agent string
104
105 // Session is the agent's session ID, e.g. a UUID. KindAgent only, with the
106 // same read/write asymmetry as Agent.
107 Session string
108
109 // TokenName names the credential that authenticated this request: the
110 // tokens.sr.ht row id, or "stateless" for a token short enough that the
111 // daemon never wrote it down. KindAgent only, diagnostics only — it grants
112 // nothing.
113 TokenName string
114
115 // CookieUser is whatever username the unified-login cookie carried, even
116 // when that user was not the instance owner and Kind is therefore
117 // KindAnonymous. Display and logging only: never an authorization input.
118 CookieUser string
119
120 // Plane is which credential plane authenticated an agent. Empty for every
121 // other kind. Authorize reads it to decide whether Grants means anything.
122 Plane Plane
123
124 // Grants is what the instance token this request carried permits, parsed.
125 // PlaneInstance only; the zero value everywhere else, which grants nothing
126 // and is why Authorize checks Plane before it checks the set.
127 Grants grants.Grants
128
129 // UserID is the id of the local "user" row the instance token's owner
130 // resolved to. PlaneInstance only, and zero for a principal no credential
131 // backs.
132 UserID int
133 }
134
135 // Anonymous returns the principal for an unauthenticated request.
136 52 func Anonymous() Principal { return Principal{Kind: KindAnonymous} }
137
138 // IsAnonymous reports whether the principal carries no authority. Written as
139 // "not one of the two that do" so that an unrecognised or zero Kind is denied
140 // rather than accidentally admitted.
141 33 func (p Principal) IsAnonymous() bool { return p.Kind != KindOwner && p.Kind != KindAgent }
142
143 // IsOwner reports whether this is the human owner — the principal that may
144 // approve proposals and whose pushes need no review.
145 15 func (p Principal) IsOwner() bool { return p.Kind == KindOwner }
146
147 // IsAgent reports whether this is an agent — the principal gitx confines to
148 // proposals/*.
149 24 func (p Principal) IsAgent() bool { return p.Kind == KindAgent }
150
151 // CanRead reports whether this principal may read content: the owner and its
152 // agents may, nobody else may. This is the whole read-plane ACL — one human, no
153 // visibility levels, and a non-owner human already resolved to anonymous by
154 // authn — and it lives here, in one place, because every read surface (graph's
155 // /query, the web UI, the MCP tools) must apply the identical policy: two read
156 // surfaces with two spellings of it is how a corpus leaks.
157 6 func (p Principal) CanRead() bool { return p.IsOwner() || p.IsAgent() }
158
159 // Authorize reports whether the credential behind this principal covers action
160 // — one of the ActionPropose / ActionRead constants.
161 //
162 // It is a grant check and nothing else. It says nothing about who the principal
163 // is, so every caller must already have made the identity decision (IsAgent for
164 // the write plane, CanRead for the read plane); calling this alone would
165 // "authorize" an anonymous request, because an anonymous request carries no
166 // instance token and so has no grant to be missing. The two questions are
167 // separate on purpose: the resolver answers identity in middleware, upstream of
168 // the router, and only the layer that knows the action can ask this one.
169 //
170 // A principal off the instance plane passes, and each of the three ways that
171 // happens is deliberate rather than a hole left over from the agent_token days:
172 //
173 // - The owner's cookie is a person, whose authority is their identity. There is
174 // no grant to read, and checking a zero set would refuse every logged-in
175 // human on the site.
176 // - The CLI's locally asserted agent runs as the operator on the daemon's own
177 // host and presented nothing to have a grant clipped out of.
178 // - A meta.sr.ht personal access token (PlaneMeta) is scoped in a vocabulary
179 // this method does not speak. No PAT can ever carry "spec:read" — meta's
180 // personal-token page cannot spell it — so checking one here would refuse
181 // every PAT on the instance rather than scope it, which is the opposite of
182 // what a grant check is for. A PAT is scoped once, in meta's own grammar, at
183 // the point it is resolved (MetaAuth.VerifyToken), and /query's read gate is
184 // the only surface it can reach at all.
185 //
186 // Every agent the resolver produces is on the instance plane and is checked here.
187 17 func (p Principal) Authorize(action string) error {
188 17 if p.Plane != PlaneInstance {
189 6 return nil
190 6 }
191 11 if !p.Grants.Has(action) {
192 5 return fmt.Errorf("%w: the instance token grants %q, which does not cover %q",
193 5 ErrMissingGrant, p.Grants.String(), action)
194 5 }
195 6 return nil
196 }
197
198 // String renders the principal for logs. It never includes the token name's
199 // secret (there is none — the name is not the token) and never includes the
200 // cookie value.
201 9 func (p Principal) String() string {
202 9 switch p.Kind {
203 2 case KindOwner:
204 2 return "owner ~" + p.Owner
205 4 case KindAgent:
206 4 agent := p.Agent
207 4 if agent == "" {
208 1 agent = "(unnamed)"
209 1 }
210 4 session := p.Session
211 4 if session == "" {
212 1 session = "(no session)"
213 1 }
214 4 line := fmt.Sprintf("agent %s session %s for ~%s", agent, session, p.Owner)
215 4 // Each credential-backed agent is annotated with the plane that admitted
216 4 // it, and only the instance plane's annotation carries a grant set: that
217 4 // set is what its annotation says, and neither a PAT nor an agent a local
218 4 // process asserted has one to print. Naming the plane is what lets a log
219 4 // line distinguish the two credentials afterwards, which is the whole
220 4 // reason a reader would look — a PAT reaches /query and nothing else, so
221 4 // "which plane" is also "which surface" when one turns up somewhere
222 4 // surprising.
223 4 switch p.Plane {
224 1 case PlaneInstance:
225 1 line += " (tokens.sr.ht: " + p.Grants.String() + ")"
226 0 case PlaneMeta:
227 0 line += " (meta.sr.ht personal access token)"
228 }
229 4 return line
230 3 default:
231 3 if p.CookieUser != "" {
232 1 return "anonymous (cookie user ~" + p.CookieUser + ")"
233 1 }
234 2 return "anonymous"
235 }
236 }
237
238 // AgentWriteFor builds the provenance inputs for an agent write at the given
239 // base revision, enforcing that the mandatory fields are present. It fails for
240 // a non-agent principal: the human write path goes through native
241 // receive-pack and constructs no commit here.
242 10 func (p Principal) AgentWriteFor(base string) (AgentWrite, error) {
243 10 if !p.IsAgent() {
244 2 return AgentWrite{}, fmt.Errorf("%w: %s", ErrNotAgent, p)
245 2 }
246 8 w := AgentWrite{Agent: p.Agent, Session: p.Session, Base: base}
247 8 if err := w.Validate(); err != nil {
248 5 return AgentWrite{}, err
249 5 }
250 3 return w, nil
251 }
252
253 type contextKey struct{ name string }
254
255 var principalCtxKey = &contextKey{"authn.principal"}
256
257 // WithPrincipal returns a copy of ctx carrying p.
258 4 func WithPrincipal(ctx context.Context, p Principal) context.Context {
259 4 return context.WithValue(ctx, principalCtxKey, p)
260 4 }
261
262 // PrincipalFromContext returns the principal stored by WithPrincipal, or the
263 // anonymous principal when none was stored. It never panics: an
264 // unauthenticated request is ordinary here, and a handler reached without the
265 // middleware must degrade to *less* authority, not more.
266 5 func PrincipalFromContext(ctx context.Context) Principal {
267 5 p, ok := ctx.Value(principalCtxKey).(Principal)
268 5 if !ok {
269 1 return Anonymous()
270 1 }
271 4 return p
272 }