~bigbes/sr-ht-spec

sr-ht-spec/authn/meta.go -rw-r--r-- 12.8 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day 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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
package authn

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"

	"sourcecraft.dev/bigbes/sr-ht-ecore/metapat"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// ScopeRead is the OAuth grant a meta.sr.ht personal access token must carry to
// read through this service: "spec.sr.ht/SPECS", at :RO or better.
//
// It is meta.sr.ht's vocabulary and it is not ActionRead spelled differently.
// The two grammars share a token format and nothing else: ActionRead
// ("spec:read") is what tokens.sr.ht seals into a working token, and no PAT can
// carry it because meta's personal-token page cannot spell it; ScopeRead is a
// checkbox on that page, and no working token can carry it because ecore's
// grants parser does not read that shape at all. A credential has one or the
// other and never both, which is why the two planes are asked different
// questions about the same request (Principal.Authorize, metapat.Allows).
//
// The service half is ConfigSection, so a rename breaks the build here rather
// than leaving behind a scope nobody can be granted. The bare half — "SPECS" —
// is what api-meta.json publishes, because meta prefixes the service name
// itself; cmd/specsrht derives that list from this constant rather than spelling
// it a second time, and a test on each side asserts the two agree. A scope
// published and not checked admits what should have been refused; one checked
// and not published cannot be minted at all; neither failure is visible from
// inside a single file.
const ScopeRead = ConfigSection + "/SPECS"

// MetaValidator is the sliver of sr-ht-ecore's metapat.Validator this plane
// needs.
//
// The interface is declared in the consumer, as BearerValidator and UserLookup
// are and for the same reasons: it states exactly how much of the shared
// validator this package depends on — one method — and it is what keeps authn
// testable with no meta.sr.ht, no network and no Postgres. The concrete
// validator reaches core-go's auth.LookupUser, which reads a database handle out
// of the context and panics without one; holding it behind an interface is what
// keeps that dependency in cmd/ where the wiring lives, exactly as this package's
// doc comment promises.
type MetaValidator interface {
	// Resolve verifies the signature and expiry locally, refuses a token sealed
	// by tokens.sr.ht, mirrors the owner's profile and asks meta.sr.ht whether
	// the token has been revoked. It answers with core-go's own OAuth2 caller, or
	// with one of the metapat package's sentinels.
	Resolve(ctx context.Context, presented string) (*auth.AuthContext, error)
}

// Compile-time proof that the shared validator satisfies the port. It is what
// lets this package depend on the interface rather than on *metapat.Validator,
// and it fails the build the moment either side drifts.
var _ MetaValidator = (*metapat.Validator)(nil)

// MetaAuth is the meta.sr.ht plane: an ordinary personal access token, the
// credential every upstream service on this instance already accepts.
//
// # Why spec.sr.ht accepts one at all
//
// It did not, and the reason it does now is the gateway. api.sr.ht forwards ONE
// client "Authorization" header to every service a federated query touches — its
// AuthMiddleware copies the client's header verbatim into the request context,
// and the internal credential it can mint is used only to fetch schemas at
// startup — so a federated caller arrives holding whatever credential the client
// had. The only credential that works across the whole instance is a meta PAT,
// so an endpoint that refuses them answers 401 to the first authenticated
// federated query and can never be part of the gateway's schema, however correct
// each individual refusal looks.
//
// # Where it is and is not accepted
//
// This plane is deliberately NOT part of Resolver. It is reachable from /query
// alone, which is the only surface with the federation problem; the REST write
// plane, /mcp and the push hook keep asking for a tokens.sr.ht working token,
// where a narrow, short-lived, revocable grant is worth what it costs an agent to
// obtain. Nothing on those paths holds a MetaAuth, so nothing on them can produce
// a PlaneMeta principal, and a PAT is not a way around a grant.
//
// # What it is left deciding
//
// It owns no crypto and no lookups: verification is ecore's metapat, one copy of
// that check for every service on the instance. Three questions are this
// service's own and are why the type exists at all — whether the token's owner is
// the one human this instance answers to, whether it carries the scope, and which
// failures of the shared validator are a bad credential, which are a missing
// permission, and which are an outage.
type MetaAuth struct {
	validator MetaValidator
	owner     string
	scope     string
}

