package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/db"
)
// MaxTokenNameLen bounds a token's label. It is a human-readable note about
// which agent holds the credential, rendered in a table and in `token list`;
// the cap keeps a pasted paragraph from becoming a row nobody can read.
const MaxTokenNameLen = 128
// AgentTokenLookup is the one db/ method the token adapter needs. It is an
// interface rather than a *db.Store so the error mapping below — the part that
// actually carries a contract — can be tested against a fake instead of a
// Postgres instance. *db.Store satisfies it.
type AgentTokenLookup interface {
AgentTokenByHash(ctx context.Context, hash []byte) (*db.AgentToken, error)
}
// TokenStore adapts db/'s agent_token queries to authn.TokenStore. It exists
// because authn must not import db: authn declares the sliver of persistence it
// needs, and service/ — the layer that is allowed to know about both — wires
// them together.
//
// The whole of the adaptation is the error contract, and it is not cosmetic.
// authn's contract is that "no such token" is an error satisfying
// errors.Is(err, authn.ErrUnknownToken) and that everything else is transient.
// db/ spells the same condition ErrNotFound, which authn has never heard of, so
// an unmapped pass-through would make an unknown token look like a Postgres
// outage: a 503 telling an agent to retry a credential that will never work.
type TokenStore struct {
lookup AgentTokenLookup
}
// NewTokenStore wires a db.Store in as authn's TokenStore.
func NewTokenStore(lookup AgentTokenLookup) *TokenStore {
return &TokenStore{lookup: lookup}
}
// TokenStore returns the adapter the resolver authenticates agents through.
func (s *Service) TokenStore() *TokenStore { return s.tokens }
// LookupAgentToken implements authn.TokenStore.
//
// A revoked row is returned rather than refused: authn is what turns it into a
// refusal, so the refusal can say "revoked" instead of "unknown" and an
// operator can tell a token they deliberately killed from one that never
// existed. Any other failure is returned wrapped and unclassified, which authn
// reads as transient — the fail-closed direction, since a store outage must
// never read as a valid credential.
func (t *TokenStore) LookupAgentToken(ctx context.Context, hash []byte) (authn.AgentToken, error) {
if t.lookup == nil {
return authn.AgentToken{}, errors.New("service: TokenStore has no backing store")
}
row, err := t.lookup.AgentTokenByHash(ctx, hash)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return authn.AgentToken{}, fmt.Errorf("%w: no agent_token row matches the presented token",
authn.ErrUnknownToken)
}
return authn.AgentToken{}, fmt.Errorf("service: look up agent token: %w", err)
}
if row == nil {
// db/ never returns (nil, nil); a store that did would otherwise
// authenticate a nil row as a valid token.
return authn.AgentToken{}, errors.New("service: agent token lookup returned no row and no error")
}
return authn.AgentToken{
ID: int64(row.ID),
Name: row.Name,
Hash: row.Hash,
Created: row.Created,
Revoked: row.Revoked,
}, nil
}
// AgentToken is one credential as the surfaces above this layer need it: no
// hash, because nothing above service/ has any use for it, and no plaintext,
// because it exists only in the response to the call that minted it.
type AgentToken struct {
ID int
Name string
Created time.Time
Revoked *time.Time
}
// Active reports whether the token may still authenticate.
func (t AgentToken) Active() bool { return t.Revoked == nil }
// IssueAgentToken mints a credential for the agent write plane and returns the
// plaintext exactly once, alongside the stored row.
//
// Owner-only, and that is the interesting half of the ACL: an agent holding a
// valid token may not mint another. Were it allowed to, revoking a compromised
// credential would not end the compromise — the holder would simply have issued
// itself a second one — and "revoke the token" is the entire incident response
// this design has.
//
// The plaintext is returned rather than stored. There is no second chance to
// read it, which is what makes a leaked database dump unreplayable, so a caller
// that drops the value has to mint a new token.
func (s *Service) IssueAgentToken(ctx context.Context, p authn.Principal, name string) (string, AgentToken, error) {
if !p.IsOwner() {
return "", AgentToken{}, fmt.Errorf("%w: %s may not issue agent tokens; only the instance owner may",
ErrForbidden, p)
}
name, err := validateTokenName(name)
if err != nil {
return "", AgentToken{}, err
}
token, err := db.GenerateToken()
if err != nil {
return "", AgentToken{}, fmt.Errorf("service: %w", err)
}
row, err := s.store.CreateAgentToken(ctx, name, db.HashToken(token))
if err != nil {
return "", AgentToken{}, fmt.Errorf("service: issue agent token %q: %w", name, err)
}
return token, tokenView(row), nil
}
// ListAgentTokens returns every token, newest first, so the owner can see what
// exists and pick one to revoke. Owner-only for the same reason minting is: the
// list is the inventory of who can write, and an agent has no business reading
// it.
func (s *Service) ListAgentTokens(ctx context.Context, p authn.Principal) ([]AgentToken, error) {
if !p.IsOwner() {
return nil, fmt.Errorf("%w: %s may not list agent tokens; only the instance owner may", ErrForbidden, p)
}
rows, err := s.store.ListAgentTokens(ctx)
if err != nil {
return nil, fmt.Errorf("service: list agent tokens: %w", err)
}
out := make([]AgentToken, 0, len(rows))
for _, row := range rows {
out = append(out, tokenView(row))
}
return out, nil
}
// RevokeAgentToken stamps a token revoked. Owner-only. Revoking is a stamp
// rather than a delete so the audit trail keeps naming the token that made past
// proposals; re-revoking is a no-op, because an operator killing a credential
// twice is not an error worth failing.
func (s *Service) RevokeAgentToken(ctx context.Context, p authn.Principal, id int) error {
if !p.IsOwner() {
return fmt.Errorf("%w: %s may not revoke agent tokens; only the instance owner may", ErrForbidden, p)
}
if id <= 0 {
return fmt.Errorf("%w: %d is not an agent token id", ErrInvalid, id)
}
if err := s.store.RevokeAgentToken(ctx, id); err != nil {
if errors.Is(err, db.ErrNotFound) {
return fmt.Errorf("%w: agent token %d", ErrNotFound, id)
}
return fmt.Errorf("service: revoke agent token %d: %w", id, err)
}
return nil
}
// validateTokenName normalizes and checks a token label. The rules are the
// weakest ones that keep the listing readable and unambiguous: trimmed,
// non-empty, valid UTF-8, no control characters, and bounded. A name grants
// nothing, so nothing stricter would be buying anything.
func validateTokenName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("%w: a token needs a name saying which agent holds it", ErrInvalid)
}
if len(name) > MaxTokenNameLen {
return "", fmt.Errorf("%w: token name is %d bytes, over the %d-byte limit",
ErrInvalid, len(name), MaxTokenNameLen)
}
if !utf8.ValidString(name) {
return "", fmt.Errorf("%w: token name is not valid UTF-8", ErrInvalid)
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return "", fmt.Errorf("%w: token name contains a control character %U", ErrInvalid, r)
}
}
return name, nil
}
// tokenView maps a stored token onto the surface shape, dropping the hash.
func tokenView(row *db.AgentToken) AgentToken {
return AgentToken{ID: row.ID, Name: row.Name, Created: row.Created, Revoked: row.Revoked}
}