| 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 |
|
} |