// NewMetaAuth builds the plane over a validator, the instance owner username
// from [sr.ht] owner-name, and the OAuth scope a token must carry to read
// through this service.
//
// Each argument is refused rather than tolerated when it is empty, and the three
// reasons are different:
//
//   - A nil validator is not the "this instance has no such plane" configuration.
//     That one is a nil *MetaAuth, which graph.New refuses outright because the
//     plane needs no per-instance origin and so can never be legitimately absent.
//     A validator-less plane would fail every PAT while looking configured.
//   - An empty owner would compare every token's account against "", so either no
//     PAT would ever be admitted, or — worse, if the comparison were ever loosened
//     — the check that makes this a single-owner instance would be the one that
//     silently did nothing. NewResolver validates its owner for the same reason
//     and with the same rule, so the two planes cannot disagree about who bigbes
//     is.
//   - An empty scope would have metapat.Allows asked about the grant name "",
//     which no token carries and no meta checkbox can mint, so every PAT on the
//     instance would be refused with a message naming a permission that does not
//     exist.
func NewMetaAuth(validator MetaValidator, owner, scope string) (*MetaAuth, error) {
	if validator == nil {
		return nil, fmt.Errorf("authn: nil MetaValidator")
	}
	owner = strings.TrimPrefix(owner, "~")
	if err := core.ValidateOwner(owner); err != nil {
		return nil, fmt.Errorf("authn: meta plane instance owner: %w", err)
	}
	if scope == "" {
		return nil, fmt.Errorf("authn: empty OAuth scope, e.g. %s", ScopeRead)
	}
	return &MetaAuth{validator: validator, owner: owner, scope: scope}, nil
}

// Scope is the OAuth grant this plane requires, for a caller that wants to name
// it in a refusal without spelling it a second time.
func (a *MetaAuth) Scope() string { return a.scope }

// Owner is the instance owner username this plane admits tokens for.
func (a *MetaAuth) Owner() string { return a.owner }

