From 5ba967ed2e6de75de1e1ee980a9f1fb855e13914 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 17:20:56 +0300 Subject: [PATCH] grants: the shared grant vocabulary of tokens.sr.ht --- README.md | 11 +- grants/grants.go | 335 ++++++++++++++++++++++++++++++++++++++++++ grants/grants_test.go | 216 +++++++++++++++++++++++++++ 3 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 grants/grants.go create mode 100644 grants/grants_test.go diff --git a/README.md b/README.md index d63a1abdede05a023dae71a8702c40542f28eb52..9f23f3ef93e72878a3a8e33524d882f25c9deec9 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,15 @@ copies of the same code. `srht-nav` / `srht-env-banner` template partials (circle brand + red service label + switcher + login box), and the generic template helpers (`dict`, `shortsha`). +- `grants` — the grant vocabulary of tokens.sr.ht (SPEC ch. 3): + `:` members split on ASCII whitespace, `*` for every action + of every service, the reserved `id:` member a registered token carries, + and the subset rule an exchange narrows by. Shared because the daemon that + mints and every service that validates have to read one grammar — two + parsers that disagree about what counts as a permission is a hole on the + security path, not a cosmetic divergence. -## Usage +## Usage: chrome ```go svc := chrome.NewService(conf, "compare.sr.ht") @@ -40,7 +47,7 @@ The template dot must expose the `chrome.Page` fields — either a `Page` itself, or a service view struct that embeds one (promoted fields resolve in templates). -## Policy +## Policy: chrome The chrome bakes in the instance-wide decisions instead of parameterizing them: the switcher renders only for authenticated viewers; paste, pages and diff --git a/grants/grants.go b/grants/grants.go new file mode 100644 index 0000000000000000000000000000000000000000..a057273ef2aed5a50aa3c5ca36c47d90589966d8 --- /dev/null +++ b/grants/grants.go @@ -0,0 +1,335 @@ +// 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 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 : 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() +} diff --git a/grants/grants_test.go b/grants/grants_test.go new file mode 100644 index 0000000000000000000000000000000000000000..69c171e30f3b55eaf48703cafc5ddc51ecae01f4 --- /dev/null +++ b/grants/grants_test.go @@ -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()) +}