~bigbes/sr-ht-ecore

ref: 89fa694cbf548ecd642b8101825b7a49b48f975a sr-ht-ecore/grants/grants.go -rw-r--r-- 13.0 KiB
89fa694c — Eugene Blikh metapat: the meta.sr.ht PAT plane a federated endpoint needs 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
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
// Package grants is the grant vocabulary of tokens.sr.ht: space-separated
// members, each naming an action as "<service>:<action>", with "*" standing for
// every action of every service.
//
// It lives here rather than inside tokens.sr.ht because two ends read the same
// string and they must read it identically. The daemon parses a grant string to
// decide what it may seal into a working token; every service that accepts one
// parses it again to decide whether the holder may act. Two parsers that
// disagree about — say — whether "id:42" is a permission, or about what counts
// as whitespace, is not a cosmetic divergence: it is a hole on the security
// path, and the only reliable way to keep the two in step is to have one of
// them.
//
// This package parses that string and answers questions about it. It does not
// know which services exist or which actions they define, and it must not learn:
// SPEC ch. 3 gives the vocabulary to the services and keeps the daemon out of
// it, so that adding cov:download to cov.sr.ht is a change to cov.sr.ht.
// An unknown grant is therefore not an error — it is a grant that happens to
// admit nobody anywhere, which is the safe direction for a string the daemon
// only ever passes through.
//
// What is validated is the shape, and only the shape a mistake could hide in: a
// member with no colon, an empty segment, a byte outside printable ASCII, an
// upper-case letter. Case is refused rather than folded because a validator
// compares grants literally, so "Bench:upload" folded here and spelled that way
// in a service's check are two different silent failures, and a 400 at mint time
// is the one place a human is still looking.
//
// The syntax deliberately admits more than v1 uses. SPEC ch. 3 names
// "bench:upload:~bigbes/foo" as the repository-scoped form a later version may
// want and requires the format not to forbid it, so an action may carry further
// colon-separated segments and the characters a repository reference needs.
//
// This is not core-go's auth.Grants. That one is meta.sr.ht's OAuth vocabulary
// ("git.sr.ht/OBJECTS:RW" — a service, a scope and an access mode), it is what a
// meta PAT carries, and nothing here parses it or is parsed by it. The two
// grammars share a token format and nothing else.
package grants

import (
	"errors"
	"fmt"
	"sort"
	"strconv"
	"strings"
)

// ErrInvalid is the sentinel every refusal in this package wraps.
//
// The package owns it rather than borrowing tokens.sr.ht's error vocabulary,
// because the importers are on both sides of that daemon: the daemon maps it to
// a 400 on a mint, and a validating service reaches it while parsing a string
// it did not write. A shared library that imported one consumer's sentinels
// would make every other consumer depend on that consumer.
var ErrInvalid = errors.New("invalid grants")

const (
	// Universal is the member that stands for every action of every service.
	Universal = "*"

	// grantIDService is the reserved service name of the member that carries a
	// registered working token's row id (SPEC ch. 4). It travels inside the
	// grant string because auth.BearerToken has no field to put it in, not
	// because it is a permission — Grants keeps it apart from the permission set
	// everywhere, and IsSubsetOf ignores it on both sides.
	grantIDService = "id"

	// MaxGrantsLen bounds the rendered grant string. The bound exists because
	// the string is copied into a working token's payload and that payload is
	// handed to a client as one base64 line: an unbounded grants field is an
	// unbounded token, and a token too long to put in an HTTP header is a
	// credential that authenticates nothing while looking valid. 4 KiB is two
	// orders of magnitude above the vocabulary of SPEC ch. 3 and still leaves
	// the encoded token comfortably inside every proxy's header limit.
	MaxGrantsLen = 4096
)

// Grants is a parsed grant set: either universal, or an explicit set of
// members, optionally carrying the row id of the registered working token it
// was read from.
//
// The zero value is the empty set, which grants nothing. That is deliberately
// not the same as the universal set even though an empty *string* parses to
// universal (SPEC ch. 3: an empty grants column on an old parent token means
// "everything"). A caller that forgot to parse must end up with a token that
// admits nobody, not with one that admits everybody, so the meaning of "" lives
// in Parse and not in the zero value.
type Grants struct {
	all     bool
	members map[string]struct{}
	tokenID int
}

