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

Coverage
100.0% 36/36 statements
Δ
Blob
a813f7e
Uncovered nothing — every instrumented line ran
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 // One plane is left, and the field outlives its sibling because the distinction
37 // it draws is no longer "which of two stores said yes" but "was there a
38 // credential at all". Only a credential carries a grant set, and a check that
39 // reads Grants has to know whether there were any to read.
40 //
41 // Empty for every principal that is not an agent, and for the one agent that is
42 // not credential-backed: `specsrht doc propose`, which runs as the operator on
43 // the daemon's own host and names an agent for provenance rather than
44 // authenticating one. Resolver never produces an agent with an empty plane —
45 // every agent it resolves came through the tokens.sr.ht validator.
46 type Plane string
47
48 const (
49 // PlaneInstance is a tokens.sr.ht working token: signed, expiring, owned by
50 // a meta.sr.ht account, and carrying the grant set Authorize checks.
51 PlaneInstance Plane = "instance"
52 )
53
54 // Principal is the resolved identity of a request. It is a value type with no
55 // pointers into request state, so it can be stashed in a context, logged, and
56 // passed to service/ without aliasing surprises.
57 //
58 // This is what the API layer and gitx's refs rule branch on, and it is
59 // deliberately the narrowest thing that supports both: which kind, and — for an
60 // agent — the two provenance fields that every agent write must carry, plus
61 // which credential plane it came in on and what that credential permits.
62 //
63 // It is not comparable with ==: Grants holds a set. Compare the fields that
64 // matter, or the String() rendering. The set itself is immutable once parsed —
65 // grants.Grants has no mutating method — so copies sharing it is not the
66 // aliasing this type's value semantics are guarding against.
67 type Principal struct {
68 // Kind is which of the three principals this is. The zero value is the
69 // anonymous case, so a Principal read out of a context that never had one
70 // set is safe rather than privileged.
71 Kind Kind
72
73 // Owner is the instance owner username (no leading '~') this principal acts
74 // as or on behalf of: itself for KindOwner, the human an agent writes for
75 // for KindAgent. Empty for KindAnonymous.
76 Owner string
77
78 // Agent is the agent identity string, e.g. "claude-code/spec-writer".
79 // KindAgent only. It may be empty on a read — it is demanded at the write,
80 // which is the only place the design requires it.
81 Agent string
82
83 // Session is the agent's session ID, e.g. a UUID. KindAgent only, with the
84 // same read/write asymmetry as Agent.
85 Session string
86
87 // TokenName names the credential that authenticated this request: the
88 // tokens.sr.ht row id, or "stateless" for a token short enough that the
89 // daemon never wrote it down. KindAgent only, diagnostics only — it grants
90 // nothing.
91 TokenName string
92
93 // CookieUser is whatever username the unified-login cookie carried, even
94 // when that user was not the instance owner and Kind is therefore
95 // KindAnonymous. Display and logging only: never an authorization input.
96 CookieUser string
97
98 // Plane is which credential plane authenticated an agent. Empty for every
99 // other kind. Authorize reads it to decide whether Grants means anything.
100 Plane Plane
101
102 // Grants is what the instance token this request carried permits, parsed.
103 // PlaneInstance only; the zero value everywhere else, which grants nothing
104 // and is why Authorize checks Plane before it checks the set.
105 Grants grants.Grants
106
107 // UserID is the id of the local "user" row the instance token's owner
108 // resolved to. PlaneInstance only, and zero for a principal no credential
109 // backs.
110 UserID int
111 }
112
113 // Anonymous returns the principal for an unauthenticated request.
114 36 func Anonymous() Principal { return Principal{Kind: KindAnonymous} }
115
116 // IsAnonymous reports whether the principal carries no authority. Written as
117 // "not one of the two that do" so that an unrecognised or zero Kind is denied
118 // rather than accidentally admitted.
119 25 func (p Principal) IsAnonymous() bool { return p.Kind != KindOwner && p.Kind != KindAgent }
120
121 // IsOwner reports whether this is the human owner — the principal that may
122 // approve proposals and whose pushes need no review.
123 13 func (p Principal) IsOwner() bool { return p.Kind == KindOwner }
124
125 // IsAgent reports whether this is an agent — the principal gitx confines to
126 // proposals/*.
127 23 func (p Principal) IsAgent() bool { return p.Kind == KindAgent }
128
129 // CanRead reports whether this principal may read content: the owner and its
130 // agents may, nobody else may. This is the whole read-plane ACL — one human, no
131 // visibility levels, and a non-owner human already resolved to anonymous by
132 // authn — and it lives here, in one place, because every read surface (graph's
133 // /query, the web UI, the MCP tools) must apply the identical policy: two read
134 // surfaces with two spellings of it is how a corpus leaks.
135 5 func (p Principal) CanRead() bool { return p.IsOwner() || p.IsAgent() }
136
137 // Authorize reports whether the credential behind this principal covers action
138 // — one of the ActionPropose / ActionRead constants.
139 //
140 // It is a grant check and nothing else. It says nothing about who the principal
141 // is, so every caller must already have made the identity decision (IsAgent for
142 // the write plane, CanRead for the read plane); calling this alone would
143 // "authorize" an anonymous request, because an anonymous request carries no
144 // instance token and so has no grant to be missing. The two questions are
145 // separate on purpose: the resolver answers identity in middleware, upstream of
146 // the router, and only the layer that knows the action can ask this one.
147 //
148 // A principal off the instance plane passes. That is not a hole left over from
149 // the agent_token days: grants describe machine credentials, and the principals
150 // with no plane are the owner's cookie — a person, whose authority is their
151 // identity — and the CLI's locally asserted agent, which runs as the operator on
152 // the daemon's host and presented nothing to have a grant clipped out of. Every
153 // agent the resolver produces is on the instance plane and is checked here.
154 15 func (p Principal) Authorize(action string) error {
155 15 if p.Plane != PlaneInstance {
156 4 return nil
157 4 }
158 11 if !p.Grants.Has(action) {
159 5 return fmt.Errorf("%w: the instance token grants %q, which does not cover %q",
160 5 ErrMissingGrant, p.Grants.String(), action)
161 5 }
162 6 return nil
163 }
164
165 // String renders the principal for logs. It never includes the token name's
166 // secret (there is none — the name is not the token) and never includes the
167 // cookie value.
168 9 func (p Principal) String() string {
169 9 switch p.Kind {
170 2 case KindOwner:
171 2 return "owner ~" + p.Owner
172 4 case KindAgent:
173 4 agent := p.Agent
174 4 if agent == "" {
175 1 agent = "(unnamed)"
176 1 }
177 4 session := p.Session
178 4 if session == "" {
179 1 session = "(no session)"
180 1 }
181 4 line := fmt.Sprintf("agent %s session %s for ~%s", agent, session, p.Owner)
182 4 // Only a credential-backed agent is annotated: the grant set is what the
183 4 // annotation says, and an agent a local process asserted has none to
184 4 // print.
185 4 if p.Plane == PlaneInstance {
186 1 line += " (tokens.sr.ht: " + p.Grants.String() + ")"
187 1 }
188 4 return line
189 3 default:
190 3 if p.CookieUser != "" {
191 1 return "anonymous (cookie user ~" + p.CookieUser + ")"
192 1 }
193 2 return "anonymous"
194 }
195 }
196
197 // AgentWriteFor builds the provenance inputs for an agent write at the given
198 // base revision, enforcing that the mandatory fields are present. It fails for
199 // a non-agent principal: the human write path goes through native
200 // receive-pack and constructs no commit here.
201 10 func (p Principal) AgentWriteFor(base string) (AgentWrite, error) {
202 10 if !p.IsAgent() {
203 2 return AgentWrite{}, fmt.Errorf("%w: %s", ErrNotAgent, p)
204 2 }
205 8 w := AgentWrite{Agent: p.Agent, Session: p.Session, Base: base}
206 8 if err := w.Validate(); err != nil {
207 5 return AgentWrite{}, err
208 5 }
209 3 return w, nil
210 }
211
212 type contextKey struct{ name string }
213
214 var principalCtxKey = &contextKey{"authn.principal"}
215
216 // WithPrincipal returns a copy of ctx carrying p.
217 4 func WithPrincipal(ctx context.Context, p Principal) context.Context {
218 4 return context.WithValue(ctx, principalCtxKey, p)
219 4 }
220
221 // PrincipalFromContext returns the principal stored by WithPrincipal, or the
222 // anonymous principal when none was stored. It never panics: an
223 // unauthenticated request is ordinary here, and a handler reached without the
224 // middleware must degrade to *less* authority, not more.
225 5 func PrincipalFromContext(ctx context.Context) Principal {
226 5 p, ok := ctx.Value(principalCtxKey).(Principal)
227 5 if !ok {
228 1 return Anonymous()
229 1 }
230 4 return p
231 }