~bigbes/sr-ht-ecore

5ba967ed2e6de75de1e1ee980a9f1fb855e13914 — Eugene Blikh 10 days ago 3631585
grants: the shared grant vocabulary of tokens.sr.ht
3 files changed, 560 insertions(+), 2 deletions(-)

M README.md
A grants/grants.go
A grants/grants_test.go
M README.md => README.md +9 -2
@@ 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):
  `<service>:<action>` members split on ASCII whitespace, `*` for every action
  of every service, the reserved `id:<n>` 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

A grants/grants.go => grants/grants.go +335 -0
@@ 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()
}

A grants/grants_test.go => grants/grants_test.go +216 -0
@@ 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())
}