// All is the universal set.
func All() Grants { return Grants{all: true} }

// Parse parses a stored or presented grant string, the id: member included. Use
// it when reading a token or a database column — that is, when the string is one
// tokens.sr.ht wrote.
//
// For a string a caller supplied, use ParseRequested instead: it is the same
// parse with the id: member refused, and the difference is a privilege boundary
// rather than a convenience (see there).
func Parse(s string) (Grants, error) {
	return parse(s, true)
}

// ParseRequested parses a grant string that came from a caller — a mint body, an
// exchange body, a form field — and refuses the reserved id: member.
//
// The refusal is what keeps revocation honest. A registered working token proves
// it is still live by the id: it carries (SPEC ch. 6 step 4), and a validator
// asks the daemon about that id and nothing else. A caller allowed to choose it
// could name the id of some other token that is still alive, and their own
// revocation would then stop revoking anything — the row would be stamped and
// the credential would keep passing, which is the one failure this whole
// mechanism exists to prevent. Naming a nonexistent id fails closed (the
// revocation check 404s and the token is refused), so only the live-id case is
// dangerous, and both are refused here rather than one.
func ParseRequested(s string) (Grants, error) {
	return parse(s, false)
}

func parse(s string, allowID bool) (Grants, error) {
	if len(s) > MaxGrantsLen {
		return Grants{}, fmt.Errorf("%w: grants are %d bytes, the limit is %d",
			ErrInvalid, len(s), MaxGrantsLen)
	}

	fields := asciiFields(s)
	if len(fields) == 0 {
		// SPEC ch. 3: a blank grant string means every action. It is what the
		// column of a parent token minted before some service existed holds.
		return Grants{all: true}, nil
	}

	g := Grants{members: make(map[string]struct{}, len(fields))}
	for _, m := range fields {
		if m == Universal {
			g.all = true
			continue
		}
		service, action, err := splitMember(m)
		if err != nil {
			return Grants{}, err
		}
		if service == grantIDService {
			if !allowID {
				return Grants{}, fmt.Errorf(
					"%w: %q is reserved: the id: member is stamped by the daemon, not requested",
					ErrInvalid, m)
			}
			id, err := strconv.Atoi(action)
			if err != nil || id <= 0 {
				return Grants{}, fmt.Errorf("%w: %q does not name a row id", ErrInvalid, m)
			}
			g.tokenID = id
			continue
		}
		g.members[m] = struct{}{}
	}

	// "* cov:upload" is the universal set with a redundant member spelled out;
	// keeping the member would make String round-trip to something longer than
	// what it means, and Has already answers true for everything.
	if g.all {
		g.members = nil
	}
	return g, nil
}

// asciiFields splits a grant string on ASCII whitespace, and on nothing else.
//
// strings.Fields would be the obvious choice and is the wrong one, because it
// splits on unicode.IsSpace — which includes U+00A0, U+2007, the ideographic
// space and a dozen more. Under it a grants field holding nothing but a
// non-breaking space split into zero members, and zero members is the rule of
// SPEC ch. 3 that a blank grant string means *every* action: one invisible
// character pasted into a form was the difference between "no grants stated" and
// a universal token. The subset check of an exchange still bounded that, so it
// was not an escalation — but a mint from the UI has no parent to be bounded by,
// and a value nobody can see should not decide what a credential can do.
//
// Splitting on ASCII only makes the same input an error instead: U+00A0 stays
// inside the member and splitMember refuses it as a byte outside printable
// ASCII, which is a 400 a human can act on. This is the same reading of
// "whitespace" the sibling services settled on for query parameters, and for the
// same reason — the unicode set is right for prose and wrong for anything a
// machine compares literally.
func asciiFields(s string) []string {
	return strings.FieldsFunc(s, func(r rune) bool {
		switch r {
		case ' ', '\t', '\n', '\v', '\f', '\r':
			return true
		default:
			return false
		}
	})
}

