Help us shape the future of Go! Take the 2026 Go Developer Survey

accesstoken

package
v1.0.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package accesstoken is the security primitive behind Reliant Labs' ONE machine credential, `rlat_`: its wire format, generation, hashing, constant-time comparison, the closed scope vocabulary, the rule that a token may never grant authority it does not hold, and the Principal a presented token resolves to.

── WHAT IS HERE, AND WHAT IS NOT ─────────────────────────────────────

This package follows forge/pkg/apikey's split exactly: it holds the reusable, get-it-wrong-and-you're-breached part, and nothing else. It has no database, no SQL and no store. The TABLE and the STORE that reads and writes it are application code, owned by whichever deployment issues tokens:

  • control-plane owns controlplane.access_tokens for the hosted product;
  • a self-hosted reliant with no control-plane owns its own table.

Both import THIS package, so the parts that decide whether a credential is safe — entropy, hashing, comparison, scope parsing, the grant rule and the resource binding — have one implementation however many deployments there are.

── WHY NOT pkg/apikey ────────────────────────────────────────────────

pkg/apikey is the generic `fk_` key a scaffolded forge app owns: a bearer with no authority model beyond "this key exists". An access token carries a closed scope set, an org, an optional acting user and an optional resource binding, and the grant rule is written against all of that. Folding that into apikey would make every forge app's key speak this vocabulary.

── THE PREFIX ────────────────────────────────────────────────────────

Tokens are `rlat_<43 base62 chars>`. ONE prefix for every machine credential Reliant issues; SCOPES say what a given credential may do. The families this replaced (`rlnt_pat_`, `rlnt_conn_`, `rlnt_` LLM keys, `dpat_`) shared or nearly shared a namespace, so a credential presented to the wrong subsystem matched that subsystem's prefix test and failed at a hash lookup instead of at the boundary. One prefix removes that whole class of bug.

Index

Constants

View Source
const (
	// Prefix marks a Reliant machine credential.
	Prefix = "rlat_"

	// TokenLen is the exact length of every token this package mints.
	TokenLen = len(Prefix) + randomBytes

	// DisplayPrefixLen is how many characters of the random suffix are kept,
	// alongside Prefix, as a safe-to-show identifier. Eight base62 chars
	// (~47 bits) tell an org's tokens apart without narrowing the secret by
	// anything that matters.
	DisplayPrefixLen = 8
)
View Source
const ReasonScopeMissing = "scope_missing"

ReasonScopeMissing is the error reason (svcerr.ReasonHeader) a server gives when the caller authenticated but its credential, or the person it acts for, lacks a scope the call needs. Signing in again does not fix it; a grant does. Carry the missing scopes in ScopesHeader.

View Source
const ScopesHeader = "x-forge-error-scopes"

ScopesHeader is the Connect error-metadata key that names the scopes a ReasonScopeMissing refusal is about, comma-separated with no spaces ("deploy:write,secret:read"; ScopesHeaderValue). Structured so a client can say which grant to ask for without parsing the message. A server attaches it with svcerr.WithMetadata; forge's CORS defaults expose it.

Variables

AllResourceKinds is the closed set; a store's CHECK declares 'forge:closed-set accesstoken.AllResourceKinds' and `forge lint` pins it.

AllScopes is the closed set, grouped by product, least authority first.

View Source
var ErrEscalation = errors.New("accesstoken: a token cannot grant scopes it does not hold")

ErrEscalation reports a mint that would grant authority the grantor does not hold. Distinct from ErrInvalidGrant because it is an authority problem (PermissionDenied), not a malformed request (InvalidArgument).

View Source
var ErrInvalidGrant = errors.New("accesstoken: invalid grant")

ErrInvalidGrant reports a malformed mint request.

Functions

func EqualHash

func EqualHash(a, b string) bool

EqualHash reports whether two token hashes are equal, in constant time.

The stored digest is unsalted and the hash function is public, so a `==` that returns at the first differing byte would let an attacker extract the digest byte by byte and then search for a preimage offline. Constant time removes that oracle. Unequal LENGTHS return early, which is harmless: a SHA-256 hex digest's length is a public constant.

func HasFormat

func HasFormat(token string) bool

HasFormat reports whether a bearer token has the exact shape Mint produces.

A CHEAP PRE-FILTER, never an authorization decision: it lets a validator chain skip a lookup for the overwhelming majority of bearers (JWTs). It tests the exact length and the alphabet, not just the prefix, so a truncated, padded or foreign token is refused on shape.

