~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/authn/bearer.go -rw-r--r-- 8.6 KiB
c7477607 — Eugene Blikh authn: accept tokens.sr.ht working tokens beside the agent token 10 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
package authn

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
)

// The grant vocabulary spec.sr.ht declares for tokens.sr.ht working tokens.
//
// The daemon that mints them does not know these strings and must not: the
// tokens.sr.ht spec gives the vocabulary to the services, so that adding an
// action to spec.sr.ht is a change to spec.sr.ht. An unknown grant simply
// admits nobody. They are constants rather than literals at the check because a
// grant is compared byte for byte — a typo in one of the two places it is
// spelled is a silent widening or a silent refusal, and neither shows up until
// it matters.
const (
	// ActionPropose is what an instance token must carry to write: open a
	// proposal or add documents to one.
	ActionPropose = "spec:propose"

	// ActionRead is what an instance token must carry to read content through
	// any of the read surfaces (the web UI, /query, MCP).
	ActionRead = "spec:read"
)

// BearerValidator is the sliver of sr-ht-ecore's bearer.Validator this package
// needs: who a presented working token belongs to, what it permits, and whether
// it is still live.
//
// Inspect and not Validate, because the resolver runs in middleware upstream of
// the router and so does not know which action is being attempted. The grant
// check happens where the action is known — service.Propose for the write
// plane, the read gates for the read plane — through Principal.Authorize.
//
// It is an interface rather than a *bearer.Validator so that this package stays
// testable without a tokens.sr.ht to talk to, exactly as TokenStore keeps it
// testable without a Postgres.
type BearerValidator interface {
	Inspect(ctx context.Context, presented string) (*bearer.Token, error)
}

// InstanceUser is the local "user" row that the owner of an instance token
// resolves to. It is the whole of what this package needs from that row: the id
// other layers key user-scoped state by, and the name it was found under.
type InstanceUser struct {
	ID       int
	Username string
}

// UserLookup resolves the meta.sr.ht username an instance token names into this
// service's local user row — core-go's auth.LookupUser in production.
//
// It is declared here for the same reason TokenStore is: that function reads a
// database handle and a config out of the context and panics without either,
// which is service/'s business to supply and not something a package answering
// "who is making this request?" should carry. service/ wires the real one in;
// tests wire a map.
type UserLookup interface {
	LookupUser(ctx context.Context, username string) (InstanceUser, error)
}

// resolveInstanceToken runs the tokens.sr.ht plane against a presented bearer
// credential.
//
// The middle result says whether the caller should fall back to spec's own
// agent-token plane. Exactly two refusals fall through, and which two is the
// only interesting decision in this function:
//
//   - bearer.ErrInvalid — the string did not decode as a token this instance
//     sealed. spec's local token is 32 random bytes in base64, which is
//     precisely what that looks like.
//   - bearer.ErrNotOurs — a well-formed token from another issuer (a meta.sr.ht
//     PAT). spec accepts no such credential, but it is not this plane's to
//     refuse, and falling through costs one hash lookup that will miss.
//
// spec's local token carries no prefix to discriminate on — unlike bench's and
// cover's — so there is no shape test that could route a request to the right
// plane up front. Trying the instance plane first and falling back on those two
// sentinels is what replaces it.
//
// Every other refusal is terminal and must never reach the old door:
//
//   - bearer.ErrRevoked — the credential was withdrawn. Letting a revoked
//     instance token be re-tried as a local one would answer "unknown token" for
//     a token an operator deliberately killed, and would mean revocation has a
//     second door to be checked at.
//   - bearer.ErrForbidden — cannot arise from Inspect, which is given no action,
//     but is terminal for the same reason: the credential is good.
//   - bearer.ErrUnavailable — tokens.sr.ht could not be asked. Degrading to the
//     legacy plane when the daemon is unreachable is exactly the silent
//     downgrade the 503 of StatusFor exists to prevent.
func (rs *Resolver) resolveInstanceToken(
	ctx context.Context, r *http.Request, presented string,
) (Principal, bool, error) {
	tok, err := rs.bearer.Inspect(ctx, presented)
	switch {
	case err == nil:
		// fall through
	case errors.Is(err, bearer.ErrInvalid), errors.Is(err, bearer.ErrNotOurs):
		return Anonymous(), true, nil
	default:
		return Anonymous(), false, fmt.Errorf("authn: instance token: %w", err)
	}

	// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
	// means anything. This is the same rule the cookie plane already applies —
	// a real user who is not the instance owner reads as nobody — and applying
	// it here keeps every consumer of Principal.Owner honest: the provenance
	// committer, the refs rule's principal kind and coreauth's AuthContext all
	// assume the human an agent acts for is the instance owner, and a foreign
	// name would make each of them quietly wrong in a different way.
	//
	// 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(tok.Username, "~")
	if username != rs.owner {
		return Anonymous(), false, fmt.Errorf(
			"%w: the token belongs to ~%s, and this instance answers only to ~%s",
			ErrNotInstanceOwner, username, rs.owner)
	}

	// The owner is resolved to a local row even though single-user spec could
	// infer it: the row id is what user-scoped state keys off, and looking it up
	// here is what makes the instance plane's identity a fact about this
	// database rather than a name copied out of a signed blob.
	user, err := rs.users.LookupUser(ctx, username)
	if err != nil {
		// Unclassified, therefore transient, therefore 503: a database that
		// cannot answer must never read as a bad credential.
		return Anonymous(), false, fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err)
	}

	return Principal{
		Kind:      KindAgent,
		Owner:     rs.owner,
		Agent:     strings.TrimSpace(r.Header.Get(HeaderAgent)),
		Session:   strings.TrimSpace(r.Header.Get(HeaderAgentSession)),
		TokenName: instanceTokenLabel(tok),
		Plane:     PlaneInstance,
		Grants:    tok.Grants,
		UserID:    user.ID,
	}, false, nil
}

// instanceTokenLabel names the credential in a log line. A registered token has
// a row at tokens.sr.ht an operator can find and revoke, so its id is the useful
// thing to print; a stateless one was never written down, and saying so is more
// honest than printing "0".
func instanceTokenLabel(tok *bearer.Token) string {
	if tok.Registered() {
		return "tokens.sr.ht #" + strconv.Itoa(tok.TokenID)
	}
	return "tokens.sr.ht (stateless)"
}

// StatusFor maps an error out of Resolve — or out of a later Authorize — onto
// the status the surface must answer with. It is one function so that the three
// surfaces cannot each invent their own table.
//
// The mapping, and the one line of it that has to be defended:
//
//   - bearer.ErrUnavailable is 503 and never 401. Reading "I could not reach
//     tokens.sr.ht" as "your token is revoked" would refuse every live instance
//     token on the instance for as long as a daemon that is deliberately off the
//     hot path is restarting, and would tell a thousand clients their
//     credentials are bad when the truth is that one service is down. 503 says
//     the true thing and keeps the operator's attention where the fault is.
//   - ErrMissingGrant and ErrNotInstanceOwner are 403: the credential verifies
//     and the holder is who they say they are, so retrying is pointless and what
//     they need is a wider grant, not another login.
//   - Everything permanent about the credential itself — unknown, revoked,
//     malformed, on either plane — is 401.
//   - Everything else is transient by definition and answers 503, which is the
//     fail-closed direction: a backend outage never reads as a valid credential.
func StatusFor(err error) int {
	switch {
	case err == nil:
		return http.StatusOK
	case errors.Is(err, bearer.ErrUnavailable):
		return http.StatusServiceUnavailable
	case errors.Is(err, bearer.ErrForbidden),
		errors.Is(err, ErrMissingGrant),
		errors.Is(err, ErrNotInstanceOwner):
		return http.StatusForbidden
	case IsAuthFailure(err):
		return http.StatusUnauthorized
	default:
		return http.StatusServiceUnavailable
	}
}