// splitMember validates one member and splits it at its first colon. The
// remainder is returned whole, colons included, because an action may carry
// further segments (SPEC ch. 3's repository-scoped form).
func splitMember(m string) (service, action string, err error) {
	for i := 0; i < len(m); i++ {
		c := m[i]
		if c < 0x21 || c > 0x7e {
			return "", "", fmt.Errorf("%w: grant %q holds a byte outside printable ASCII",
				ErrInvalid, m)
		}
		if c >= 'A' && c <= 'Z' {
			return "", "", fmt.Errorf(
				"%w: grant %q is not lower case; grants are compared literally", ErrInvalid, m)
		}
	}
	i := strings.IndexByte(m, ':')
	if i < 0 {
		return "", "", fmt.Errorf("%w: grant %q is not in <service>:<action> form", ErrInvalid, m)
	}
	if i == 0 || i == len(m)-1 || strings.Contains(m, "::") {
		return "", "", fmt.Errorf("%w: grant %q has an empty segment", ErrInvalid, m)
	}
	return m[:i], m[i+1:], nil
}

// All reports whether this is the universal set.
func (g Grants) All() bool { return g.all }

// Empty reports whether the set admits nothing at all. Only the zero value and
// a set built by removing every member can be empty — a parsed string never is,
// because a blank one is universal.
func (g Grants) Empty() bool { return !g.all && len(g.members) == 0 }

// Has reports whether the set admits one named action, e.g. "bench:upload".
//
// There is no wildcard below the universal one: "cov:*" is a member like any
// other and matches only a validator asking for exactly "cov:*". SPEC ch. 3
// defines "*" and nothing else, and a per-service wildcard invented here would
// be a permission the services do not know they are honouring.
func (g Grants) Has(grant string) bool {
	if g.all {
		return true
	}
	_, ok := g.members[grant]
	return ok
}

// IsSubsetOf reports whether every action this set admits is also admitted by
// other. It is the whole of the narrowing rule of SPEC ch. 2: an exchange may
// drop grants and may not add them.
//
// The id: member is not a permission and takes no part in the comparison — a
// registered child of a stateless parent is still a narrowing, and a token
// compared against the parent it came from would otherwise never be a subset of
// anything once it had been stamped.
func (g Grants) IsSubsetOf(other Grants) bool {
	if other.all {
		return true
	}
	if g.all {
		// Universal is a subset only of universal, which the branch above
		// already answered.
		return false
	}
	for m := range g.members {
		if _, ok := other.members[m]; !ok {
			return false
		}
	}
	return true
}

// TokenID returns the row id this grant string carries, or 0 when it carries
// none. A working token with no id: is a stateless one (SPEC ch. 2): it was
// never written to the database and has no revocation to check.
func (g Grants) TokenID() int { return g.tokenID }

// WithTokenID returns a copy carrying the given row id. Passing 0 strips it,
// which is what turns a stored grant string into the one shown to a human.
func (g Grants) WithTokenID(id int) Grants {
	out := Grants{all: g.all, tokenID: id}
	if g.members != nil {
		out.members = make(map[string]struct{}, len(g.members))
		for m := range g.members {
			out.members[m] = struct{}{}
		}
	}
	return out
}

// Members returns the permission members in sorted order, without the id:.
// It is what a page lists and what a mint response echoes.
func (g Grants) Members() []string {
	if g.all {
		return []string{Universal}
	}
	out := make([]string, 0, len(g.members))
	for m := range g.members {
		out = append(out, m)
	}
	sort.Strings(out)
	return out
}

// String renders the set the way it is stored and sealed into a token: members
// in sorted order, the id: member last.
//
// Sorted rather than in the order the caller wrote them, because the string is
// both a database column and a token payload, and two spellings of one
// permission set would make an audit row that reads differently from the token
// it describes. The id: goes last so that the human-readable part of a stored
// grant string is a prefix of it, which is what makes a rendered token row
// legible without parsing.
func (g Grants) String() string {
	var b strings.Builder
	if g.all {
		b.WriteString(Universal)
	} else {
		for i, m := range g.Members() {
			if i > 0 {
				b.WriteByte(' ')
			}
			b.WriteString(m)
		}
	}
	if g.tokenID > 0 {
		if b.Len() > 0 {
			b.WriteByte(' ')
		}
		b.WriteString(grantIDService)
		b.WriteByte(':')
		b.WriteString(strconv.Itoa(g.tokenID))
	}
	return b.String()
}