// 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 cover:download to cover.sr.ht is a change to cover.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{}{}
}
// "* cover: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: "cover:*" is a member like any
// other and matches only a validator asking for exactly "cover:*". 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()
}