Files
2026-08-15 07:13:00 +00:00

182 lines
5.4 KiB
Go

package store
import (
"context"
"database/sql"
"time"
)
// Scope is what an API key is allowed to touch.
type Scope string
const (
// ScopeAdmin may manage every project, key and deployment.
ScopeAdmin Scope = "admin"
// ScopeProject may manage only the deployments of its own project.
ScopeProject Scope = "project"
)
// APIKey is a row of the api_keys table. The secret itself is never stored —
// only sha256 of it — and is shown to the operator exactly once, at creation.
type APIKey struct {
ID string // public half of the token; safe to display and log
SecretHash []byte // sha256(secret), 32 bytes
Scope Scope
ProjectID *int64 // nil for admin keys
Name string
CreatedAt time.Time
ExpiresAt *time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
}
// Usable reports whether the key may authenticate a request at time t.
func (k *APIKey) Usable(t time.Time) bool {
if k.RevokedAt != nil {
return false
}
if k.ExpiresAt != nil && !t.Before(*k.ExpiresAt) {
return false
}
return true
}
const keyColumns = `id, secret_hash, scope, project_id, name, created_at, expires_at, last_used_at, revoked_at`
func scanKey(row rowScanner) (*APIKey, error) {
var k APIKey
var projectID, expires, lastUsed, revoked sql.NullInt64
var created int64
if err := row.Scan(&k.ID, &k.SecretHash, &k.Scope, &projectID, &k.Name,
&created, &expires, &lastUsed, &revoked); err != nil {
return nil, mapErr(err)
}
k.ProjectID = intPtr(projectID)
k.CreatedAt = time.Unix(created, 0).UTC()
k.ExpiresAt = timePtr(expires)
k.LastUsedAt = timePtr(lastUsed)
k.RevokedAt = timePtr(revoked)
return &k, nil
}
// CreateKey stores a freshly minted key.
func (d *DB) CreateKey(ctx context.Context, k *APIKey) error {
if k.CreatedAt.IsZero() {
k.CreatedAt = time.Unix(unixNow(), 0).UTC()
}
return d.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO api_keys (id, secret_hash, scope, project_id, name, created_at, expires_at)
VALUES (?,?,?,?,?,?,?)`,
k.ID, k.SecretHash, string(k.Scope), nullInt(k.ProjectID), k.Name,
k.CreatedAt.Unix(), nullTime(k.ExpiresAt))
return mapErr(err)
})
}
// KeyByID is the authentication lookup: a primary-key hit on a WITHOUT ROWID
// table, never a scan.
func (d *DB) KeyByID(ctx context.Context, id string) (*APIKey, error) {
return scanKey(d.r.QueryRowContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE id = ?`, id))
}
// ListKeys returns the keys for one project, or every key when projectID is nil.
// Revoked keys are included so an operator can see what was revoked and when.
func (d *DB) ListKeys(ctx context.Context, projectID *int64) ([]*APIKey, error) {
query := `SELECT ` + keyColumns + ` FROM api_keys`
var args []any
if projectID != nil {
query += ` WHERE project_id = ?`
args = append(args, *projectID)
}
query += ` ORDER BY created_at DESC, id`
rows, err := d.r.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*APIKey
for rows.Next() {
k, err := scanKey(rows)
if err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// RevokeKey marks a key unusable. It is idempotent: revoking twice keeps the
// first timestamp, because that is when the key actually stopped working.
//
// The caller must invalidate the auth cache afterwards, or the key stays live
// for up to the cache TTL.
func (d *DB) RevokeKey(ctx context.Context, id string) error {
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`,
unixNow(), id)
if err != nil {
return mapErr(err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
// Either it does not exist or it was already revoked; distinguish, so
// the API can answer 404 versus 204.
var exists int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM api_keys WHERE id = ?`, id).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return ErrNotFound
}
}
return nil
})
}
// CountUsableAdminKeys counts admin keys that could authenticate right now. A
// zero result on startup is what triggers minting the bootstrap key.
func (d *DB) CountUsableAdminKeys(ctx context.Context) (int, error) {
var n int
err := d.r.QueryRowContext(ctx, `
SELECT count(*) FROM api_keys
WHERE scope = 'admin' AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
unixNow()).Scan(&n)
return n, err
}
// TouchKeys records last-use times in one transaction.
//
// This is deliberately a batch: updating last_used_at on every request would
// funnel every authenticated read through the single write connection, which is
// exactly the contention the two-pool design exists to avoid. The auth layer
// accumulates the timestamps in memory and flushes them periodically, so the
// column is approximate by design — it answers "is this key still in use?", not
// "when exactly was request N".
func (d *DB) TouchKeys(ctx context.Context, seen map[string]time.Time) error {
if len(seen) == 0 {
return nil
}
return d.Tx(ctx, func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx,
`UPDATE api_keys SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`)
if err != nil {
return err
}
defer stmt.Close()
for id, t := range seen {
ts := t.Unix()
if _, err := stmt.ExecContext(ctx, ts, id, ts); err != nil {
return err
}
}
return nil
})
}