~bigbes/sr-ht-spec

ref: 643fa0b5d34a047923791d23f11ab585dd7487d5 sr-ht-spec/authn/principal.go -rw-r--r-- 9.5 KiB
643fa0b5 — Eugene Blikh logging: log through slog and scribe rather than stdlib log 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
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
}