func Hash

func Hash(plaintext string) string

Hash returns the SHA-256 hex digest stored for a plaintext token.

Unsalted and deterministic on purpose: the digest IS the lookup key, so authentication is one indexed read. Salting defends a small input space against precomputation; 32 random bytes have no such space. The prefix is part of the hashed input.

func JoinScopes

func JoinScopes(scopes []Scope) string

JoinScopes renders a scope slice for an error message.

func ParseScopesHeader added in v1.0.0

func ParseScopesHeader(value string) []string

ParseScopesHeader reads a ScopesHeader value back into its scopes, in order. Blank entries are dropped; an empty value is nil.

func PortResourceID

func PortResourceID(daemonID string, port int32) string

PortResourceID renders the id of a port binding. One spelling, here, so a minting site and a checking site cannot disagree about the separator.

func RequiresActingUser

func RequiresActingUser(scope Scope) bool

RequiresActingUser reports whether a scope acts as a person.

func ScopesHeaderValue added in v1.0.0

func ScopesHeaderValue[S ~string](scopes []S) string

ScopesHeaderValue renders scopes for ScopesHeader.

Types

type Grant

type Grant struct {
	// OrgID is the one organization the token acts on, for its whole life.
	OrgID string
	// Name is the operator's label. Descriptive only.
	Name string
	// Scopes is the authority granted.
	Scopes Set
	// ActingUserID, when set, is the person the token acts as. Required by
	// any scope in actingUserScopes; absent for an org automation token.
	ActingUserID string
	// Resource, when set, confines the token to one resource.
	Resource *Resource
	// Ephemeral marks a token tied to a session's lifetime (a desktop
	// daemon's run), revoked when that session ends. It must be bound and
	// must expire: an ephemeral token that outlives everything is a
	// contradiction.
	Ephemeral bool
	// ExpiresAt is optional for non-ephemeral tokens. Checked at every
	// authentication; never defaulted.
	ExpiresAt *time.Time

	// GrantorScopes is the authority of the CALLER when the caller is itself
	// a token. NIL MEANS A HUMAN CALLER (or trusted internal service) whose
	// authority was checked by the handler before this; it is absence, not a
	// synthetic "every scope" value that could leak into another check.
	GrantorScopes Set
}

Grant is everything a mint decides. Every store validates it with Validate before inserting, so the rules below have one implementation.

func (Grant) Admissible added in v1.0.0

func (g Grant) Admissible() Set

Admissible returns every scope in AllScopes that a grant shaped like g — its acting user and its binding; its scopes, org, name and expiry are ignored — may carry. It is what "every scope" MEANS for a credential of that shape.

── THE WILDCARD IS RESOLVED, NEVER STORED AS A LIST ──────────────────