// VerifyToken validates a presented personal access token and returns the
// principal of its owner.
//
// presented is the bare credential, with the "Bearer " scheme already stripped.
//
// The principal is KindAgent, never KindOwner, even though the token belongs to
// the instance owner's own meta.sr.ht account. That is the security decision of
// this whole change: KindOwner may approve proposals and manage webhooks, and it
// is reached by a unified-login cookie — a browser session a human is sitting in
// front of — while a PAT is a bearer string any process holding it can present,
// forwarded through a gateway by whatever client asked. Reading one as the owner
// would hand the approved branch to the widest credential on the instance.
//
// It carries no Grants either: those are tokens.sr.ht's vocabulary and a PAT is
// in meta's, which is checked here instead — once, against the scope this plane
// was built with, because the whole surface it guards is a read. That is also why
// Principal.Authorize passes a PlaneMeta caller through: asking it for a grant a
// PAT can never carry would refuse every one of them.
//
// Agent identity and session are left empty, and this plane is given the
// credential rather than the request precisely so that they cannot be otherwise.
// Provenance is demanded at a write (AgentWrite.Validate) and /query performs
// none — its only mutations are the webhook ones, which webhookAuthorized
// restricts to KindOwner — so there is nothing here to attribute. A plane handed
// the request could also read a cookie off it, which would quietly reintroduce
// the ambient authority this endpoint exists without.
//
// The failures are this package's sentinels, so that graph turns them into
// statuses through the same StatusFor table it already uses for the other plane.
func (a *MetaAuth) VerifyToken(ctx context.Context, presented string) (Principal, error) {
	if presented == "" {
		return Anonymous(), ErrNoToken
	}

	ac, err := a.validator.Resolve(ctx, presented)
	if err != nil {
		return Anonymous(), classifyResolve(err)
	}
	if ac.UserID == 0 {
		// metapat refuses this itself; the check is kept because the seam is an
		// interface, and a validator that answered with an empty context must not
		// produce a principal that owns whichever row has an unset owner id.
		return Anonymous(), fmt.Errorf(
			"%w: it resolved to no meta.sr.ht user id", ErrInvalidPersonalToken)
	}

	// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
	// means anything. This is the rule resolveInstanceToken applies to a working
	// token and the cookie plane applies to a session, and it matters most here:
	// a PAT is the credential every account on the instance can mint for itself,
	// so without this check the widest credential in existence would be the one
	// that skipped the narrowest identity rule, and any user of the instance could
	// read the whole corpus through /query.
	//
	// It is a refusal rather than a downgrade to anonymous because a presented
	// credential that fails must fail at the door: the asymmetry this package's
	// doc comment draws between cookies and bearer tokens.
	username := strings.TrimPrefix(ac.Username, "~")
	if username != a.owner {
		return Anonymous(), fmt.Errorf(
			"%w: the personal access token belongs to ~%s, and this instance answers only to ~%s",
			ErrNotInstanceOwner, username, a.owner)
	}

	if !metapat.Allows(ac, a.scope, auth.RO) {
		return Anonymous(), fmt.Errorf(
			"%w: this personal access token does not carry %s", ErrMissingScope, a.scope)
	}

	return Principal{
		Kind:  KindAgent,
		Owner: a.owner,
		// Diagnostics only, as on the other plane. There is no row id to print:
		// meta.sr.ht does not number a PAT the way tokens.sr.ht numbers a working
		// token, and naming the plane is the useful half anyway — it says which of
		// two credentials a log line is about.
		TokenName: "meta.sr.ht personal access token",
		Plane:     PlaneMeta,
		UserID:    ac.UserID,
	}, nil
}

// classifyResolve maps metapat's sentinels onto this package's, and is the one
// place spec.sr.ht decides what each refusal of the shared PAT validator means
// here.
//
// It is deliberately the same shape as the tokens.sr.ht plane's mapping, arm for
// arm — resolveInstanceToken wraps bearer's sentinels and StatusFor reads them —
// because the two planes owe the surfaces above the same three answers, 401, 403
// and 503. A difference between the tables would be a difference in what a client
// is told about the same kind of failure, decided by which credential it happened
// to be holding.
//
// metapat.ErrNotOurs is classified for totality and is not reachable in
// production: graph routes on metapat.PlaneOf before this plane is asked, so a
// working token has already gone to the other one. Were it ever to arrive here it
// is a bad credential *for this plane*, and 401 is the honest answer.
//
// An unrecognised error is ErrMetaUnavailable, and that is the fail-closed
// direction rather than a shrug: a sentinel this table has never seen must read
// as "I could not decide" — a 503 the caller retries — and never as a verdict
// about the credential. Answering 401 to something this function does not
// understand would tell a client to re-mint a token that may be perfectly good.
func classifyResolve(err error) error {
	switch {
	case errors.Is(err, metapat.ErrInvalid),
		errors.Is(err, metapat.ErrNotOurs),
		errors.Is(err, metapat.ErrRevoked):
		return fmt.Errorf("%w: %w", ErrInvalidPersonalToken, err)
	case errors.Is(err, metapat.ErrForbidden):
		// Not reachable through Resolve, which is never told a scope; classified
		// so that the table is total.
		return fmt.Errorf("%w: %w", ErrMissingScope, err)
	case errors.Is(err, metapat.ErrUnavailable):
		// meta.sr.ht and not tokens.sr.ht, which is the entire reason this
		// sentinel exists beside bearer.ErrUnavailable rather than reusing it.
		return fmt.Errorf("%w: %w", ErrMetaUnavailable, err)
	default:
		return fmt.Errorf("%w: validating a meta.sr.ht personal access token: %w",
			ErrMetaUnavailable, err)
	}
}