Documentation
¶
Overview ¶
Package apitoken implements scoped, expiring, individually revocable API tokens for non-interactive access to the cloop hub (Task 20175).
Why this exists ¶
The hub previously had exactly two ways in. An OIDC browser session, which CI cannot hold; and one static bearer token (`--token`) that bypassed RBAC entirely and saw every project on the hub. There was no credential you could give a build job, a deploy script, or an edge device that was narrower than "everything", expired on its own, or could be taken away without rotating the secret every other caller also depended on.
A personal access token (PAT) is that credential. It carries its own roles, optionally names the projects it may address, optionally expires, and can be revoked one at a time.
The token string ¶
cloop_pat_<id>_<secret>
`id` is the public half: 64 bits of randomness, stored in the clear, used as the primary key on the verification path so authenticating a request costs one indexed read rather than a scan-and-compare across every token the hub has ever issued. `secret` is 256 bits of randomness and is the only part that authenticates anything.
The `cloop_pat_` prefix is deliberate and load-bearing in two directions: it lets the auth chain tell "this is a PAT" from "this is the static token" without a database read, and it is the anchor a secret scanner needs to recognise a leaked cloop credential in a repository or a CI log.
What is stored ¶
Only `<alg>$<salt-hex>$<digest-hex>` over the secret half, plus a display prefix. The plaintext is returned by Mint exactly once and is never written anywhere — not to the database, not to the audit trail, not to a log line. A stolen database file yields no usable credential.
Why HMAC-SHA256 and not argon2id ¶
Password KDFs exist to make guessing expensive when the input is guessable. The secret here is 256 bits from crypto/rand: there is no dictionary, no reuse across sites, and no human choosing it. Stretching adds nothing an attacker must overcome, while putting a deliberately-slow function on the authentication path of every API request — which is a self-inflicted denial of service, since an unauthenticated caller controls how often it runs.
So the construction is HMAC-SHA256 keyed by a per-token salt: a proper PRF, constant-time to verify, and with a per-row key so one precomputed table cannot attack the whole column. The stored form is algorithm-tagged, so if a future token type ever does take low-entropy input, argon2id can be added alongside without a migration or a flag day.
What this package does not decide ¶
It does not know about HTTP, OIDC, or the route table. Verify answers "is this string a live token, and if so which one" — the caller turns that into an authz.Decision (see Token.Decision) and the route gates enforce it unchanged. Deny-by-default therefore applies to PATs for free: a token is not a bypass, it is an identity with a permission set.
Index ¶
- Constants
- Variables
- func CheckDelegation(d Delegator, roles []string, projectScope []string) error
- func DeriveHash(secret string) (string, error)
- func LooksLikeToken(raw string) bool
- func NormalizeProjectScope(in []string) ([]string, error)
- func NormalizeRoles(in []string) ([]string, error)
- func Parse(raw string) (id, secret string, ok bool)
- func VerifyHash(encoded, secret string) bool
- type Delegator
- type Manager
- func (m *Manager) Approve(id, by string) (bool, error)
- func (m *Manager) Get(id string) (Token, error)
- func (m *Manager) List() ([]Token, error)
- func (m *Manager) Mint(opts MintOptions) (Minted, error)
- func (m *Manager) Recheck(id string) (*Token, error)
- func (m *Manager) Revoke(id string) error
- func (m *Manager) SetClock(now func() time.Time)
- func (m *Manager) SetRevokeHook(fn func(id string))
- func (m *Manager) SetTouchErrorHandler(fn func(error))
- func (m *Manager) Verify(raw string) (*Token, error)
- type MintOptions
- type Minted
- type Owner
- type SQLStore
- func (s *SQLStore) Approve(id, by string, at time.Time) (bool, error)
- func (s *SQLStore) Get(id string) (Token, error)
- func (s *SQLStore) List() ([]Token, error)
- func (s *SQLStore) Put(t Token) error
- func (s *SQLStore) Revoke(id string, at time.Time) error
- func (s *SQLStore) Touch(id string, at time.Time) error
- type Store
- type Token
- func (t *Token) Active(now time.Time) bool
- func (t *Token) AllowsProject(name, path string) bool
- func (t *Token) AwaitsApproval() bool
- func (t *Token) Decision(scope authz.Scope, now time.Time) authz.Decision
- func (t *Token) Expired(now time.Time) bool
- func (t *Token) Label() string
- func (t *Token) OwnerBinding() *Owner
- func (t *Token) ParsedRoles() []authz.Role
- func (t *Token) Revoked() bool
Constants ¶
const ( // MaxNameLen bounds the operator-supplied label. Long enough to describe // a purpose ("github-actions deploy — payments"), short enough that the // column cannot be used as storage. MaxNameLen = 200 // MaxProjectScope bounds how many projects one token may name. A token // that needs more than this wants an unscoped token and an honest // conversation about it. MaxProjectScope = 64 )
const ( // MintedUnderPolicy: minted through the hub while its role policy was in // force, so the minter's roles — which bound the token's — were the // policy's. MintedUnderPolicy = "policy" // MintedUnderNoPolicy: minted through the hub while no role policy was in // force, when every signed-in identity holds every role. MintedUnderNoPolicy = "no_policy" // MintedByOperator: minted at the control plane by `cloop hub token // create`, by whoever holds its database — who holds the hub already. MintedByOperator = "operator" )
What governed a mint (Token.MintedUnder).
const ( // KindGlasses is a per-user display-glasses link (Task 20194). One live // token per identity: minting rotates, so a link handed out yesterday // stops working the moment a new one is generated. KindGlasses = "glasses" )
Kind labels what a token was issued for. The empty kind is an ordinary operator-minted PAT; named kinds mark tokens the hub issues on a user's behalf, which have their own lifecycle rules.
const Prefix = "cloop_pat_"
Prefix is the literal every minted token starts with. Exported so the auth chain can classify a credential without a database read, and so secret scanners have one constant to match.
Variables ¶
var ( // ErrMalformed means the string is not shaped like a token at all. ErrMalformed = errors.New("apitoken: malformed token") // ErrNotFound means no token has that id. ErrNotFound = errors.New("apitoken: token not found") // ErrBadSecret means the id resolved but the secret did not match. ErrBadSecret = errors.New("apitoken: token secret does not match") // ErrExpired means the token passed its ExpiresAt. ErrExpired = errors.New("apitoken: token has expired") // ErrRevoked means the token was withdrawn by an operator. ErrRevoked = errors.New("apitoken: token has been revoked") // ErrNoRoles means the token carries no role this binary recognises. // Treated as a hard failure rather than an empty permission set so a // token written by a newer binary cannot silently degrade into a // credential that authenticates but authorizes nothing — which reads to // an operator as "the token is broken", not "the token is unsafe". ErrNoRoles = errors.New("apitoken: token carries no usable role") )
Errors returned by Verify. Callers on the authentication path must not distinguish these to the client: every one of them is answered with the same 401, so an unauthenticated caller cannot use the response to learn whether a token id exists, whether it is merely expired, or whether it was revoked. They are distinguished here so the *audit trail* can say which it was.
Functions ¶
func CheckDelegation ¶
CheckDelegation reports whether the delegator may mint a token carrying roles and projectScope.
Both inputs are the *normalized* forms (NormalizeRoles / NormalizeProjectScope); callers run those first so a validation error is reported as a bad request rather than as a permission denial.
The error text names the specific permission or project that was refused. That is safe to return: it describes the caller's own authority, which they can already read from /api/me, and withholding it produces the worst kind of security UX — a 403 with no way to discover what would have worked.
func DeriveHash ¶ added in v0.0.5
DeriveHash derives the stored form of a fresh secret under a new random salt: what Mint stores for a token, for another credential the hub verifies the same way — the SCIM bearer credential (Task 20415). VerifyHash checks it.
func LooksLikeToken ¶
LooksLikeToken reports whether a credential string is claiming to be a PAT.
Used by the auth chain to decide whether to consult the token store at all, so that a deployment with no PATs — or a request carrying the static token — never pays for a database read. It is a prefix test, not validation: Verify still decides.
func NormalizeProjectScope ¶
NormalizeProjectScope trims, de-duplicates, and bounds a project scope list. An empty result means "every project", which is the documented meaning of omitting the field.
func NormalizeRoles ¶
NormalizeRoles validates and de-duplicates a role list, returning the canonical lowercase names to persist.
Unknown names are rejected rather than dropped. This is the mirror image of ParsedRoles, and the asymmetry is intentional: at *mint* time a typo must be a loud failure, because silently issuing a token weaker than the operator asked for produces a credential that fails mysteriously in CI a week later. At *verification* time the same value must be ignored rather than fatal, because by then the token exists and the safe reading of an unrecognised role is "grants nothing".
func Parse ¶
Parse splits a token string into its id and secret halves without touching any storage. ok is false for anything not shaped like a token.
Both halves are required to be lowercase hex of exactly the minted length. That strictness is what lets the auth chain reject obvious garbage — a truncated copy-paste, a URL-encoded blob, an SQL fragment — before it becomes a database lookup.
func VerifyHash ¶
VerifyHash reports whether secret derives to encoded, in constant time with respect to the digest.
The comparison is subtle.ConstantTimeCompare over the raw digest bytes, not a string == on the hex form: an early-exit compare over the encoding leaks, through response timing, how many leading bytes of a guess were right, which turns an offline problem into an online one.
A malformed or unknown-algorithm stored value returns false. That case is database corruption, not an attacker-controlled path, so returning early is not a leak — there is no secret in the comparison that never happened.
Types ¶
type Delegator ¶
type Delegator struct {
// Decision is the minter's own resolved authority, evaluated at the
// global scope. Global rather than per-project because a token is a
// fleet-level object: it is not created "inside" a project, and
// resolving against one would let a caller who is maintainer on a single
// project mint a hub-wide maintainer credential.
Decision authz.Decision
// ProjectScope is non-empty when the minter is itself a project-scoped
// token. A scoped minter may only issue tokens at least as narrow.
ProjectScope []string
// Unrestricted marks a caller whose authority is not expressible as a
// permission set — the OIDC-disabled local operator, and the static
// bearer token. Both already act as the deployment itself, so bounding
// them against their own (allow-all) decision would be theatre. Recorded
// as a distinct flag rather than inferred, so the bypass is visible at
// every call site instead of hiding inside an allow-all Decision.
Unrestricted bool
}
Delegator is the authority a new token is being minted from.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager is the verification entry point: it holds the store, the clock, and the coalescing state for last-used tracking.
Safe for concurrent use. Construct one per hub and share it.
func NewManager ¶
NewManager builds a Manager over store.
func (*Manager) Approve ¶ added in v0.0.6
Approve records by's approval of a live token: one minted with no role policy in force, which a hub enforcing one suspends until it is approved (Token.AwaitsApproval). Whether by may approve it is the caller's to decide. It reports whether this call recorded the approval — false when one was recorded already, which stands — and wraps ErrNotFound for an unknown or revoked id.
func (*Manager) Mint ¶
func (m *Manager) Mint(opts MintOptions) (Minted, error)
Mint creates and persists a token, returning the plaintext once.
func (*Manager) Recheck ¶ added in v0.0.5
Recheck re-reads a token that Verify accepted earlier and reports whether Verify would still accept it: nil, or ErrRevoked, ErrExpired, ErrNotFound, ErrNoRoles, or the storage error that stopped it reading the row.
For a caller that holds a verified token past the request that presented it — a WebSocket or SSE stream, a sandbox terminal — and has to ask again whether it may go on (Task 20398). Such a caller keeps the copy Verify returned, and that copy never learns that the row was revoked.
The secret is not compared: the holder proved possession when it opened, and holds the id, not the plaintext. Nor is a use recorded: a connection staying open is not the token being presented again. A row that cannot be read fails closed, as it does in Verify.
func (*Manager) SetRevokeHook ¶ added in v0.0.5
SetRevokeHook installs fn to be told the id of every token Revoke withdraws, once the revocation is stored. Set it before the manager is shared; fn must not block.
A request presenting a revoked token is refused on its own, because Verify reads the row. A connection that a request opened earlier is not a request: it holds the token Verify returned, and this is how the hub hears that it must ask again (Task 20398).
func (*Manager) SetTouchErrorHandler ¶
SetTouchErrorHandler installs a sink for asynchronous last-used write failures.
func (*Manager) Verify ¶
Verify authenticates a token string.
Order of checks is deliberate. The secret is compared *before* expiry and revocation are consulted, so that a caller holding a wrong secret cannot distinguish "this id exists but is revoked" from "this id does not exist" by timing or by which error came back. Only a caller who already proved possession learns why their token stopped working — which is the person entitled to know.
A successful verification schedules an asynchronous LastUsedAt update and returns immediately; the read path never waits on a write.
type MintOptions ¶
type MintOptions struct {
// Name is the operator-supplied label. Required: an unnamed credential
// is one nobody can decide whether to revoke.
Name string
// Roles are the authz roles the bearer acts with. Required — a token
// with no role can authenticate and do nothing, which is a support
// ticket, not a security posture.
Roles []string
// ProjectScope optionally restricts which projects the token may
// address, by registry name or filesystem path.
ProjectScope []string
// CreatedBy records the acting identity for the audit trail.
CreatedBy string
// ExpiresAt is when the token stops working. Zero means never, which
// callers should discourage but which the model permits: an edge device
// that cannot be re-provisioned needs a credential that outlives the
// operator's calendar.
ExpiresAt time.Time
// Kind labels a hub-issued token; "" for an ordinary PAT.
Kind string
// Owner binds the token to the identity it acts for. See Token.Owner.
Owner *Owner
// MintedUnder records what governed the mint: MintedUnderPolicy,
// MintedUnderNoPolicy or MintedByOperator. See Token.AwaitsApproval.
MintedUnder string
// Now overrides the clock, for tests.
Now time.Time
}
MintOptions describes the token to create.
type Minted ¶
type Minted struct {
Token Token
// Plaintext is the full `cloop_pat_<id>_<secret>` string. It exists only
// in this struct and only until the caller returns it; nothing in cloop
// stores it, and no later API call can retrieve it.
Plaintext string
}
Minted is the one-time result of Mint: the persistable record plus the plaintext, which the caller must hand to the operator and then drop.
func Mint ¶
func Mint(opts MintOptions) (Minted, error)
Mint generates a new token. The plaintext is returned exactly once.
Validation is strict and happens before any randomness is drawn, so a rejected request cannot leave a half-formed record or consume entropy.
type Owner ¶
type Owner struct {
Sub string `json:"sub,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
Roles []string `json:"roles,omitempty"`
// Username and OID are the owner's preferred_username and Entra oid
// claims (Task 20415): what a SCIM directory matching on either finds the
// owner by, so a link outlives no deactivation the directory reports.
// Empty on a token minted before them.
Username string `json:"username,omitempty"`
OID string `json:"oid,omitempty"`
}
Owner is the identity a token was minted on behalf of.
It is the claim bundle as it stood at mint time, not a foreign key: cloop has no user directory to resolve one against, and an IdP subject that has not signed in recently exists nowhere else on the hub. Storing the claims lets the authorization path re-resolve the owner's *current* authority from the *current* policy on every request — so an operator who edits a role mapping changes what every delegated token can do, immediately, without having to find and revoke them.
What it deliberately does not store: anything from the ID token beyond the claims pkg/authz already resolves against. No access token, no refresh token, nothing that would let a stolen database act as the user elsewhere.
type SQLStore ¶
type SQLStore struct {
// contains filtered or unexported fields
}
SQLStore is the statedb-backed Store.
func NewSQLStore ¶
NewSQLStore adapts a control-plane database.
Which database matters: tokens must live in the *hub's* own state, never in a managed project's, for the same reason executor bindings and secret grants do. A tenant that could write to the database holding its own credentials could mint itself an admin token.
type Store ¶
type Store interface {
// Put writes a newly minted token. It must reject a duplicate ID rather
// than overwrite: rewriting a live credential's roles is a privilege
// change nobody asked for.
Put(Token) error
// Get returns the token with id, or an error wrapping ErrNotFound.
Get(id string) (Token, error)
// List returns every token, newest first, including revoked and expired
// ones.
List() ([]Token, error)
// Revoke stamps a token as withdrawn. Revoking twice is a no-op success;
// revoking an unknown id must wrap ErrNotFound.
Revoke(id string, at time.Time) error
// Touch records a token's last use. Called off the verification path;
// failures are advisory.
Touch(id string, at time.Time) error
// Approve records an administrator's approval of a live token, and
// reports whether this call recorded it — false when one was recorded
// already, which stands. An unknown or revoked id must wrap ErrNotFound.
Approve(id, by string, at time.Time) (bool, error)
}
Store persists tokens. Implementations must be safe for concurrent use: every authenticated request reads through one.
type Token ¶
type Token struct {
ID string
Name string
Hash string
Prefix string
Roles []string
ProjectScope []string
CreatedBy string
CreatedAt time.Time
ExpiresAt time.Time
LastUsedAt time.Time
RevokedAt time.Time
// Kind is "" for an ordinary PAT, or one of the Kind* constants.
Kind string
// Owner is set on a token minted on a user's behalf. When present, the
// token's authority is the *intersection* of its own roles and whatever
// the owner may currently do — see authz.Intersect and the ui package's
// grant.decide. Roles alone are a ceiling, never a grant.
Owner *Owner
// MintedUnder says what governed the mint: one of the MintedUnder*
// constants, or "" for a token minted before it was recorded. ApprovedAt
// and ApprovedBy are an administrator's approval of a token minted with
// no role policy in force (Task 20422) — see AwaitsApproval.
MintedUnder string
ApprovedAt time.Time
ApprovedBy string
// OwnerUnreadable marks a row whose stored owner binding did not decode.
// Only List sets it: the verification path refuses such a row outright
// (see SQLStore.Get), but a listing has to keep showing it or an operator
// would have no surface from which to revoke it. A token carrying this
// flag must never be treated as unbound — "we cannot tell whose this is"
// is not the same answer as "it belongs to nobody".
OwnerUnreadable bool
}
Token is one API token as persisted. Hash is the derived secret; the plaintext exists only in the return value of Mint.
func (*Token) AllowsProject ¶
AllowsProject reports whether the token may address a project identified by its registry name and/or filesystem path.
An empty ProjectScope means every project the roles allow. A non-empty scope matches either identifier, case-insensitively, mirroring how authz.Binding resolves a project so an operator writes the same thing in both places.
A scoped token asked about *no* project at all (both arguments empty, i.e. the global scope) is allowed through: that is a fleet-wide action, which rule 1 above deliberately does not govern.
func (*Token) AwaitsApproval ¶ added in v0.0.6
AwaitsApproval reports whether a hub enforcing a role policy suspends the token until an administrator approves it (Task 20422): it has no owner, so its authority is its stamped roles alone; it was not minted under a policy or by an operator — a token minted before that was recorded included, since nobody can say; and nobody approved it since.
A token with an owner never waits: its authority is intersected with what its owner may do now, which the policy decides.
func (*Token) Decision ¶
Decision resolves the token's authority over scope.
Two rules, in order:
If the token names a project scope and the request is about a project outside it, the answer is a full deny — not a reduced role. The caller's 404/403 split then reports the project as nonexistent, which is the correct answer to give a credential that has no business knowing it is there.
Otherwise the token's roles apply as an ordinary permission set.
Note what rule 1 does *not* do: it does not narrow global-scope actions. A project scope limits which projects a token can reach; it is not a second axis of privilege. What a token may do anywhere is decided by its roles, and the mint path is what keeps those from exceeding the minter's own.
func (*Token) Expired ¶
Expired reports whether the token is past its expiry at time now. A zero ExpiresAt means the token does not expire.
func (*Token) Label ¶
Label identifies the token in audit records. It never contains the secret: the prefix is the public half by construction.
func (*Token) OwnerBinding ¶
OwnerBinding returns the identity this token acts for, or nil. Nil-safe on the receiver so callers can chain it off a lookup that may have found no token at all — "no token" and "a token acting as nobody" are the same answer to every question the callers ask.
func (*Token) ParsedRoles ¶
ParsedRoles returns the token's roles as validated authz.Role values, dropping any this binary does not recognise.
Dropping rather than failing is the conservative direction: an unknown role name can only ever have granted permissions this binary cannot enumerate, so ignoring it can shrink the permission set but never grow it. Verify turns a token left with nothing into ErrNoRoles.