@@ 0,0 1,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 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()
+}
@@ 0,0 1,216 @@
+package grants
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// nbsp is U+00A0 NON-BREAKING SPACE, built from its code point rather than
+// pasted in as a character.
+//
+// It is the input asciiFields exists for: under strings.Fields a grant string
+// holding nothing but this splits into zero members, and zero members means the
+// universal set. Spelling it as a code point keeps the case that matters from
+// depending on an invisible byte pair surviving an editor's whitespace
+// normalisation, a copy-paste or a diff viewer — the very fragility the rule
+// under test is about.
+var nbsp = string(rune(0x00a0))
+
+func TestParseReadsTheVocabularyOfTheSpec(t *testing.T) {
+ g, err := Parse("cover:upload bench:upload spec:propose")
+ require.NoError(t, err)
+
+ assert.False(t, g.All())
+ assert.True(t, g.Has("cover:upload"))
+ assert.True(t, g.Has("bench:upload"))
+ assert.True(t, g.Has("spec:propose"))
+ assert.False(t, g.Has("cover:read"), "a grant not named is not held")
+ assert.Equal(t, 0, g.TokenID(), "no id: member, so no row id")
+}
+
+// A blank grant string means every action, which is what the column of a parent
+// minted before some service existed holds (SPEC ch. 3).
+func TestBlankGrantsMeanEverything(t *testing.T) {
+ for _, s := range []string{"", " ", "\t\n"} {
+ g, err := Parse(s)
+ require.NoError(t, err, "parse %q", s)
+ assert.True(t, g.All(), "%q should be universal", s)
+ assert.True(t, g.Has("anything:at-all"))
+ }
+}
+
+func TestUniversalGrantAdmitsEverything(t *testing.T) {
+ g, err := Parse("*")
+ require.NoError(t, err)
+ assert.True(t, g.All())
+ assert.True(t, g.Has("bench:upload"))
+ assert.Equal(t, "*", g.String())
+}
+
+// The zero value must grant nothing. It is what a caller who forgot to parse
+// ends up holding, and the failure has to be closed.
+func TestZeroGrantsAdmitNothing(t *testing.T) {
+ var g Grants
+ assert.False(t, g.All())
+ assert.True(t, g.Empty())
+ assert.False(t, g.Has("bench:upload"))
+ assert.Equal(t, "", g.String())
+}
+
+// "* cover:upload" is the universal set with one member spelled out redundantly.
+// Keeping the member would make String render something longer than what it
+// means.
+func TestUniversalAbsorbsNamedMembers(t *testing.T) {
+ g, err := Parse("cover:upload * bench:read")
+ require.NoError(t, err)
+ assert.True(t, g.All())
+ assert.Equal(t, "*", g.String())
+}
+
+func TestStringIsSortedAndDeduplicated(t *testing.T) {
+ g, err := Parse("spec:propose cover:upload bench:upload cover:upload")
+ require.NoError(t, err)
+ assert.Equal(t, "bench:upload cover:upload spec:propose", g.String(),
+ "one permission set must have one spelling: the string is both a column and a token payload")
+}
+
+// SPEC ch. 3 requires the format not to forbid the repository-scoped form a
+// later version may want.
+func TestActionsMayCarryFurtherSegments(t *testing.T) {
+ g, err := Parse("bench:upload:~bigbes/foo")
+ require.NoError(t, err)
+ assert.True(t, g.Has("bench:upload:~bigbes/foo"))
+ assert.False(t, g.Has("bench:upload"),
+ "a repository-scoped grant is not the unscoped one")
+}
+
+func TestMalformedGrantsAreRefused(t *testing.T) {
+ for name, s := range map[string]string{
+ "no colon": "coverupload",
+ "leading colon": ":upload",
+ "trailing colon": "cover:",
+ "empty segment": "cover::upload",
+ "upper case": "Cover:upload",
+ "non-ascii": "cover:upload" + nbsp,
+ "lone nbsp": nbsp,
+ "control byte": "cover:up\x01load",
+ "bare wildcard 2": "cover:upload **",
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := Parse(s)
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err)
+ })
+ }
+}
+
+// The lone non-breaking space deserves its own assertion, not just membership in
+// the table above: the bug it guards against was not "this errors" but "this
+// silently parsed as the universal set", and only comparing against All() says
+// so.
+func TestALoneNonBreakingSpaceIsNotTheUniversalSet(t *testing.T) {
+ g, err := Parse(nbsp)
+ require.Error(t, err, "a grant string nobody can see must not decide what a credential can do")
+ assert.True(t, errors.Is(err, ErrInvalid))
+ assert.False(t, g.All(), "the refused parse must not hand back the widest set on the instance")
+ assert.True(t, g.Empty())
+}
+
+func TestOverlongGrantsAreRefused(t *testing.T) {
+ _, err := Parse(strings.Repeat("a", MaxGrantsLen+1))
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrInvalid))
+}
+
+// The id: member is stamped by the daemon and must never be requestable: a
+// caller who could choose it could point their token's revocation check at
+// somebody else's live row and make their own revoke a no-op.
+func TestRequestedGrantsRefuseTheReservedIDMember(t *testing.T) {
+ for _, s := range []string{"id:7", "cover:upload id:7", "id:0", "id:nope"} {
+ _, err := ParseRequested(s)
+ require.Error(t, err, "should refuse %q", s)
+ assert.True(t, errors.Is(err, ErrInvalid), "%q: %v", s, err)
+ }
+}
+
+func TestStoredGrantsCarryTheIDMember(t *testing.T) {
+ g, err := Parse("bench:upload id:42")
+ require.NoError(t, err)
+
+ assert.Equal(t, 42, g.TokenID())
+ assert.True(t, g.Has("bench:upload"))
+ assert.False(t, g.Has("id:42"), "the id is metadata, not a permission")
+ assert.Equal(t, []string{"bench:upload"}, g.Members(),
+ "the id must not show up in what a page lists")
+ assert.Equal(t, "bench:upload id:42", g.String(), "the id renders last")
+}
+
+func TestIDMemberIsRefusedWhenItNamesNoRow(t *testing.T) {
+ for _, s := range []string{"id:0", "id:-1", "id:x", "id:1.5"} {
+ _, err := Parse(s)
+ require.Error(t, err, "should refuse %q", s)
+ assert.True(t, errors.Is(err, ErrInvalid), "%q: %v", s, err)
+ }
+}
+
+func TestWithTokenIDStampsAndStrips(t *testing.T) {
+ g, err := ParseRequested("bench:upload")
+ require.NoError(t, err)
+
+ stamped := g.WithTokenID(42)
+ assert.Equal(t, "bench:upload id:42", stamped.String())
+ assert.Equal(t, "bench:upload", g.String(), "the original must not be mutated")
+ assert.Equal(t, "bench:upload", stamped.WithTokenID(0).String())
+
+ universal := All().WithTokenID(9)
+ assert.Equal(t, "* id:9", universal.String())
+ round, err := Parse(universal.String())
+ require.NoError(t, err)
+ assert.True(t, round.All())
+ assert.Equal(t, 9, round.TokenID())
+}
+
+// The narrowing rule of SPEC ch. 2: an exchange may drop grants and may not add
+// them.
+func TestIsSubsetOfIsTheNarrowingRule(t *testing.T) {
+ parse := func(s string) Grants {
+ g, err := Parse(s)
+ require.NoError(t, err)
+ return g
+ }
+
+ parent := parse("cover:upload bench:upload")
+
+ assert.True(t, parse("bench:upload").IsSubsetOf(parent), "dropping a grant narrows")
+ assert.True(t, parse("cover:upload bench:upload").IsSubsetOf(parent), "asking for all of them")
+ assert.True(t, Grants{}.IsSubsetOf(parent), "the empty set is a subset of anything")
+
+ assert.False(t, parse("spec:propose").IsSubsetOf(parent), "a grant the parent lacks")
+ assert.False(t, parse("bench:upload spec:propose").IsSubsetOf(parent), "one of two is not enough")
+ assert.False(t, parse("*").IsSubsetOf(parent), "universal is not a subset of a named set")
+
+ all := parse("*")
+ assert.True(t, parse("bench:upload").IsSubsetOf(all), "everything narrows a universal parent")
+ assert.True(t, all.IsSubsetOf(all), "universal narrows universal")
+}
+
+// A stamped child is still a narrowing of its parent: the id: is not a
+// permission and must take no part in the comparison.
+func TestSubsetIgnoresTheIDMember(t *testing.T) {
+ parent, err := Parse("cover:upload bench:upload")
+ require.NoError(t, err)
+ child, err := Parse("bench:upload id:42")
+ require.NoError(t, err)
+
+ assert.True(t, child.IsSubsetOf(parent))
+}
+
+func TestMembersNeverExposeTheWildcardAsAMember(t *testing.T) {
+ g, err := Parse("*")
+ require.NoError(t, err)
+ assert.Equal(t, []string{Universal}, g.Members())
+}