From 89fa694cbf548ecd642b8101825b7a49b48f975a Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 16 Aug 2026 21:42:19 +0300 Subject: [PATCH] metapat: the meta.sr.ht PAT plane a federated endpoint needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.sr.ht forwards ONE client Authorization header to every service a federated query touches — AuthMiddleware copies it verbatim, and the Internal credential it can mint is used only to fetch schemas at startup. So a GraphQL endpoint that refuses meta.sr.ht personal access tokens cannot be federated: the first authenticated query answers 401. Four services on this instance refuse them today and publish an empty scope list besides, which is the stronger half of the same problem — meta cannot mint a token for a service whose api-meta.json names no scope. This is the half of accepting one that is the same everywhere: routing a credential to its plane, the decode/lookup/revocation path, and the OAuth scope gate. dolt is where it is written today; putting a fourth and fifth copy of a credential path in four services is how the copies start to disagree. PlaneOf routes without a validator, which matters more than it looks: an instance with no [tokens.sr.ht] section holds no working-token validator to call, and its PAT plane must keep working anyway. Only successes are cached, so an outage cannot be pinned into the cache and outlive itself, and the cache is bounded — dolt's grows without one. Options.Service is required because auth.DecodeGrants reads the calling service's name off the context and config.ServiceName PANICS for the absence of it rather than answering "". In production config.Middleware puts it there; anywhere else — a job, a CLI, a test — a validator that leaned on the ambient context would take the process down. So the validator carries its own. bearer's step-2 prose said bench and cover had no reason to accept a meta PAT. That was true of a service reached only by its own clients and is not true of one behind the gateway; it now says which surface accepts which credential, and points at PlaneOf. --- bearer/bearer.go | 19 +- metapat/metapat.go | 553 +++++++++++++++++++++++++++++++++++ metapat/metapat_test.go | 630 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1196 insertions(+), 6 deletions(-) create mode 100644 metapat/metapat.go create mode 100644 metapat/metapat_test.go diff --git a/bearer/bearer.go b/bearer/bearer.go index d974dddd0384cbc69de6e043f2f30958b37bb31d..7749089f640673465e0930c954d03d38df48d7d9 100644 --- a/bearer/bearer.go +++ b/bearer/bearer.go @@ -323,20 +323,27 @@ func New(opts Options) (*Validator, error) { // A token whose ClientID is not TokensClientID gets ErrNotOurs and the decoded // token, and this package takes it no further. That is deliberate and SPEC ch. 6 // step 2 requires it: what to do with a foreign bearer token is per-service -// policy, not a property of the format. dolt accepts meta.sr.ht PATs today and -// must keep accepting them; bench and cover have no reason to. A validator that -// refused on its own behalf would break the first and look correct doing it, -// because the token really is not one of ours — it just is not this package's -// call. +// policy, not a property of the format, and the policy varies by *surface* and +// not only by service. Every GraphQL endpoint on the instance accepts meta PATs, +// because api.sr.ht forwards one client credential to every service a federated +// query touches, so one that refused them could not be federated at all; the +// REST and MCP surfaces beside them accept only working tokens, where a narrow, +// revocable grant is worth its cost. A validator that refused on its own behalf +// would break the first kind and look correct doing it, because the token really +// is not one of ours — it just is not this package's call. // // So a service that also accepts meta PATs writes: // // tok, err := v.Validate(ctx, presented, "dolt:push") // if errors.Is(err, bearer.ErrNotOurs) { -// // ... its own meta-PAT path ... +// // ... its own meta-PAT path — see the metapat package ... // } // // and one that does not turns ErrNotOurs into 401 alongside ErrInvalid. +// +// Routing on this error is the second-best way to do it, and metapat.PlaneOf is +// the first: an instance with no [tokens.sr.ht] section holds no Validator to +// call, and its meta PAT plane has to keep working anyway. func (v *Validator) Validate(ctx context.Context, presented, action string) (*Token, error) { tok, err := decodeOurs(presented) if err != nil { diff --git a/metapat/metapat.go b/metapat/metapat.go new file mode 100644 index 0000000000000000000000000000000000000000..ea41cf949aeb533f6f7146a42d89b14023d4d4f3 --- /dev/null +++ b/metapat/metapat.go @@ -0,0 +1,553 @@ +// Package metapat validates a meta.sr.ht personal access token, and is the one +// copy of that check for every service on the instance that accepts one beside +// a tokens.sr.ht working token. +// +// # Why a service needs both planes +// +// The instance seals two bearer shapes with the same key, and only the ClientID +// tells them apart (bearer.TokensClientID). Which one a surface accepts is not a +// matter of taste: +// +// - api.sr.ht forwards ONE client "Authorization" header to every service a +// federated query touches — its AuthMiddleware copies the header verbatim +// into the request context, and the Internal credential it can mint is used +// only to fetch schemas at startup. So a federated query carries whatever +// credential the client had, to all of its services at once. A GraphQL +// endpoint that refuses meta PATs therefore cannot be federated: the first +// authenticated query that reaches it answers 401. +// - Every upstream service on the instance authenticates machine callers with +// a meta PAT, so a meta PAT is the only credential a client can hold that +// works instance-wide. +// +// A tokens.sr.ht working token remains the credential of the surfaces that are +// not federated — the REST uploads and the MCP endpoints — because those are +// where a narrow, short-lived, revocable grant is worth its cost. This package +// is what lets one service hold both without writing the PAT path four times. +// +// # What this package does and does not decide +// +// It answers exactly one question: is this presented string a live meta.sr.ht +// personal access token, and whose? Resolving that into the service's own notion +// of a caller, choosing an HTTP status for each refusal, and deciding what the +// caller may then see are all the service's, as they are for bearer. +// +// The steps, in order, and the order is the point — everything that can refuse +// locally runs before anything that touches the network: +// +// 1. decode and verify the signature and expiry (local, no network); +// 2. is this a PAT at all, or a working token wearing the same envelope? +// 3. mirror the owner's profile from meta.sr.ht; +// 4. ask meta.sr.ht whether the token has been revoked. +// +// Steps 3 and 4 are cached together for CacheTTL, so a burst of federated +// queries carrying one PAT costs one pair of lookups rather than one per field +// resolver. +// +// Scope enforcement is deliberately not part of resolution. A PAT carries +// core-go's OAuth grant vocabulary ("cov.sr.ht/REPORTS:RO"), the surface knows +// which scope and which mode it is about to exercise, and Allows is where the +// two meet. +// +// Usage: +// +// v, err := metapat.New(metapat.Options{Service: "cov.sr.ht"}) +// ... +// switch metapat.PlaneOf(presented) { +// case metapat.PlaneWorking: +// tok, err := workingTokens.Inspect(ctx, presented) +// ... +// case metapat.PlaneMeta: +// ac, err := v.Resolve(ctx, presented) +// if err == nil && !metapat.Allows(ac, "cov.sr.ht/REPORTS", auth.RO) { +// // 403 +// } +// } +// +// The process must have run crypto.InitCrypto before any of this: the signing +// key step 1 verifies against lives in that package's globals. This is the same +// precondition every core-go authentication path carries, and it is not checked +// here, because there is nothing this package could usefully do about it at +// request time. +package metapat + +import ( + "context" + "crypto/sha512" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/vaughan0/go-ini" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + "sourcecraft.dev/bigbes/sr-ht-core/config" + + "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" +) + +// DefaultCacheTTL is how long one resolution is reused when Options leaves +// CacheTTL at zero. +// +// Sixty seconds, matching the figure the tokens SPEC names for the working-token +// revocation check, and for the same trade: it bounds how long a revoked +// credential keeps working, against how hard a single agent's request loop hits +// meta.sr.ht. +const DefaultCacheTTL = 60 * time.Second + +// maxCacheEntries bounds the resolution cache. See (*Validator).remember for +// what happens at the bound and why that is the right thing to happen. +const maxCacheEntries = 4096 + +// The refusals of this package, and the status each one is for a service. +// +// They are separate sentinels rather than one error with a code because the +// mapping is not uniform, and they are spelled to mirror bearer's, so that a +// service holding both planes writes one classification table and not two: +// +// - ErrInvalid — 401. The signature did not verify, the token has expired, or +// it names an account meta.sr.ht will not resolve. +// - ErrNotOurs — the service's own policy, not a status. See Resolve. +// - ErrForbidden — 403. The credential is good; its OAuth grants do not cover +// what is being attempted. Returned by callers of Allows, never by Resolve. +// - ErrRevoked — 401. The token was withdrawn by its owner. +// - ErrUnavailable — 503. meta.sr.ht could not be asked. +// +// The 503 is the one that has to be defended, for the reason bearer's own +// sentinels give at length: "I could not check" is not "your credential is bad", +// and answering 401 to a meta.sr.ht outage tells every client on the instance to +// go and re-mint credentials that were never broken. It is also what core-go +// itself answers when meta cannot be reached, so a service that classifies this +// way stays consistent with the upstream services beside it. +var ( + // ErrInvalid: the presented string is not a personal access token this + // instance sealed, or no longer is one. 401. + ErrInvalid = errors.New("metapat: token does not verify") + + // ErrNotOurs: a well-formed token sealed by tokens.sr.ht rather than by + // meta.sr.ht. Returned so that a service which routes on failure rather than + // on PlaneOf still gets a total answer; the status is the service's to + // choose, and for a service holding both planes it is not a refusal at all. + ErrNotOurs = errors.New("metapat: token was issued by tokens.sr.ht, not meta.sr.ht") + + // ErrForbidden: the token is good and does not carry the scope. 403. This + // package returns it from no function — Allows answers a bool — and it is + // exported so that the service's refusal has a sentinel to wrap that belongs + // to the same table as the rest. + ErrForbidden = errors.New("metapat: token does not carry the required OAuth scope") + + // ErrRevoked: meta.sr.ht reports the token as revoked. 401 and not 403: it + // is no longer a credential at all, and a client shown 403 will keep + // presenting it. + ErrRevoked = errors.New("metapat: token has been revoked") + + // ErrUnavailable: the profile mirror or the revocation check could not be + // completed. 503, never 401 — see above. + ErrUnavailable = errors.New("metapat: meta.sr.ht could not be reached") +) + +// Plane says which of the instance's two bearer planes sealed a credential. +type Plane int + +const ( + // PlaneUnknown: the string does not decode as a bearer token this instance + // sealed at all — forged, corrupted, expired, or simply not a token. + PlaneUnknown Plane = iota + + // PlaneWorking: a tokens.sr.ht working token. + PlaneWorking + + // PlaneMeta: a meta.sr.ht personal access token. + PlaneMeta +) + +// String names the plane, for a log line. +func (p Plane) String() string { + switch p { + case PlaneWorking: + return "tokens.sr.ht working token" + case PlaneMeta: + return "meta.sr.ht personal access token" + default: + return "unrecognised credential" + } +} + +// PlaneOf reports which plane a presented credential belongs to, without +// resolving it — one local HMAC and no network. +// +// This is how a service routes, and routing here rather than on the failure of +// one plane matters for a reason that is easy to miss: an instance whose config +// has no [tokens.sr.ht] section holds no working-token validator at all, and its +// meta PAT plane must keep working anyway. A service that routed by calling the +// working-token validator first and catching bearer.ErrNotOurs would have +// nothing to call. +// +// PlaneUnknown is not a verdict about the credential's issuer, only about this +// process's ability to read it. An expired token of either plane lands here, +// because auth.DecodeBearerToken checks expiry before it reports anything — so a +// service should answer PlaneUnknown with the same 401 it gives ErrInvalid, +// rather than treating it as "no credential presented". +func PlaneOf(presented string) Plane { + bt := auth.DecodeBearerToken(presented) + if bt == nil { + return PlaneUnknown + } + if bt.ClientID == bearer.TokensClientID { + return PlaneWorking + } + return PlaneMeta +} + +// Backend is the meta.sr.ht half of the check, declared here as an interface so +// that every arm of Resolve is testable without a meta.sr.ht, without a network +// and without a database. +// +// Both methods are core-go calls in production (CoreBackend), and both may hit +// the network: LookupUser falls back to an internal GraphQL query when the local +// mirror misses, and IsRevoked always asks. +type Backend interface { + // LookupUser mirrors a meta.sr.ht profile into out, filling in at least + // UserID and Username. An error is transient by contract — the account may + // exist and meta may simply be unreachable. + LookupUser(ctx context.Context, username string, out *auth.AuthContext) error + + // IsRevoked reports whether the personal access token with this sha512 has + // been revoked by its owner. clientID is the token's own, which is what + // scopes the revocation row. + IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) +} + +// coreBackend is the production Backend: core-go, unadorned. +type coreBackend struct{} + +// Compile-time proof that the production backend satisfies the port. Its two +// methods are the only lines in this package a test cannot reach — they need a +// meta.sr.ht — so this is what stands between them and a signature drift. +var _ Backend = coreBackend{} + +func (coreBackend) LookupUser(ctx context.Context, username string, out *auth.AuthContext) error { + return auth.LookupUser(ctx, username, out) +} + +func (coreBackend) IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) { + return auth.LookupTokenRevocation(ctx, username, hash, clientID) +} + +// CoreBackend returns the production backend, the one Options selects when +// Backend is nil. It is exported so that a service wrapping it — to add a metric +// or a log line — has something to embed. +func CoreBackend() Backend { return coreBackend{} } + +// Options configures a Validator. +type Options struct { + // Service is this service's name as meta.sr.ht spells it in a grant, + // e.g. "cov.sr.ht". Required. + // + // It is required for a reason that is invisible until it is not: + // auth.DecodeGrants reads the *calling* service's name off the context, to + // expand a grant written without one, and config.ServiceName PANICS rather + // than returning "" when nothing put it there. In production nothing puts it + // there except core-go's config.Middleware, so a validator that relied on the + // ambient context would work behind an HTTP router and take the process down + // anywhere else — a background job, a CLI, a test. Naming the service here + // makes this package answerable to its own caller instead. + Service string + + // Backend performs the meta.sr.ht lookups. Nil means CoreBackend(). + Backend Backend + + // CacheTTL is how long one resolution is reused. Zero means + // DefaultCacheTTL; negative is refused. + CacheTTL time.Duration + + // Now is the clock the cache ages entries against. Nil means time.Now. + // + // It does not move the expiry check of step 1: auth.DecodeBearerToken reads + // the real clock itself and this package cannot reach inside it. A test that + // wants an expired token has to mint one that is genuinely in the past. + Now func() time.Time +} + +// Validator resolves meta.sr.ht personal access tokens for one service. It is +// safe for concurrent use, which it has to be: a service holds exactly one and +// every request handler goes through it. +type Validator struct { + service string + backend Backend + ttl time.Duration + now func() time.Time + + mu sync.Mutex + cache map[[64]byte]entry +} + +// entry is one cached resolution: the caller it produced, and when that stops +// being reusable. +// +// Only successes are cached. A failure to reach meta is not an answer, and +// caching it would let one blip pin every token checked during it to failure for +// the whole TTL — turning a moment of unavailability into a minute of it, while +// meta is already healthy again. A genuine refusal is not cached either: it +// costs one local HMAC to reproduce, and the alternative is a data structure +// that an attacker can grow by presenting garbage. +type entry struct { + ac *auth.AuthContext + until time.Time +} + +// New builds a Validator, refusing options that would only fail later. +func New(opts Options) (*Validator, error) { + if opts.Service == "" { + return nil, errors.New( + "metapat: Service is required, e.g. cov.sr.ht: decoding a grant string needs it") + } + if opts.CacheTTL < 0 { + return nil, fmt.Errorf("metapat: CacheTTL %s is negative; zero means %s", + opts.CacheTTL, DefaultCacheTTL) + } + + v := &Validator{ + service: opts.Service, + backend: opts.Backend, + ttl: opts.CacheTTL, + now: opts.Now, + cache: make(map[[64]byte]entry), + } + if v.backend == nil { + v.backend = CoreBackend() + } + if v.ttl == 0 { + v.ttl = DefaultCacheTTL + } + if v.now == nil { + v.now = time.Now + } + return v, nil +} + +// Resolve runs the four steps against one presented personal access token. +// +// presented is the bare credential, with any "Bearer " scheme already stripped. +// +// On success it returns an *auth.AuthContext with AuthMethod, BearerToken, +// TokenHash and Grants filled in — the same shape core-go's own OAuth2 +// middleware produces, so that everything downstream which already understands +// an OAuth2 caller keeps working, Allows included. +// +// On failure it returns one of this package's sentinels, wrapped with detail: +// test with errors.Is and map to a status with the table on those sentinels. The +// returned context is nil for every failure, including ErrNotOurs — a service +// that meant to accept a working token must route with PlaneOf and call its +// working-token validator, which is the only thing that can check one. +// +// # What is not checked here +// +// The token's own username is taken as the identity. There is no second name in +// a bearer header to compare it against — that check belongs to the Basic-auth +// flows, where a token is presented as somebody's password and the point is to +// stop it being presented as somebody else's. +// +// The OAuth scope is not checked either. See Allows. +func (v *Validator) Resolve(ctx context.Context, presented string) (*auth.AuthContext, error) { + if presented == "" { + return nil, fmt.Errorf("%w: no token presented", ErrInvalid) + } + + hash := sha512.Sum512([]byte(presented)) + if ac, ok := v.cached(hash); ok { + return ac, nil + } + + // Step 1, local: signature and expiry. A forged or expired credential costs + // one HMAC and never becomes a request to meta.sr.ht. + bt := auth.DecodeBearerToken(presented) + if bt == nil { + return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalid) + } + + // Step 2. Refused rather than attempted: a working token's grant string is + // in tokens.sr.ht's vocabulary, which auth.DecodeGrants would reject as + // malformed, and its revocation row lives at a different daemon entirely. + if bt.ClientID == bearer.TokensClientID { + return nil, fmt.Errorf("%w: ClientID is %q", ErrNotOurs, bt.ClientID) + } + + // Step 3. The token names a meta.sr.ht account; turning that into a local + // row is core-go's job, through the same call every other plane makes. + var ac auth.AuthContext + if err := v.backend.LookupUser(ctx, bt.Username, &ac); err != nil { + // Transient. The credential is good, and telling an agent to re-mint + // over a lookup outage is the wrong instruction twice: it does not help, + // and it destroys a working credential. + return nil, fmt.Errorf("%w: looking up user %q: %w", ErrUnavailable, bt.Username, err) + } + if ac.UserID == 0 { + // LookupUser answered without filling in an id. Nothing downstream can + // use that: every ownership row keys on the user id, and a zero would + // match whichever row has an unset owner. Permanent rather than + // transient — retrying will not conjure the account back. + return nil, fmt.Errorf("%w: token names %q, for whom no meta id was mirrored", + ErrInvalid, bt.Username) + } + + // Step 4. + revoked, err := v.backend.IsRevoked(ctx, bt.Username, hash, bt.ClientID) + if err != nil { + return nil, fmt.Errorf("%w: checking revocation for %q: %w", ErrUnavailable, bt.Username, err) + } + if revoked { + return nil, fmt.Errorf("%w: token of %q", ErrRevoked, bt.Username) + } + + grants, err := auth.DecodeGrants(v.grantContext(ctx), bt.Grants) + if err != nil { + return nil, fmt.Errorf("%w: decoding token grants: %w", ErrInvalid, err) + } + + ac.AuthMethod = auth.AUTH_OAUTH2 + ac.BearerToken = bt + ac.TokenHash = hash + ac.Grants = grants + + v.remember(hash, &ac) + return copyOf(&ac), nil +} + +// grantContext derives the context auth.DecodeGrants insists on: one naming the +// calling service, which it uses to expand a grant written without a service +// prefix, and which config.ServiceName panics for the absence of. +// +// The config half is deliberately empty. DecodeGrants reads only the name, the +// derived context never leaves this call, and carrying a real ini.File through +// Options just to satisfy a field nothing reads would make every caller supply +// one. If core-go ever starts reading the config here, this is where it stops +// being enough — which is why it is one named function and not an inline +// expression. +// +// Overwriting rather than inspecting is forced: both context keys are +// unexported, so there is no way to ask whether a name is already present that +// does not go through the function that panics. Overwriting is also correct — +// what would already be there is this same service's name, put there by +// config.Middleware on the request path. +func (v *Validator) grantContext(ctx context.Context) context.Context { + return config.Context(ctx, ini.File{}, v.service) +} + +// Forget drops any cached resolution of this token, so that the next Resolve +// asks meta.sr.ht again. +// +// It exists for the service that learns out of band — from a webhook, from its +// own revocation UI — that a credential has changed, and would otherwise keep +// honouring it for up to CacheTTL. Forgetting a token that was never cached is a +// no-op rather than an error. +func (v *Validator) Forget(presented string) { + hash := sha512.Sum512([]byte(presented)) + v.mu.Lock() + defer v.mu.Unlock() + delete(v.cache, hash) +} + +// cached returns a live cached resolution, if there is one. +func (v *Validator) cached(hash [64]byte) (*auth.AuthContext, bool) { + v.mu.Lock() + defer v.mu.Unlock() + + e, ok := v.cache[hash] + if !ok { + return nil, false + } + if !v.now().Before(e.until) { + delete(v.cache, hash) + return nil, false + } + return copyOf(e.ac), true +} + +// remember caches one successful resolution. +// +// At maxCacheEntries the cache is dropped whole rather than evicted by age. The +// bound is not a tuning knob and reaching it is not the steady state: a service +// sees a handful of distinct credentials, and four thousand of them means either +// an instance far larger than this one or a caller minting a token per request. +// Dropping everything costs one round of re-resolution and cannot degrade into +// the thing an LRU can — a cache that spends more time evicting than answering, +// under exactly the load that filled it. +func (v *Validator) remember(hash [64]byte, ac *auth.AuthContext) { + v.mu.Lock() + defer v.mu.Unlock() + + if len(v.cache) >= maxCacheEntries { + v.cache = make(map[[64]byte]entry, maxCacheEntries) + } + v.cache[hash] = entry{ac: copyOf(ac), until: v.now().Add(v.ttl)} +} + +// copyOf returns a shallow copy, so that a caller which annotates the context it +// was handed — core-go's own middleware sets IPAddress on one — does not write +// through into the cache and hand the next caller somebody else's address. +// +// Shallow is enough and deep would be wrong. The pointer fields are the mirrored +// profile and the decoded token, which are read-only facts about the account and +// the credential; auth.Grants holds a map, and its only methods read it. +func copyOf(ac *auth.AuthContext) *auth.AuthContext { + if ac == nil { + return nil + } + c := *ac + return &c +} + +// Allows reports whether a resolved caller's OAuth grants permit acting on scope +// at mode — the gate that complements whatever the service's own access matrix +// decides. A caller must pass both. +// +// scope is the full grant name as meta.sr.ht spells it, service included: +// "cov.sr.ht/REPORTS". The service part is not optional in practice even though +// core-go will fill it in from the ambient config when it is missing, because +// what it fills in is the *calling* service's name read off a context — which is +// right in a service talking about itself and silently wrong everywhere else, +// including in a test. Spell it out. +// +// mode is auth.RO or auth.RW; core-go panics on anything else. +// +// A caller carrying no OAuth grants at all passes unconditionally, and that is +// not a hole in either of the two ways it happens: +// +// - A cookie session, an anonymous request, or a tokens.sr.ht working token +// resolved by the other plane has no BearerToken. It was never scoped by +// meta's vocabulary and cannot be judged in it; a working token is scoped by +// its own grants, asked for separately. +// - A personal access token minted with no grants selected is universal by +// core-go's definition (auth.Grants.HasAll), exactly as it is for every +// upstream service on the instance. +func Allows(ac *auth.AuthContext, scope, mode string) bool { + if ac == nil || ac.BearerToken == nil { + return true + } + return ac.Grants.Has(scope, mode) +} + +// Scope assembles the grant name of one scope on one service — Scope("cov.sr.ht", +// "REPORTS") is "cov.sr.ht/REPORTS". +// +// It exists so that the two spellings a service must keep in agreement are built +// from the same halves: the scope it publishes in api-meta.json, which meta.sr.ht +// turns into a checkbox by prefixing the service name itself, and the grant name +// it checks here. A service should assert them equal in a test rather than hope. +func Scope(service, scope string) string { + return service + "/" + scope +} + +// ScopeName returns the bare scope of a full grant name — the half a service +// publishes in api-meta.json. ScopeName("cov.sr.ht/REPORTS") is "REPORTS". +// +// A name with no service prefix is returned unchanged, which is what makes this +// safe to apply to a value that may already be bare. +func ScopeName(scope string) string { + if _, after, ok := strings.Cut(scope, "/"); ok { + return after + } + return scope +} diff --git a/metapat/metapat_test.go b/metapat/metapat_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4e9791ea2d00c5a9a32ebd47a08eca440e8526bb --- /dev/null +++ b/metapat/metapat_test.go @@ -0,0 +1,630 @@ +package metapat + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "strings" + "sync" + "testing" + "testing/fstest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + "sourcecraft.dev/bigbes/sr-ht-core/config" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" + + "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" +) + +// TestMain initialises the process-global core-go crypto state this package +// depends on and never sets up itself. +// +// [webhooks]private-key is what auth.BearerToken.Encode signs with and what +// step 1 verifies against — it is derived into the HMAC key, not used directly. +// The value is the one core-go's own tests use. +// +// The std logger is silenced because auth.DecodeBearerToken narrates every +// refusal through it, and the refusals are half of what this file tests: without +// this, one `go test` prints a page of "Invalid bearer token" for tokens that +// were invalid on purpose. +func TestMain(m *testing.M) { + config.FS = fstest.MapFS{ + "config.ini": &fstest.MapFile{Data: []byte(` +[webhooks] +private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc= + +[sr.ht] +network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk= +`)}, + } + crypto.InitCrypto(config.LoadConfig()) + log.SetOutput(io.Discard) + m.Run() +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ( + // metaClientID is what meta.sr.ht stamps into a personal access token: the + // UUID of the OAuth client it was minted for. Its only load-bearing property + // here is that it is not bearer.TokensClientID. + metaClientID = "b2a5e8b0-0c8f-4e4a-9a34-1f9f6b3a0c11" + + covService = "cov.sr.ht" + covReports = covService + "/REPORTS" +) + +// seal mints a token the way the instance's daemons do, so that what these tests +// present is byte-for-byte the shape a real one has. +func seal(username, clientID, grantString string, expires time.Time) string { + bt := &auth.BearerToken{ + Version: auth.TokenVersion, + Expires: auth.ToTimestamp(expires), + Grants: grantString, + ClientID: clientID, + Username: username, + } + return bt.Encode() +} + +// pat is a live personal access token from meta.sr.ht. +func pat(grantString string) string { + return seal("bigbes", metaClientID, grantString, time.Now().Add(time.Hour)) +} + +// workingToken is a live token from tokens.sr.ht — the other plane. +func workingToken() string { + return seal("bigbes", bearer.TokensClientID, "cov:read", time.Now().Add(time.Hour)) +} + +// backend is a stand-in for meta.sr.ht. It records every call so a test can +// assert not only what came back but whether anything was asked at all — which, +// for the cache, is the whole question. +type backend struct { + mu sync.Mutex + + // userID is filled into every looked-up profile. Zero is the "meta answered + // without an id" case, which is a refusal and not a lookup failure. + userID int + // lookupErr and revokedErr make the two network steps fail. + lookupErr error + revokedErr error + // revoked is what IsRevoked answers when it does not fail. + revoked bool + + lookups int + revChecks int + names []string + clientIDs []string +} + +func (b *backend) LookupUser(_ context.Context, username string, out *auth.AuthContext) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.lookups++ + b.names = append(b.names, username) + if b.lookupErr != nil { + return b.lookupErr + } + out.UserID = b.userID + out.Username = username + out.Email = username + "@example.org" + return nil +} + +func (b *backend) IsRevoked(_ context.Context, _ string, _ [64]byte, clientID string) (bool, error) { + b.mu.Lock() + defer b.mu.Unlock() + + b.revChecks++ + b.clientIDs = append(b.clientIDs, clientID) + if b.revokedErr != nil { + return false, b.revokedErr + } + return b.revoked, nil +} + +func (b *backend) counts() (lookups, revChecks int) { + b.mu.Lock() + defer b.mu.Unlock() + return b.lookups, b.revChecks +} + +// newValidator builds a validator over a healthy backend that resolves everyone +// to user 42, which is what most of these tests want. +func newValidator(t *testing.T, opts ...func(*Options)) (*Validator, *backend) { + t.Helper() + + b := &backend{userID: 42} + o := Options{Service: covService, Backend: b} + for _, fn := range opts { + fn(&o) + } + v, err := New(o) + require.NoError(t, err) + return v, b +} + +// --------------------------------------------------------------------------- +// PlaneOf +// --------------------------------------------------------------------------- + +func TestPlaneOfSeparatesTheTwoPlanes(t *testing.T) { + assert.Equal(t, PlaneMeta, PlaneOf(pat(""))) + assert.Equal(t, PlaneWorking, PlaneOf(workingToken())) +} + +func TestPlaneOfRefusesWhatItCannotRead(t *testing.T) { + // A credential this process cannot decode is PlaneUnknown whoever sealed it: + // the routing question has no answer, and the service owes it a 401 rather + // than a trip to either daemon. + assert.Equal(t, PlaneUnknown, PlaneOf("")) + assert.Equal(t, PlaneUnknown, PlaneOf("not a token at all")) + assert.Equal(t, PlaneUnknown, PlaneOf("!!!not even base64!!!")) +} + +func TestPlaneOfReadsAnExpiredTokenAsUnknown(t *testing.T) { + // auth.DecodeBearerToken checks expiry before it reports anything, so an + // expired PAT never reaches PlaneMeta. The service must answer it like any + // other unreadable credential — the point of documenting this on PlaneOf is + // that "unknown" is tempting to read as "no credential presented". + expired := seal("bigbes", metaClientID, "", time.Now().Add(-time.Hour)) + assert.Equal(t, PlaneUnknown, PlaneOf(expired)) +} + +func TestPlaneNamesItself(t *testing.T) { + assert.Equal(t, "meta.sr.ht personal access token", PlaneMeta.String()) + assert.Equal(t, "tokens.sr.ht working token", PlaneWorking.String()) + assert.Equal(t, "unrecognised credential", PlaneUnknown.String()) + assert.Equal(t, "unrecognised credential", Plane(99).String()) +} + +// --------------------------------------------------------------------------- +// New +// --------------------------------------------------------------------------- + +func TestNewFillsInTheProductionDefaults(t *testing.T) { + v, err := New(Options{Service: covService}) + require.NoError(t, err) + + assert.NotNil(t, v.backend, "a nil Backend must become the core one") + assert.Equal(t, DefaultCacheTTL, v.ttl) + assert.NotNil(t, v.now) + assert.NotNil(t, v.cache) +} + +func TestNewRefusesANegativeTTL(t *testing.T) { + // Refused at construction rather than later: a negative TTL expires every + // entry the instant it is written, which is not a cache misbehaving but a + // service quietly asking meta.sr.ht once per request forever. + _, err := New(Options{Service: covService, CacheTTL: -time.Second}) + require.Error(t, err) + assert.Contains(t, err.Error(), "negative") +} + +func TestNewRefusesAnUnnamedService(t *testing.T) { + // Without it, decoding a grant string reads the service name off the ambient + // context and PANICS when nothing put one there. Refusing here turns a crash + // in whichever caller runs outside an HTTP router into a wiring error at + // startup. + _, err := New(Options{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Service is required") +} + +func TestResolveNeedsNoAmbientConfigContext(t *testing.T) { + // The whole reason Options.Service exists. context.Background() carries no + // service name, and core-go's config.ServiceName panics rather than + // answering "" — so a shared package that leaned on the request context + // would take down every caller that is not an HTTP handler. + v, _ := newValidator(t) + + require.NotPanics(t, func() { + ac, err := v.Resolve(context.Background(), pat(covReports)) + require.NoError(t, err) + assert.True(t, Allows(ac, covReports, auth.RO)) + }) +} + +// --------------------------------------------------------------------------- +// Resolve — the happy path +// --------------------------------------------------------------------------- + +func TestResolveProducesAnOAuth2Caller(t *testing.T) { + v, b := newValidator(t) + token := pat(covReports + ":RO") + + ac, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + + // The shape core-go's own OAuth2 middleware produces, because everything + // downstream — Allows included — is written against that shape. + assert.Equal(t, auth.AUTH_OAUTH2, ac.AuthMethod) + assert.Equal(t, 42, ac.UserID) + assert.Equal(t, "bigbes", ac.Username) + require.NotNil(t, ac.BearerToken) + assert.Equal(t, metaClientID, ac.BearerToken.ClientID) + assert.NotEqual(t, [64]byte{}, ac.TokenHash) + assert.True(t, Allows(ac, covReports, auth.RO)) + + // The revocation row is scoped by the token's own client id, not by ours. + assert.Equal(t, []string{metaClientID}, b.clientIDs) + assert.Equal(t, []string{"bigbes"}, b.names) +} + +func TestResolveAcceptsAnUngrantedTokenAsUniversal(t *testing.T) { + // meta.sr.ht mints a personal token with no grants selected, and core-go + // reads that as every permission. Refusing it here would refuse the most + // common credential on the instance. + v, _ := newValidator(t) + + ac, err := v.Resolve(context.Background(), pat("")) + require.NoError(t, err) + assert.True(t, ac.Grants.HasAll()) + assert.True(t, Allows(ac, covReports, auth.RW)) +} + +// --------------------------------------------------------------------------- +// Resolve — the refusals +// --------------------------------------------------------------------------- + +func TestResolveRefusesAnEmptyCredential(t *testing.T) { + v, b := newValidator(t) + + _, err := v.Resolve(context.Background(), "") + require.ErrorIs(t, err, ErrInvalid) + + lookups, revChecks := b.counts() + assert.Zero(t, lookups) + assert.Zero(t, revChecks) +} + +func TestResolveRefusesAForgedCredentialWithoutAskingMeta(t *testing.T) { + // The ordering is the property: everything that can refuse locally runs + // before anything that touches the network, so a flood of junk tokens does + // not become a flood of requests against meta.sr.ht. + v, b := newValidator(t) + + _, err := v.Resolve(context.Background(), "this is not a bearer token") + require.ErrorIs(t, err, ErrInvalid) + + lookups, revChecks := b.counts() + assert.Zero(t, lookups) + assert.Zero(t, revChecks) +} + +func TestResolveRefusesAnExpiredToken(t *testing.T) { + v, b := newValidator(t) + expired := seal("bigbes", metaClientID, "", time.Now().Add(-time.Hour)) + + _, err := v.Resolve(context.Background(), expired) + require.ErrorIs(t, err, ErrInvalid) + + lookups, _ := b.counts() + assert.Zero(t, lookups) +} + +func TestResolveSendsAWorkingTokenBackToTheOtherPlane(t *testing.T) { + // Not a refusal on the instance's behalf: the token is perfectly good, it + // simply cannot be checked here — its grants are in another vocabulary and + // its revocation row is at another daemon. + v, b := newValidator(t) + + ac, err := v.Resolve(context.Background(), workingToken()) + require.ErrorIs(t, err, ErrNotOurs) + assert.Nil(t, ac, "a caller must not be handed a context it cannot use") + + lookups, _ := b.counts() + assert.Zero(t, lookups, "the other plane's token must not cost a meta lookup") +} + +func TestResolveTreatsALookupFailureAsTransient(t *testing.T) { + // The 503 that has to be defended: "I could not check" is not "your + // credential is bad", and answering 401 here tells every client on the + // instance to re-mint credentials that were never broken. + v, b := newValidator(t) + b.lookupErr = errors.New("dial tcp: connection refused") + + _, err := v.Resolve(context.Background(), pat("")) + require.ErrorIs(t, err, ErrUnavailable) + assert.NotErrorIs(t, err, ErrInvalid) +} + +func TestResolveRefusesAProfileWithNoID(t *testing.T) { + // meta answered, and answered uselessly. Permanent rather than transient: + // retrying will not conjure the account back, and a zero id downstream + // matches whichever row has an unset owner. + v, b := newValidator(t) + b.userID = 0 + + _, err := v.Resolve(context.Background(), pat("")) + require.ErrorIs(t, err, ErrInvalid) + assert.NotErrorIs(t, err, ErrUnavailable) + + _, revChecks := b.counts() + assert.Zero(t, revChecks, "a caller with no id is refused before the revocation check") +} + +func TestResolveTreatsARevocationOutageAsTransient(t *testing.T) { + v, b := newValidator(t) + b.revokedErr = errors.New("meta.sr.ht: 502") + + _, err := v.Resolve(context.Background(), pat("")) + require.ErrorIs(t, err, ErrUnavailable) +} + +func TestResolveRefusesARevokedToken(t *testing.T) { + v, b := newValidator(t) + b.revoked = true + + _, err := v.Resolve(context.Background(), pat("")) + require.ErrorIs(t, err, ErrRevoked) + // 401 and not 403: the token is no longer a credential at all, and a client + // shown 403 keeps presenting it. + assert.NotErrorIs(t, err, ErrForbidden) +} + +func TestResolveRefusesAMalformedGrantString(t *testing.T) { + // core-go's grant grammar is "/[:]". A token whose + // grant string does not parse is not one this instance can reason about. + v, _ := newValidator(t) + + _, err := v.Resolve(context.Background(), pat("garbage-without-a-slash")) + require.ErrorIs(t, err, ErrInvalid) +} + +func TestResolveDoesNotCacheARefusal(t *testing.T) { + // A refusal costs one local HMAC to reproduce; caching it would hand an + // attacker a data structure to grow by presenting garbage, and would pin a + // token to failure across a meta outage that has since ended. + v, b := newValidator(t) + b.lookupErr = errors.New("down") + token := pat("") + + _, err := v.Resolve(context.Background(), token) + require.ErrorIs(t, err, ErrUnavailable) + + b.mu.Lock() + b.lookupErr = nil + b.mu.Unlock() + + ac, err := v.Resolve(context.Background(), token) + require.NoError(t, err, "recovery must not wait out a cached failure") + assert.Equal(t, 42, ac.UserID) +} + +// --------------------------------------------------------------------------- +// The cache +// --------------------------------------------------------------------------- + +func TestResolveAsksMetaOncePerTokenPerTTL(t *testing.T) { + // The reason the cache exists: one federated query fans out across a service's + // resolvers, and each of them would otherwise be a pair of lookups. + v, b := newValidator(t) + token := pat(covReports) + + for range 5 { + _, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + } + + lookups, revChecks := b.counts() + assert.Equal(t, 1, lookups) + assert.Equal(t, 1, revChecks) +} + +func TestTheCacheExpires(t *testing.T) { + now := time.Now() + clock := func() time.Time { return now } + v, b := newValidator(t, func(o *Options) { + o.CacheTTL = time.Minute + o.Now = clock + }) + token := pat("") + + _, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + + now = now.Add(time.Minute) // exactly at the boundary: no longer reusable + _, err = v.Resolve(context.Background(), token) + require.NoError(t, err) + + lookups, _ := b.counts() + assert.Equal(t, 2, lookups, "an expired entry must be re-resolved") +} + +func TestDistinctTokensDoNotShareAnEntry(t *testing.T) { + v, b := newValidator(t) + + _, err := v.Resolve(context.Background(), pat(covReports)) + require.NoError(t, err) + _, err = v.Resolve(context.Background(), pat("bench.sr.ht/RESULTS")) + require.NoError(t, err) + + lookups, _ := b.counts() + assert.Equal(t, 2, lookups) +} + +func TestForgetDropsACachedResolution(t *testing.T) { + // For the service that learns out of band that a credential has changed and + // would otherwise keep honouring it for the rest of the TTL. + v, b := newValidator(t) + token := pat("") + + _, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + v.Forget(token) + _, err = v.Resolve(context.Background(), token) + require.NoError(t, err) + + lookups, _ := b.counts() + assert.Equal(t, 2, lookups) +} + +func TestForgettingAnUncachedTokenIsHarmless(t *testing.T) { + v, _ := newValidator(t) + assert.NotPanics(t, func() { v.Forget("never seen") }) +} + +func TestACallerCannotWriteThroughIntoTheCache(t *testing.T) { + // core-go's own middleware annotates the context it is handed — IPAddress is + // per-request — so handing out the cached pointer would give the next caller + // somebody else's address. + v, _ := newValidator(t) + token := pat("") + + first, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + first.IPAddress = "203.0.113.7" + + second, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + assert.Empty(t, second.IPAddress) + assert.NotSame(t, first, second) +} + +func TestCopyOfPassesNilThrough(t *testing.T) { + // cached() and remember() both call it, and a nil there would be a bug + // elsewhere; passing it through rather than dereferencing keeps that bug + // reported where it happens instead of here. + assert.Nil(t, copyOf(nil)) +} + +func TestTheCacheIsBounded(t *testing.T) { + // Not a tuning knob: reaching the bound means a caller minting a token per + // request, and the answer is to drop everything rather than to spend the + // request budget evicting. + v, _ := newValidator(t) + + for i := range maxCacheEntries + 1 { + token := seal(fmt.Sprintf("user%d", i), metaClientID, "", time.Now().Add(time.Hour)) + _, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + } + + v.mu.Lock() + defer v.mu.Unlock() + assert.LessOrEqual(t, len(v.cache), maxCacheEntries) + assert.NotEmpty(t, v.cache, "the entry that hit the bound is still cached") +} + +func TestResolveIsSafeUnderConcurrency(t *testing.T) { + v, _ := newValidator(t) + token := pat(covReports) + + var wg sync.WaitGroup + for range 32 { + wg.Add(1) + go func() { + defer wg.Done() + ac, err := v.Resolve(context.Background(), token) + assert.NoError(t, err) + assert.NotNil(t, ac) + }() + } + wg.Wait() +} + +// --------------------------------------------------------------------------- +// Allows +// --------------------------------------------------------------------------- + +func TestAllowsHonoursTheScope(t *testing.T) { + v, _ := newValidator(t) + + ac, err := v.Resolve(context.Background(), pat(covReports+":RO")) + require.NoError(t, err) + + assert.True(t, Allows(ac, covReports, auth.RO)) + assert.False(t, Allows(ac, covReports, auth.RW), "a read grant is not a write grant") + assert.False(t, Allows(ac, "bench.sr.ht/RESULTS", auth.RO), "another service's scope is not this one") +} + +func TestAllowsPassesACallerWithNoOAuthGrants(t *testing.T) { + // A cookie session, an anonymous request, or a working token resolved by the + // other plane. None of them was ever scoped in meta's vocabulary, so there + // is nothing here to judge; what they may see is the service's own matrix. + assert.True(t, Allows(nil, covReports, auth.RO)) + assert.True(t, Allows(&auth.AuthContext{}, covReports, auth.RO)) + assert.True(t, Allows(&auth.AuthContext{AuthMethod: auth.AUTH_COOKIE}, covReports, auth.RW)) +} + +func TestAllowsAcceptsAWriteGrantForARead(t *testing.T) { + v, _ := newValidator(t) + + ac, err := v.Resolve(context.Background(), pat(covReports+":RW")) + require.NoError(t, err) + assert.True(t, Allows(ac, covReports, auth.RO)) + assert.True(t, Allows(ac, covReports, auth.RW)) +} + +// --------------------------------------------------------------------------- +// Scope spelling +// --------------------------------------------------------------------------- + +func TestScopeAndScopeNameAreInverses(t *testing.T) { + // The two spellings a service has to keep in agreement: what it publishes in + // api-meta.json, which meta.sr.ht prefixes with the service name itself, and + // the full grant name it checks against. + full := Scope("cov.sr.ht", "REPORTS") + assert.Equal(t, covReports, full) + assert.Equal(t, "REPORTS", ScopeName(full)) +} + +func TestScopeNameLeavesABareScopeAlone(t *testing.T) { + // Which is what makes it safe to apply to a value that may already be bare. + assert.Equal(t, "REPORTS", ScopeName("REPORTS")) +} + +func TestScopeNameTakesTheFirstSlashAsTheSeparator(t *testing.T) { + assert.Equal(t, "a/b", ScopeName("svc.sr.ht/a/b")) +} + +// TestTheDocumentedUsageCompiles pins the routing shape the package comment +// prescribes, so that a change to PlaneOf or Resolve which breaks it fails here +// rather than in four services. +func TestTheDocumentedUsageCompiles(t *testing.T) { + v, _ := newValidator(t) + + resolve := func(presented string) (string, error) { + switch PlaneOf(presented) { + case PlaneMeta: + ac, err := v.Resolve(context.Background(), presented) + if err != nil { + return "", err + } + if !Allows(ac, covReports, auth.RO) { + return "", fmt.Errorf("%w: %s", ErrForbidden, covReports) + } + return ac.Username, nil + case PlaneWorking: + return "working", nil + default: + return "", ErrInvalid + } + } + + who, err := resolve(pat(covReports)) + require.NoError(t, err) + assert.Equal(t, "bigbes", who) + + who, err = resolve(workingToken()) + require.NoError(t, err) + assert.Equal(t, "working", who) + + _, err = resolve(pat("meta.sr.ht/PROFILE:RO")) + require.ErrorIs(t, err, ErrForbidden) + assert.True(t, strings.Contains(err.Error(), covReports)) + + _, err = resolve("nonsense") + require.ErrorIs(t, err, ErrInvalid) +}