package authn import ( "context" "fmt" "sourcecraft.dev/bigbes/sr-ht-ecore/grants" ) // Kind enumerates the principals spec.sr.ht distinguishes. There are three // values but only two of them carry authority: the design's "authorization is // about agents, not people" collapses every human other than the instance owner // into the anonymous case, because there is no second human in the model to // grant anything to. type Kind string const ( // KindAnonymous is an unauthenticated request — no cookie, an unreadable // cookie, or a cookie belonging to somebody who is not the instance owner. // It is a normal, expected state: the read plane is anonymous-capable. KindAnonymous Kind = "anonymous" // KindOwner is bigbes: the unified-login cookie resolved to the username in // [sr.ht] owner-name. This is the principal whose git push *is* the // approval, and the only one that may approve a proposal. KindOwner Kind = "owner" // KindAgent is a bot holding a tokens.sr.ht working token. It may propose // and it may read; the refs rule in gitx is what stops it touching the // approved branch. KindAgent Kind = "agent" ) // Plane names the credential plane an agent authenticated on. // // One plane is left, and the field outlives its sibling because the distinction // it draws is no longer "which of two stores said yes" but "was there a // credential at all". Only a credential carries a grant set, and a check that // reads Grants has to know whether there were any to read. // // Empty for every principal that is not an agent, and for the one agent that is // not credential-backed: `specsrht doc propose`, which runs as the operator on // the daemon's own host and names an agent for provenance rather than // authenticating one. Resolver never produces an agent with an empty plane — // every agent it resolves came through the tokens.sr.ht validator. type Plane string const ( // PlaneInstance is a tokens.sr.ht working token: signed, expiring, owned by // a meta.sr.ht account, and carrying the grant set Authorize checks. PlaneInstance Plane = "instance" ) // Principal is the resolved identity of a request. It is a value type with no // pointers into request state, so it can be stashed in a context, logged, and // passed to service/ without aliasing surprises. // // This is what the API layer and gitx's refs rule branch on, and it is // deliberately the narrowest thing that supports both: which kind, and — for an // agent — the two provenance fields that every agent write must carry, plus // which credential plane it came in on and what that credential permits. // // It is not comparable with ==: Grants holds a set. Compare the fields that // matter, or the String() rendering. The set itself is immutable once parsed — // grants.Grants has no mutating method — so copies sharing it is not the // aliasing this type's value semantics are guarding against. type Principal struct { // Kind is which of the three principals this is. The zero value is the // anonymous case, so a Principal read out of a context that never had one // set is safe rather than privileged. Kind Kind // Owner is the instance owner username (no leading '~') this principal acts // as or on behalf of: itself for KindOwner, the human an agent writes for // for KindAgent. Empty for KindAnonymous. Owner string // Agent is the agent identity string, e.g. "claude-code/spec-writer". // KindAgent only. It may be empty on a read — it is demanded at the write, // which is the only place the design requires it. Agent string // Session is the agent's session ID, e.g. a UUID. KindAgent only, with the // same read/write asymmetry as Agent. Session string // TokenName names the credential that authenticated this request: the // tokens.sr.ht row id, or "stateless" for a token short enough that the // daemon never wrote it down. KindAgent only, diagnostics only — it grants // nothing. TokenName string // CookieUser is whatever username the unified-login cookie carried, even // when that user was not the instance owner and Kind is therefore // KindAnonymous. Display and logging only: never an authorization input. CookieUser string // Plane is which credential plane authenticated an agent. Empty for every // other kind. Authorize reads it to decide whether Grants means anything. Plane Plane // Grants is what the instance token this request carried permits, parsed. // PlaneInstance only; the zero value everywhere else, which grants nothing // and is why Authorize checks Plane before it checks the set. Grants grants.Grants // UserID is the id of the local "user" row the instance token's owner // resolved to. PlaneInstance only, and zero for a principal no credential // backs. UserID int } // Anonymous returns the principal for an unauthenticated request. func Anonymous() Principal { return Principal{Kind: KindAnonymous} } // IsAnonymous reports whether the principal carries no authority. Written as // "not one of the two that do" so that an unrecognised or zero Kind is denied // rather than accidentally admitted. func (p Principal) IsAnonymous() bool { return p.Kind != KindOwner && p.Kind != KindAgent } // IsOwner reports whether this is the human owner — the principal that may // approve proposals and whose pushes need no review. func (p Principal) IsOwner() bool { return p.Kind == KindOwner } // IsAgent reports whether this is an agent — the principal gitx confines to // proposals/*. func (p Principal) IsAgent() bool { return p.Kind == KindAgent } // CanRead reports whether this principal may read content: the owner and its // agents may, nobody else may. This is the whole read-plane ACL — one human, no // visibility levels, and a non-owner human already resolved to anonymous by // authn — and it lives here, in one place, because every read surface (graph's // /query, the web UI, the MCP tools) must apply the identical policy: two read // surfaces with two spellings of it is how a corpus leaks. func (p Principal) CanRead() bool { return p.IsOwner() || p.IsAgent() } // Authorize reports whether the credential behind this principal covers action // — one of the ActionPropose / ActionRead constants. // // It is a grant check and nothing else. It says nothing about who the principal // is, so every caller must already have made the identity decision (IsAgent for // the write plane, CanRead for the read plane); calling this alone would // "authorize" an anonymous request, because an anonymous request carries no // instance token and so has no grant to be missing. The two questions are // separate on purpose: the resolver answers identity in middleware, upstream of // the router, and only the layer that knows the action can ask this one. // // A principal off the instance plane passes. That is not a hole left over from // the agent_token days: grants describe machine credentials, and the principals // with no plane are the owner's cookie — a person, whose authority is their // identity — and the CLI's locally asserted agent, which runs as the operator on // the daemon's host and presented nothing to have a grant clipped out of. Every // agent the resolver produces is on the instance plane and is checked here. func (p Principal) Authorize(action string) error { if p.Plane != PlaneInstance { return nil } if !p.Grants.Has(action) { return fmt.Errorf("%w: the instance token grants %q, which does not cover %q", ErrMissingGrant, p.Grants.String(), action) } return nil } // String renders the principal for logs. It never includes the token name's // secret (there is none — the name is not the token) and never includes the // cookie value. func (p Principal) String() string { switch p.Kind { case KindOwner: return "owner ~" + p.Owner case KindAgent: agent := p.Agent if agent == "" { agent = "(unnamed)" } session := p.Session if session == "" { session = "(no session)" } line := fmt.Sprintf("agent %s session %s for ~%s", agent, session, p.Owner) // Only a credential-backed agent is annotated: the grant set is what the // annotation says, and an agent a local process asserted has none to // print. if p.Plane == PlaneInstance { line += " (tokens.sr.ht: " + p.Grants.String() + ")" } return line default: if p.CookieUser != "" { return "anonymous (cookie user ~" + p.CookieUser + ")" } return "anonymous" } } // AgentWriteFor builds the provenance inputs for an agent write at the given // base revision, enforcing that the mandatory fields are present. It fails for // a non-agent principal: the human write path goes through native // receive-pack and constructs no commit here. func (p Principal) AgentWriteFor(base string) (AgentWrite, error) { if !p.IsAgent() { return AgentWrite{}, fmt.Errorf("%w: %s", ErrNotAgent, p) } w := AgentWrite{Agent: p.Agent, Session: p.Session, Base: base} if err := w.Validate(); err != nil { return AgentWrite{}, err } return w, nil } type contextKey struct{ name string } var principalCtxKey = &contextKey{"authn.principal"} // WithPrincipal returns a copy of ctx carrying p. func WithPrincipal(ctx context.Context, p Principal) context.Context { return context.WithValue(ctx, principalCtxKey, p) } // PrincipalFromContext returns the principal stored by WithPrincipal, or the // anonymous principal when none was stored. It never panics: an // unauthenticated request is ordinary here, and a handler reached without the // middleware must degrade to *less* authority, not more. func PrincipalFromContext(ctx context.Context) Principal { p, ok := ctx.Value(principalCtxKey).(Principal) if !ok { return Anonymous() } return p }