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.
//
// What the field draws is not "which of two stores said yes" but "in which
// vocabulary, if any, was this credential scoped". Only PlaneInstance carries a
// tokens.sr.ht grant set, so a check that reads Grants has to know whether there
// were any to read — and reading the zero set of a plane that was never scoped
// in that grammar would refuse a caller for lacking a permission its credential
// could not have been minted with.
//
// 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"
// PlaneMeta is a meta.sr.ht personal access token, scoped in meta's own OAuth
// vocabulary (ScopeRead) rather than in tokens.sr.ht's, and reachable on
// /query alone.
//
// It exists because api.sr.ht forwards one client credential to every service
// a federated query touches, so a /query that refused the credential the rest
// of the instance uses could never be federated. MetaAuth's own comment
// carries the argument; what matters here is the scope of the exception —
// nothing on the REST, MCP or push paths can produce this plane, because none
// of them holds a MetaAuth, so a PAT is not a way around the grants those
// surfaces require.
//
// A principal on this plane is KindAgent and never KindOwner, even though the
// token belongs to the instance owner's own meta account. KindOwner is the
// human at a browser, and it is the only principal that may approve a proposal
// or manage a webhook; promoting a credential that any process holding a
// string can present into that role would hand the approved branch to whatever
// is holding it.
PlaneMeta Plane = "meta"
)
// 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, and each of the three ways that
// happens is deliberate rather than a hole left over from the agent_token days:
//
// - The owner's cookie is a person, whose authority is their identity. There is
// no grant to read, and checking a zero set would refuse every logged-in
// human on the site.
// - The CLI's locally asserted agent runs as the operator on the daemon's own
// host and presented nothing to have a grant clipped out of.
// - A meta.sr.ht personal access token (PlaneMeta) is scoped in a vocabulary
// this method does not speak. No PAT can ever carry "spec:read" — meta's
// personal-token page cannot spell it — so checking one here would refuse
// every PAT on the instance rather than scope it, which is the opposite of
// what a grant check is for. A PAT is scoped once, in meta's own grammar, at
// the point it is resolved (MetaAuth.VerifyToken), and /query's read gate is
// the only surface it can reach at all.
//
// 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)
// Each credential-backed agent is annotated with the plane that admitted
// it, and only the instance plane's annotation carries a grant set: that
// set is what its annotation says, and neither a PAT nor an agent a local
// process asserted has one to print. Naming the plane is what lets a log
// line distinguish the two credentials afterwards, which is the whole
// reason a reader would look — a PAT reaches /query and nothing else, so
// "which plane" is also "which surface" when one turns up somewhere
// surprising.
switch p.Plane {
case PlaneInstance:
line += " (tokens.sr.ht: " + p.Grants.String() + ")"
case PlaneMeta:
line += " (meta.sr.ht personal access token)"
}
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
}