A credential that holds every scope (a daemon's: Principal.AllScopes) must not be written down as the catalog of the day it was minted, or every scope added later is missing from it until somebody re-mints or migrates. A store keeps the wildcard as a flag and resolves it with this at each use, so a scope this binary learns is held from the next request. The resolution is still bounded by Validate's structural rules — a daemon binding never resolves to a port share (proxy:port) — and the caller still clips it to what the acting user currently holds.

func (Grant) Validate

func (g Grant) Validate(now time.Time) error

Validate enforces every structural rule of a grant at time `now`.

type Minted

type Minted struct {
	// Plaintext is the secret. Never log it; never put it on a list response.
	Plaintext string
	// Hash is the SHA-256 hex digest — the only persisted form of the secret.
	Hash string
	// DisplayPrefix ("rlat_A3f9Kd2p") is safe to store, log and display.
	DisplayPrefix string
}

Minted is a freshly generated token: the plaintext to return exactly once, and the two values persisted in its place. A struct rather than positional results because the fields have opposite handling rules — Plaintext is returned and never stored or logged; Hash is stored and never returned.

func Mint

func Mint() (Minted, error)

Mint generates a new token.

The suffix indexes base62Chars with `b % 62`, which is very slightly non-uniform (256 is not a multiple of 62). The bias costs well under one bit across the whole suffix, against ~250 bits of entropy.

type Principal

type Principal struct {
	TokenID string
	OrgID   string
	// ActingUserID is empty for an org automation token. A consumer that
	// needs a person (a reliant API call, an LLM request) refuses an empty
	// one; it never substitutes the creator.
	ActingUserID string
	Scopes       Set
	// AllScopes marks a credential that holds EVERY scope — a daemon's —
	// rather than a list. Scopes is then its resolution against this binary's
	// catalog (Grant.Admissible), clipped by the authority to what the acting
	// user holds; a validator reading a remote authority's answer builds it
	// with KnownScopes, so a scope only a newer authority knows is skipped
	// rather than failing the credential.
	AllScopes bool
	// Resource is the binding, or nil for an unbound token.
	Resource  *Resource
	Ephemeral bool
	ExpiresAt *time.Time
}

Principal is what an authenticated token resolves to. Every validator — control-plane's in-process one, reliant's introspecting one, a self-host store — yields this shape.

func (*Principal) BoundTo

func (p *Principal) BoundTo(kind ResourceKind, id string) bool

BoundTo reports whether the token is bound to exactly this resource.

func (*Principal) MayActOn

func (p *Principal) MayActOn(kind ResourceKind, id string) bool

MayActOn reports whether the token may act on this resource: an unbound token may (its authority is its scopes and acting user), a bound one only on its own resource. Required-binding scopes never reach the unbound case, because Validate refuses to mint them unbound.

func (*Principal) MayReadEnvironment added in v1.0.0

func (p *Principal) MayReadEnvironment(envID string) bool

MayReadEnvironment reports whether the token may act on environment envID of its org: an environment-bound token on its own environment only; any other token (unbound, or bound to the acting user's daemon) on every environment of the org. Org membership and scopes are the caller's checks.

type Resource

type Resource struct {
	Kind ResourceKind
	ID   string
}

Resource is a binding: a kind and an id.

type ResourceKind

type ResourceKind string

ResourceKind names what a token may be BOUND to. The set is closed.

A binding confines a token to one resource: a daemon credential that works for that daemon only, a share link that opens one port, a connector credential confined to one grant. It bounds a leaked token's blast radius to the one thing it was issued for, which is why it is carried over on merit from reliant's per-daemon PATs.

const (
	// ResourceDaemon binds to one daemon id.
	ResourceDaemon ResourceKind = "daemon"
	// ResourcePort binds to one proxied port of one daemon. The id is
	// PortResourceID(daemonID, port).
	ResourcePort ResourceKind = "port"
	// ResourceConnector binds to one MCP connector grant id.
	ResourceConnector ResourceKind = "connector"
	// ResourceEnvironment binds to one environment id: a CI token that may
	// read one env's telemetry (obs:read), triage its issues (obs:triage) or
	// query its database (db:query), and no other env of the org.
	ResourceEnvironment ResourceKind = "environment"
)

type Scope

type Scope string

Scope names one authority a token may carry, spelled <product>:<action>.

SCOPES ARE THE ONLY THING THAT DISTINGUISHES ONE CREDENTIAL FROM ANOTHER. A CI deploy token, a daemon's connection credential, an LLM gateway key and a connector credential differ in their scope set (and binding), not in their table, prefix, hashing or authentication path.

THE SET IS CLOSED. A scope that cannot be spelled cannot be granted, which is a stronger guarantee than a handler remembering to refuse it. A store backs this with a database CHECK over the same list, declared `COMMENT ON CONSTRAINT … IS 'forge:closed-set accesstoken.AllScopes'`, and `forge lint` fails when the CHECK and AllScopes differ, so neither can gain a member alone.

const (
	// ScopeDeployRead reads the org's deploy state.
	ScopeDeployRead Scope = "deploy:read"
	// ScopeDeployWrite mutates it (releases, environments, publish,
	// promote). Implies deploy:read at the point of use.
	ScopeDeployWrite Scope = "deploy:write"

	// ScopeBillingRead reads the org's billing-backed cost and usage. It grants
	// no billing management rights and is not implied by deploy or telemetry
	// authority. Consumers must check the tenant and current grants at use.
	ScopeBillingRead Scope = "billing:read"

	// ScopeClusterManage registers, rotates and removes the org's BYO
	// clusters — the credentials the platform uses to reach infrastructure
	// the org owns. Its own product, not a deploy action: deploy:write moves
	// a release onto clusters the org already trusts, and must not be able
	// to change WHICH clusters those are. An org-admin authority.
	ScopeClusterManage Scope = "cluster:manage"

	// ScopeTokenRead lists the org's machine credentials — an inventory of
	// its automation, so a separate authority from deploy:read.
	ScopeTokenRead Scope = "token:read"
	// ScopeTokenWrite mints and revokes credentials, bounded by Covers: a
	// holder can never grant a scope it does not hold. Without this scope a
	// token cannot mint at all, which is the general form of "a PAT cannot
	// mint a PAT".
	ScopeTokenWrite Scope = "token:write"

	// ScopeReliantAPI acts as the token's acting user against the reliant
	// API — the CLI / automation credential. Requires an acting user.
	ScopeReliantAPI Scope = "reliant:api"

	// ScopeDaemonConnect authenticates a daemon's stream to the gateway.
	// When bound to a daemon resource it authenticates as that daemon only.
	ScopeDaemonConnect Scope = "daemon:connect"

	// ScopeLLMInvoke calls the LLM gateway, billed to the acting user.
	ScopeLLMInvoke Scope = "llm:invoke"

	// ScopeProxyPort reaches one proxied daemon port. Always bound to a
	// port resource: an unbound port credential would open every port.
	ScopeProxyPort Scope = "proxy:port"

	// ScopeMCPConnector is a third-party MCP client's credential, confined to
	// one connector grant. Always bound to a connector resource.
	ScopeMCPConnector Scope = "mcp:connector"

	// ScopeDaemonResume resolves and wakes ONE suspended daemon on its acting
	// user's behalf, so a run nobody is watching (a scheduled trigger) can
	// reach the machine it was told to run on. Always bound to that daemon
	// resource and always acting as a user: an unbound resume credential
	// could wake every machine the person owns, and the point of a trigger
	// naming its daemon is that it never needs to.
	//
	// Wake only. It cannot connect AS the daemon (daemon:connect), run
	// anything on it, or read anything from it; the worst a leaked one does
	// is start a machine its owner already pays for.
	ScopeDaemonResume Scope = "daemon:resume"

	// ScopeDaemonManage lists, starts, stops and resizes the daemons its
	// acting user owns, as the machine settings page does: a person's CLI
	// login managing their own machines. Always acting as a user, and never
	// bound to anything — in particular never to a daemon, so no daemon's
	// own credential (a daemon wildcard resolves through Grant.Admissible)
	// and nothing an agent running on a daemon holds can carry it. Starting
	// and resizing spend the person's money, which is why a machine must not
	// be able to do it for itself or its siblings.
	//
	// It implies nothing and nothing implies it: not daemon:resume (one
	// bound machine, wake only), not daemon:connect, not reliant:api. The
	// consumer still resolves ownership, plan and funding from the acting
	// user exactly as for the person's own session.
	ScopeDaemonManage Scope = "daemon:manage"

	// ScopeSecretRead lists the org's managed secrets' METADATA (names,
	// versions) for every environment, and — for LOCAL environments ONLY —
	// pulls their VALUES (LocalSecretService/PullSecrets), because a LOCAL
	// env's workloads run on the developer's machine (`forge env up`) and
	// need them there. A PERSISTENT environment's values are never readable
	// through a token: the platform materializes them into its workloads and
	// the pull path is structurally confined to local envs.
	ScopeSecretRead Scope = "secret:read"
	// ScopeSecretWrite sets and deletes managed secret values. Implies
	// secret:read at the point of use.
	ScopeSecretWrite Scope = "secret:write"

	// ScopeDomainRead lists the org's custom domains and their bindings.
	ScopeDomainRead Scope = "domain:read"
	// ScopeDomainWrite registers domains, requests certificates and binds
	// them to environments. Implies domain:read at the point of use.
	//
	// ITS OWN PRODUCT, NOT A DEPLOY ACTION. A domain is a PUBLIC IDENTITY
	// and a certificate is issued in the org's name; binding one repoints
	// live traffic. deploy:write moves a release onto hostnames the org
	// already owns, and must not be able to change which names those are —
	// the same separation cluster:manage has from deploy:write.
	ScopeDomainWrite Scope = "domain:write"

	// ScopeIdentityRead reads an environment's managed identity: its status
	// and the end users of its instance, with their roles.
	ScopeIdentityRead Scope = "identity:read"
	// ScopeIdentityManage administers those end users: invite, change roles,
	// lock, delete, end sessions, issue password-set codes, and export the
	// instance. Implies identity:read at the point of use.
	//
	// ITS OWN PRODUCT, NOT A DEPLOY ACTION. It acts on the PEOPLE who sign in
	// to the org's apps, not on the org's infrastructure: a code it issues
	// sets an end user's password. deploy:write ships a release and must not
	// be able to take over the accounts of the people who use it.
	ScopeIdentityManage Scope = "identity:manage"

	// ScopeObsRead reads an environment's telemetry and what is derived from
	// it: traces, logs, metrics, issues and release health. Org automation
	// tokens carry it so CI and agents can gate a deploy on `forge obs check`.
	ScopeObsRead Scope = "obs:read"
	// ScopeObsTriage changes an issue's triage state (resolve, ignore,
	// unresolve). Implies obs:read at the point of use.
	//
	// ITS OWN SCOPE, NOT A READ. A read-only automation token must not be
	// able to silence an alert, so triage is held explicitly even by a
	// holder that reads.
	ScopeObsTriage Scope = "obs:triage"
	// ScopeObsWrite authors the org's observability workspace: dashboards
	// (save, restore, archive, promote) and anything else a person edits in
	// the telemetry console. Implies obs:read at the point of use.
	//
	// ITS OWN SCOPE, NOT TRIAGE. obs:triage changes the state of an issue; this
	// changes shared, versioned artifacts the whole org reads. Neither implies
	// the other, so a token that silences alerts cannot rewrite dashboards and
	// the reverse. Never bound to an environment: what it writes is org-wide.
	ScopeObsWrite Scope = "obs:write"

	// ScopeDBQuery runs a read-only query against an environment's database.
	// Not implied by any other scope and implying none: the data in an
	// app's database is the app's users' data, a different class of authority
	// from the telemetry obs:read reads. A real environment's policy may
	// further restrict it to people (audited); the scope alone never opens
	// one.
	ScopeDBQuery Scope = "db:query"
)

func ParseScope

func ParseScope(s string) (Scope, bool)

ParseScope maps a wire string onto a known Scope. An unknown string is an error, never a silently dropped entry: dropping "deploy:wrIte" would mint a token that authenticates and then refuses every write.

type Set

type Set map[Scope]struct{}

Set is the authority a token carries. Build it with NewSet, the single place an unknown scope is rejected.

func KnownScopes added in v1.0.0

func KnownScopes(raw []string) Set

KnownScopes builds a Set from the strings this binary recognises and SKIPS the rest. It is for a WILDCARD credential's resolved scopes only (see Principal.AllScopes): a newer authority lists scopes this binary has never heard of, which the credential holds by definition and nothing here can exercise, so dropping them narrows nothing. Every explicit scope list goes through NewSet, which refuses an unknown scope rather than authenticating with the understood subset.

func NewSet

func NewSet(scopes []string) (Set, error)

NewSet builds a Set, refusing unknown and empty input.

Refusing unknown scopes is the fail-closed direction on the READ path: a row written by a newer server may hold a scope this binary cannot evaluate, and proceeding with the understood subset would make a token's authority depend on which server version answered. An empty set is refused because a token with no authority that authenticates reads as an outage.

func SetOf

func SetOf(scopes ...Scope) Set

SetOf builds a Set from known Scope constants. For call sites that name scopes in code, where an unknown value is a compile-time impossibility.

func (Set) Covers

func (s Set) Covers(requested Set) bool

Covers reports whether this set may GRANT every scope in `requested`.

THE RULE: a token may never mint a token carrying scopes it does not itself hold. Authority can be narrowed on delegation and never widened.

It uses EXACT MEMBERSHIP, not Permits. Permits' write-implies-read is a statement about what an action needs; routing grants through it would make every future usability implication a minting power.

An empty request is not covered — a subset test that returns true for the empty set is how a caller concludes a token may grant anything.

func (Set) Has

func (s Set) Has(scope Scope) bool

Has reports exact membership.

func (Set) Missing

func (s Set) Missing(requested Set) []Scope

Missing returns the scopes in `requested` this set does not hold, in AllScopes order, so a refusal can name what was over-reached.

func (Set) Permits

func (s Set) Permits(want Scope) bool

Permits reports whether this set authorizes an action requiring `want`.

WRITE IMPLIES READ, WITHIN ONE PRODUCT, AT THE POINT OF USE ONLY. The stored grant is not widened; a caller holding deploy:write may perform deploy reads because every write path reads first. No implication crosses products — deploy:write never implies token:read.

func (Set) String

func (s Set) String() string

String renders the set for diagnostics. Scopes are not secret.

func (Set) Strings

func (s Set) Strings() []string

Strings returns the scopes as wire strings in AllScopes order — stable, so two reads of one unchanged token serialize identically.

type UnknownScopeError

type UnknownScopeError struct {
	Scope  string
	Reason string
}

UnknownScopeError names the scope that could not be evaluated.

func (*UnknownScopeError) Error

func (e *UnknownScopeError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL