init
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user