// Package grants is the grant vocabulary of tokens.sr.ht: space-separated // members, each naming an action as ":", 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 : 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() }