~bigbes/sr-ht-spec

ref: 3d811988f9960cf9057e09f30b59ceb71a7623c2 sr-ht-spec/authn/bearer.go -rw-r--r-- 8.1 KiB
3d811988 — Eugene Blikh service: wrap the merge and reject failures with culpa 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
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. Its answer is final: there is one agent credential plane, so a
// refusal here is the service's refusal.
//
// It used to report a third thing — whether the caller should fall back to
// spec's own agent_token store — and exactly two refusals said yes:
//
//   - bearer.ErrInvalid, because spec's local token was 32 random bytes in
//     base64, which is precisely what "did not decode as one of ours" looks
//     like;
//   - bearer.ErrNotOurs, because refusing a meta.sr.ht PAT was the local plane's
//     business rather than this one's, and falling through cost one hash lookup
//     that would miss.
//
// With that store gone both are plain refusals. The one consequence worth
// naming is ErrNotOurs: IsAuthFailure now counts it permanent, so a meta PAT
// presented here earns a 401 rather than the 503 an unclassified error would.
//
// The rest of the mapping is unchanged and lives in StatusFor: ErrInvalid and
// ErrRevoked are 401, ErrForbidden and a foreign owner are 403, and
// ErrUnavailable is 503 — never 401, because "I could not ask tokens.sr.ht" is
// not "your token is bad".
func (rs *Resolver) resolveInstanceToken(
	ctx context.Context, presented, agent, session string,
) (Principal, error) {
	tok, err := rs.bearer.Inspect(ctx, presented)
	if err != nil {
		return Anonymous(), 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(), 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(), fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err)
	}

	return Principal{
		Kind:      KindAgent,
		Owner:     rs.owner,
		Agent:     agent,
		Session:   session,
		TokenName: instanceTokenLabel(tok),
		Plane:     PlaneInstance,
		Grants:    tok.Grants,
		UserID:    user.ID,
	}, 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 — malformed, foreign,
//     revoked — is 401.
//   - ErrNoAgentPlane is 503 and not 401. An instance with no [tokens.sr.ht]
//     origin cannot check any credential, and telling the holder of a good token
//     that it is bad would send them to re-provision it.
//   - 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
	}
}