| 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 |
|
} |