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} }