coverage~bigbes/sr-ht-ecoreb36a9272grants/grants.go

Coverage
100.0% 86/86 statements
Δ
+0.0
Blob
9400c09
Uncovered nothing — every instrumented line ran
1 // Package grants is the grant vocabulary of tokens.sr.ht: space-separated
2 // members, each naming an action as "<service>:<action>", with "*" standing for
3 // every action of every service.
4 //
5 // It lives here rather than inside tokens.sr.ht because two ends read the same
6 // string and they must read it identically. The daemon parses a grant string to
7 // decide what it may seal into a working token; every service that accepts one
8 // parses it again to decide whether the holder may act. Two parsers that
9 // disagree about — say — whether "id:42" is a permission, or about what counts
10 // as whitespace, is not a cosmetic divergence: it is a hole on the security
11 // path, and the only reliable way to keep the two in step is to have one of
12 // them.
13 //
14 // This package parses that string and answers questions about it. It does not
15 // know which services exist or which actions they define, and it must not learn:
16 // SPEC ch. 3 gives the vocabulary to the services and keeps the daemon out of
17 // it, so that adding cov:download to cov.sr.ht is a change to cov.sr.ht.
18 // An unknown grant is therefore not an error — it is a grant that happens to
19 // admit nobody anywhere, which is the safe direction for a string the daemon
20 // only ever passes through.
21 //
22 // What is validated is the shape, and only the shape a mistake could hide in: a
23 // member with no colon, an empty segment, a byte outside printable ASCII, an
24 // upper-case letter. Case is refused rather than folded because a validator
25 // compares grants literally, so "Bench:upload" folded here and spelled that way
26 // in a service's check are two different silent failures, and a 400 at mint time
27 // is the one place a human is still looking.
28 //
29 // The syntax deliberately admits more than v1 uses. SPEC ch. 3 names
30 // "bench:upload:~bigbes/foo" as the repository-scoped form a later version may
31 // want and requires the format not to forbid it, so an action may carry further
32 // colon-separated segments and the characters a repository reference needs.
33 //
34 // This is not core-go's auth.Grants. That one is meta.sr.ht's OAuth vocabulary
35 // ("git.sr.ht/OBJECTS:RW" — a service, a scope and an access mode), it is what a
36 // meta PAT carries, and nothing here parses it or is parsed by it. The two
37 // grammars share a token format and nothing else.
38 package grants
39
40 import (
41 "errors"
42 "fmt"
43 "sort"
44 "strconv"
45 "strings"
46 )
47
48 // ErrInvalid is the sentinel every refusal in this package wraps.
49 //
50 // The package owns it rather than borrowing tokens.sr.ht's error vocabulary,
51 // because the importers are on both sides of that daemon: the daemon maps it to
52 // a 400 on a mint, and a validating service reaches it while parsing a string
53 // it did not write. A shared library that imported one consumer's sentinels
54 // would make every other consumer depend on that consumer.
55 var ErrInvalid = errors.New("invalid grants")
56
57 const (
58 // Universal is the member that stands for every action of every service.
59 Universal = "*"
60
61 // grantIDService is the reserved service name of the member that carries a
62 // registered working token's row id (SPEC ch. 4). It travels inside the
63 // grant string because auth.BearerToken has no field to put it in, not
64 // because it is a permission — Grants keeps it apart from the permission set
65 // everywhere, and IsSubsetOf ignores it on both sides.
66 grantIDService = "id"
67
68 // MaxGrantsLen bounds the rendered grant string. The bound exists because
69 // the string is copied into a working token's payload and that payload is
70 // handed to a client as one base64 line: an unbounded grants field is an
71 // unbounded token, and a token too long to put in an HTTP header is a
72 // credential that authenticates nothing while looking valid. 4 KiB is two
73 // orders of magnitude above the vocabulary of SPEC ch. 3 and still leaves
74 // the encoded token comfortably inside every proxy's header limit.
75 MaxGrantsLen = 4096
76 )
77
78 // Grants is a parsed grant set: either universal, or an explicit set of
79 // members, optionally carrying the row id of the registered working token it
80 // was read from.
81 //
82 // The zero value is the empty set, which grants nothing. That is deliberately
83 // not the same as the universal set even though an empty *string* parses to
84 // universal (SPEC ch. 3: an empty grants column on an old parent token means
85 // "everything"). A caller that forgot to parse must end up with a token that
86 // admits nobody, not with one that admits everybody, so the meaning of "" lives
87 // in Parse and not in the zero value.
88 type Grants struct {
89 all bool
90 members map[string]struct{}
91 tokenID int
92 }
93
94 // All is the universal set.
95 1 func All() Grants { return Grants{all: true} }
96
97 // Parse parses a stored or presented grant string, the id: member included. Use
98 // it when reading a token or a database column — that is, when the string is one
99 // tokens.sr.ht wrote.
100 //
101 // For a string a caller supplied, use ParseRequested instead: it is the same
102 // parse with the id: member refused, and the difference is a privilege boundary
103 // rather than a convenience (see there).
104 36 func Parse(s string) (Grants, error) {
105 36 return parse(s, true)
106 36 }
107
108 // ParseRequested parses a grant string that came from a caller — a mint body, an
109 // exchange body, a form field — and refuses the reserved id: member.
110 //
111 // The refusal is what keeps revocation honest. A registered working token proves
112 // it is still live by the id: it carries (SPEC ch. 6 step 4), and a validator
113 // asks the daemon about that id and nothing else. A caller allowed to choose it
114 // could name the id of some other token that is still alive, and their own
115 // revocation would then stop revoking anything — the row would be stamped and
116 // the credential would keep passing, which is the one failure this whole
117 // mechanism exists to prevent. Naming a nonexistent id fails closed (the
118 // revocation check 404s and the token is refused), so only the live-id case is
119 // dangerous, and both are refused here rather than one.
120 5 func ParseRequested(s string) (Grants, error) {
121 5 return parse(s, false)
122 5 }
123
124 41 func parse(s string, allowID bool) (Grants, error) {
125 41 if len(s) > MaxGrantsLen {
126 1 return Grants{}, fmt.Errorf("%w: grants are %d bytes, the limit is %d",
127 1 ErrInvalid, len(s), MaxGrantsLen)
128 1 }
129
130 40 fields := asciiFields(s)
131 40 if len(fields) == 0 {
132 3 // SPEC ch. 3: a blank grant string means every action. It is what the
133 3 // column of a parent token minted before some service existed holds.
134 3 return Grants{all: true}, nil
135 3 }
136
137 37 g := Grants{members: make(map[string]struct{}, len(fields))}
138 53 for _, m := range fields {
139 53 if m == Universal {
140 6 g.all = true
141 6 continue
142 }
143 47 service, action, err := splitMember(m)
144 47 if err != nil {
145 10 return Grants{}, err
146 10 }
147 37 if service == grantIDService {
148 11 if !allowID {
149 4 return Grants{}, fmt.Errorf(
150 4 "%w: %q is reserved: the id: member is stamped by the daemon, not requested",
151 4 ErrInvalid, m)
152 4 }
153 7 id, err := strconv.Atoi(action)
154 7 if err != nil || id <= 0 {
155 4 return Grants{}, fmt.Errorf("%w: %q does not name a row id", ErrInvalid, m)
156 4 }
157 3 g.tokenID = id
158 3 continue
159 }
160 26 g.members[m] = struct{}{}
161 }
162
163 // "* cov:upload" is the universal set with a redundant member spelled out;
164 // keeping the member would make String round-trip to something longer than
165 // what it means, and Has already answers true for everything.
166 19 if g.all {
167 6 g.members = nil
168 6 }
169 19 return g, nil
170 }
171
172 // asciiFields splits a grant string on ASCII whitespace, and on nothing else.
173 //
174 // strings.Fields would be the obvious choice and is the wrong one, because it
175 // splits on unicode.IsSpace — which includes U+00A0, U+2007, the ideographic
176 // space and a dozen more. Under it a grants field holding nothing but a
177 // non-breaking space split into zero members, and zero members is the rule of
178 // SPEC ch. 3 that a blank grant string means *every* action: one invisible
179 // character pasted into a form was the difference between "no grants stated" and
180 // a universal token. The subset check of an exchange still bounded that, so it
181 // was not an escalation — but a mint from the UI has no parent to be bounded by,
182 // and a value nobody can see should not decide what a credential can do.
183 //
184 // Splitting on ASCII only makes the same input an error instead: U+00A0 stays
185 // inside the member and splitMember refuses it as a byte outside printable
186 // ASCII, which is a 400 a human can act on. This is the same reading of
187 // "whitespace" the sibling services settled on for query parameters, and for the
188 // same reason — the unicode set is right for prose and wrong for anything a
189 // machine compares literally.
190 40 func asciiFields(s string) []string {
191 457 return strings.FieldsFunc(s, func(r rune) bool {
192 457 switch r {
193 24 case ' ', '\t', '\n', '\v', '\f', '\r':
194 24 return true
195 433 default:
196 433 return false
197 }
198 })
199 }
200
201 // splitMember validates one member and splits it at its first colon. The
202 // remainder is returned whole, colons included, because an action may carry
203 // further segments (SPEC ch. 3's repository-scoped form).
204 47 func splitMember(m string) (service, action string, err error) {
205 412 for i := 0; i < len(m); i++ {
206 412 c := m[i]
207 412 if c < 0x21 || c > 0x7e {
208 4 return "", "", fmt.Errorf("%w: grant %q holds a byte outside printable ASCII",
209 4 ErrInvalid, m)
210 4 }
211 408 if c >= 'A' && c <= 'Z' {
212 1 return "", "", fmt.Errorf(
213 1 "%w: grant %q is not lower case; grants are compared literally", ErrInvalid, m)
214 1 }
215 }
216 42 i := strings.IndexByte(m, ':')
217 42 if i < 0 {
218 2 return "", "", fmt.Errorf("%w: grant %q is not in <service>:<action> form", ErrInvalid, m)
219 2 }
220 40 if i == 0 || i == len(m)-1 || strings.Contains(m, "::") {
221 3 return "", "", fmt.Errorf("%w: grant %q has an empty segment", ErrInvalid, m)
222 3 }
223 37 return m[:i], m[i+1:], nil
224 }
225
226 // All reports whether this is the universal set.
227 9 func (g Grants) All() bool { return g.all }
228
229 // Empty reports whether the set admits nothing at all. Only the zero value and
230 // a set built by removing every member can be empty — a parsed string never is,
231 // because a blank one is universal.
232 2 func (g Grants) Empty() bool { return !g.all && len(g.members) == 0 }
233
234 // Has reports whether the set admits one named action, e.g. "bench:upload".
235 //
236 // There is no wildcard below the universal one: "cov:*" is a member like any
237 // other and matches only a validator asking for exactly "cov:*". SPEC ch. 3
238 // defines "*" and nothing else, and a per-service wildcard invented here would
239 // be a permission the services do not know they are honouring.
240 13 func (g Grants) Has(grant string) bool {
241 13 if g.all {
242 4 return true
243 4 }
244 9 _, ok := g.members[grant]
245 9 return ok
246 }
247
248 // IsSubsetOf reports whether every action this set admits is also admitted by
249 // other. It is the whole of the narrowing rule of SPEC ch. 2: an exchange may
250 // drop grants and may not add them.
251 //
252 // The id: member is not a permission and takes no part in the comparison — a
253 // registered child of a stateless parent is still a narrowing, and a token
254 // compared against the parent it came from would otherwise never be a subset of
255 // anything once it had been stamped.
256 9 func (g Grants) IsSubsetOf(other Grants) bool {
257 9 if other.all {
258 2 return true
259 2 }
260 7 if g.all {
261 1 // Universal is a subset only of universal, which the branch above
262 1 // already answered.
263 1 return false
264 1 }
265 7 for m := range g.members {
266 7 if _, ok := other.members[m]; !ok {
267 2 return false
268 2 }
269 }
270 4 return true
271 }
272
273 // TokenID returns the row id this grant string carries, or 0 when it carries
274 // none. A working token with no id: is a stateless one (SPEC ch. 2): it was
275 // never written to the database and has no revocation to check.
276 3 func (g Grants) TokenID() int { return g.tokenID }
277
278 // WithTokenID returns a copy carrying the given row id. Passing 0 strips it,
279 // which is what turns a stored grant string into the one shown to a human.
280 3 func (g Grants) WithTokenID(id int) Grants {
281 3 out := Grants{all: g.all, tokenID: id}
282 3 if g.members != nil {
283 2 out.members = make(map[string]struct{}, len(g.members))
284 2 for m := range g.members {
285 2 out.members[m] = struct{}{}
286 2 }
287 }
288 3 return out
289 }
290
291 // Members returns the permission members in sorted order, without the id:.
292 // It is what a page lists and what a mint response echoes.
293 8 func (g Grants) Members() []string {
294 8 if g.all {
295 1 return []string{Universal}
296 1 }
297 7 out := make([]string, 0, len(g.members))
298 8 for m := range g.members {
299 8 out = append(out, m)
300 8 }
301 7 sort.Strings(out)
302 7 return out
303 }
304
305 // String renders the set the way it is stored and sealed into a token: members
306 // in sorted order, the id: member last.
307 //
308 // Sorted rather than in the order the caller wrote them, because the string is
309 // both a database column and a token payload, and two spellings of one
310 // permission set would make an audit row that reads differently from the token
311 // it describes. The id: goes last so that the human-readable part of a stored
312 // grant string is a prefix of it, which is what makes a rendered token row
313 // legible without parsing.
314 10 func (g Grants) String() string {
315 10 var b strings.Builder
316 10 if g.all {
317 4 b.WriteString(Universal)
318 6 } else {
319 7 for i, m := range g.Members() {
320 7 if i > 0 {
321 2 b.WriteByte(' ')
322 2 }
323 7 b.WriteString(m)
324 }
325 }
326 10 if g.tokenID > 0 {
327 4 if b.Len() > 0 {
328 4 b.WriteByte(' ')
329 4 }
330 4 b.WriteString(grantIDService)
331 4 b.WriteByte(':')
332 4 b.WriteString(strconv.Itoa(g.tokenID))
333 }
334 10 return b.String()
335 }