Files
felhom.eu/hub/internal/store/api_tokens.go
T
admin d62cc08b2e
gates / gates (push) Successful in 6m44s
hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once,
  stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it.
- Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only:
  secrets shown/minted, deletes/resets, password, tokens.
- R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test.
- Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open.
- 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS.
- website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push.
Not deployed — waits for the operator's yes.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 12:20:35 +02:00

150 lines
5.0 KiB
Go

package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"strings"
"time"
)
// Operator access tokens (hub v0.152.0, `05` §17). A token is a scoped, expiring stand-in for the operator password,
// for scripts (Claude Code's floor step first). The plain token is shown ONCE at creation and never stored: the row
// keeps only its unkeyed SHA-256. A slow hash is not needed — the token is 32 random bytes, so there is nothing to
// guess and nothing a dictionary can find; the hash only stops a leaked database copy from being replayed as a token.
// Pinned by TestAPIToken_StoredRowHasNoPlainToken.
// APITokenPrefix starts every operator access token. The web surface accepts it; the box API refuses it.
const APITokenPrefix = "fhp_"
// APIToken is one access-token row. The plain token is never a field here.
type APIToken struct {
ID int64
Name string
Scopes []string // "<group>:read" / "<group>:write" — validated by the web package's scope list
CreatedAt time.Time
CreatedBy string
ExpiresAt time.Time
LastUsedAt *time.Time
LastUsedFrom string
RevokedAt *time.Time
}
// Expired reports whether the token's expiry has passed at now.
func (t *APIToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) }
// HashAPIToken is the stored lookup form of a plain token.
func HashAPIToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
func (s *Store) migrateAPITokens() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL,
created_at DATETIME NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
expires_at DATETIME NOT NULL,
last_used_at DATETIME,
last_used_from TEXT NOT NULL DEFAULT '',
revoked_at DATETIME
);`)
return err
}
// CreateAPIToken stores a new token by its hash. The caller generated plain and shows it once.
func (s *Store) CreateAPIToken(name, plain string, scopes []string, createdBy string, expiresAt time.Time) (int64, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, fmt.Errorf("token name is empty")
}
if len(scopes) == 0 {
return 0, fmt.Errorf("token has no scope")
}
res, err := s.db.Exec(`INSERT INTO api_tokens (name, token_hash, scopes, created_at, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
name, HashAPIToken(plain), strings.Join(scopes, ","), time.Now().UTC().Format(time.RFC3339),
createdBy, expiresAt.UTC().Format(time.RFC3339))
if err != nil {
return 0, err
}
return res.LastInsertId()
}
const apiTokenCols = `id, name, scopes, created_at, created_by, expires_at, last_used_at, last_used_from, revoked_at`
func scanAPIToken(sc interface{ Scan(...any) error }) (*APIToken, error) {
var t APIToken
var scopes, created, expires string
var lastUsed, revoked sql.NullString
if err := sc.Scan(&t.ID, &t.Name, &scopes, &created, &t.CreatedBy, &expires, &lastUsed, &t.LastUsedFrom, &revoked); err != nil {
return nil, err
}
if scopes != "" {
t.Scopes = strings.Split(scopes, ",")
}
t.CreatedAt = parseSQLiteTime(created)
t.ExpiresAt = parseSQLiteTime(expires)
if lastUsed.Valid && lastUsed.String != "" {
v := parseSQLiteTime(lastUsed.String)
t.LastUsedAt = &v
}
if revoked.Valid && revoked.String != "" {
v := parseSQLiteTime(revoked.String)
t.RevokedAt = &v
}
return &t, nil
}
// GetAPITokenByPlain finds a token by its plain value (hashed here). nil, nil = no such token.
func (s *Store) GetAPITokenByPlain(plain string) (*APIToken, error) {
t, err := scanAPIToken(s.db.QueryRow(`SELECT `+apiTokenCols+` FROM api_tokens WHERE token_hash = ?`, HashAPIToken(plain)))
if err == sql.ErrNoRows {
return nil, nil
}
return t, err
}
// ListAPITokens returns every token, newest first (revoked ones included — the list is the audit trail).
func (s *Store) ListAPITokens() ([]*APIToken, error) {
rows, err := s.db.Query(`SELECT ` + apiTokenCols + ` FROM api_tokens ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*APIToken
for rows.Next() {
t, err := scanAPIToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeAPIToken marks a token revoked. Revoking twice is a no-op; an unknown id is an error.
func (s *Store) RevokeAPIToken(id int64) error {
res, err := s.db.Exec(`UPDATE api_tokens SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("no token with id %d", id)
}
return nil
}
// TouchAPIToken records a use. Best effort: a failure here never refuses the request.
func (s *Store) TouchAPIToken(id int64, from string) error {
_, err := s.db.Exec(`UPDATE api_tokens SET last_used_at = ?, last_used_from = ? WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), from, id)
return err
}