Files
Sam Rolfe 4f233a8012 Phase 8: overrides (shared keycode + versioned admin lock/unlock/grant API)
Build on Phases 1–7. Adds the two override paths from AGENTS.md §4 FR-10/FR-11:
the shared device keycode and the versioned admin HTTP API the separate Kotlin
admin app will call.

- store: migration 004 adds state.grant_until and a settings table. New
  overrides.go records every override in `overrides` and verifies the shared
  keycode (bcrypt; one code for every device, §10 rule 3). ExpireLocks also
  clears an expired grant, and NextScheduleBoundary includes the grant end so a
  device re-locks on time.
- effective state: a grant unlocks a device inside a schedule window; a manual
  lock clears a grant and a grant clears a manual lock. The retained config
  payload gains grant_until (additive, v stays 1).
- web: /api/v1/admin/... (list, state, lock, unlock, grant) guarded by
  CONTROL_ADMIN_API_TOKEN, separate from the session login, plus the on-device
  keycode endpoint POST /api/v1/device/override with per-address throttling.
  A state change is published retained before the API responds.
- cmd/control: `keycode` subcommand and start-up seeding from
  CONTROL_DEVICE_KEYCODE_HASH.
- deploy/verify-phase8.sh + `make verify-phase8` prove the definition of done:
  an API lock reaches a real broker subscriber in well under a second.
- Version 0.6.0-phase8 (compose, Makefile, docs).

Measured on .13: the lock reached the TLS broker subscriber in 7 ms (handler
1 ms, request to retained publish accepted).

Tests: go vet ./... and go test -count=1 ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 16:27:46 +11:00

452 lines
13 KiB
Go

// Device registry and desired state (AGENTS.md §7 devices/state, §11 Phase 2).
//
// The Control Service is the only writer of device config (SYSTEM_MAP.md).
// Every mutation appends to the audit log (FR-15), and nothing is ever
// hard-deleted (§10 rule 10).
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// ErrNotFound is returned when a device id does not exist.
var ErrNotFound = errors.New("device not found")
// Platform values allowed by the devices.platform CHECK constraint.
const (
PlatformAndroid = "android"
PlatformWindows = "windows"
)
// ValidPlatform reports whether p is one of the supported platforms.
func ValidPlatform(p string) bool {
return p == PlatformAndroid || p == PlatformWindows
}
// Device is one registered device plus its desired state.
type Device struct {
ID int64
Name string
Child string
Platform string
Topic string
Online bool
LastSeen *time.Time
CreatedAt time.Time
UpdatedAt time.Time
// Desired state, from the state table.
Locked bool
Until *time.Time
// GrantUntil is a time-limited exemption (Phase 8): while it is in the
// future the device is unlocked even during a schedule window. It is nil
// when no grant is active or one has been cleared.
GrantUntil *time.Time
StateAt time.Time
}
// AuditEntry is one row of the append-only audit log (FR-15).
type AuditEntry struct {
ID int64
Actor string
Action string
DeviceID *int64
DeviceName string
Detail string
CreatedAt time.Time
}
// selectDevice is the shared projection for reading a device with its state.
const selectDevice = `
SELECT d.id, d.name, d.child, d.platform, d.topic, d.online, d.last_seen,
d.created_at, d.updated_at,
COALESCE(s.locked, 0), s.until, s.grant_until, s.updated_at
FROM devices d
LEFT JOIN state s ON s.device_id = d.id`
func scanDevice(row interface {
Scan(dest ...any) error
}) (*Device, error) {
var (
dev Device
online int
lastSeen, until, grantUntil sql.NullString
createdAt, updatedAt, stateAt sql.NullString
)
if err := row.Scan(
&dev.ID, &dev.Name, &dev.Child, &dev.Platform, &dev.Topic, &online,
&lastSeen, &createdAt, &updatedAt,
&dev.Locked, &until, &grantUntil, &stateAt,
); err != nil {
return nil, err
}
dev.Online = online != 0
dev.LastSeen = parseTimePtr(lastSeen)
dev.Until = parseTimePtr(until)
dev.GrantUntil = parseTimePtr(grantUntil)
if t := parseTime(createdAt.String); !t.IsZero() {
dev.CreatedAt = t
}
if t := parseTime(updatedAt.String); !t.IsZero() {
dev.UpdatedAt = t
}
if t := parseTime(stateAt.String); !t.IsZero() {
dev.StateAt = t
}
return &dev, nil
}
// ListDevices returns every registered device with its desired state, in
// registration order. Expired locks are already cleared by ExpireLocks.
func (s *Store) ListDevices(ctx context.Context) ([]Device, error) {
rows, err := s.db.QueryContext(ctx, selectDevice+` ORDER BY d.id`)
if err != nil {
return nil, fmt.Errorf("list devices: %w", err)
}
defer rows.Close()
out := make([]Device, 0, 8)
for rows.Next() {
dev, err := scanDevice(rows)
if err != nil {
return nil, fmt.Errorf("scan device: %w", err)
}
out = append(out, *dev)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list devices: %w", err)
}
return out, nil
}
// GetDevice returns one device with its state, or ErrNotFound.
func (s *Store) GetDevice(ctx context.Context, id int64) (*Device, error) {
dev, err := scanDevice(s.db.QueryRowContext(ctx, selectDevice+` WHERE d.id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get device: %w", err)
}
return dev, nil
}
// CreateDevice inserts a device and its initial (unlocked) state row, and
// appends one audit entry. The topic is derived from the name and the new id,
// so it is stable across renames.
func (s *Store) CreateDevice(ctx context.Context, name, child, platform, actor string) (*Device, error) {
name = strings.TrimSpace(name)
child = strings.TrimSpace(child)
platform = strings.ToLower(strings.TrimSpace(platform))
if name == "" {
return nil, errors.New("device name is required")
}
if len(name) > 80 {
return nil, errors.New("device name must be 80 characters or fewer")
}
if child == "" {
return nil, errors.New("child is required")
}
if len(child) > 40 {
return nil, errors.New("child must be 40 characters or fewer")
}
if !ValidPlatform(platform) {
return nil, errors.New(`platform must be "android" or "windows"`)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("create device: %w", err)
}
defer func() { _ = tx.Rollback() }()
// The topic needs the new id, so insert with a placeholder then set it.
res, err := tx.ExecContext(ctx,
`INSERT INTO devices (name, child, platform, topic) VALUES (?, ?, ?, '')`,
name, child, platform)
if err != nil {
return nil, fmt.Errorf("insert device: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("device id: %w", err)
}
topic := fmt.Sprintf("restrict/%s_%d/config", slug(name), id)
if _, err := tx.ExecContext(ctx,
`UPDATE devices SET topic = ? WHERE id = ?`, topic, id); err != nil {
return nil, fmt.Errorf("set topic: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO state (device_id, locked, until) VALUES (?, 0, NULL)`, id); err != nil {
return nil, fmt.Errorf("insert state: %w", err)
}
if err := insertAudit(ctx, tx, actor, "device.created", &id,
fmt.Sprintf("%s (%s · %s)", name, child, platform)); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit device: %w", err)
}
return s.GetDevice(ctx, id)
}
// UpdateDevice renames or reassigns a device. The topic and id never change, so
// a device's MQTT identity is stable. It appends one audit entry.
func (s *Store) UpdateDevice(ctx context.Context, id int64, name, child, platform, actor string) (*Device, error) {
name = strings.TrimSpace(name)
child = strings.TrimSpace(child)
platform = strings.ToLower(strings.TrimSpace(platform))
if name == "" {
return nil, errors.New("device name is required")
}
if len(name) > 80 {
return nil, errors.New("device name must be 80 characters or fewer")
}
if child == "" {
return nil, errors.New("child is required")
}
if len(child) > 40 {
return nil, errors.New("child must be 40 characters or fewer")
}
if !ValidPlatform(platform) {
return nil, errors.New(`platform must be "android" or "windows"`)
}
res, err := s.db.ExecContext(ctx,
`UPDATE devices SET name = ?, child = ?, platform = ?, updated_at = datetime('now') WHERE id = ?`,
name, child, platform, id)
if err != nil {
return nil, fmt.Errorf("update device: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("update device: %w", err)
}
if n == 0 {
return nil, ErrNotFound
}
if err := s.Audit(ctx, actor, "device.updated", &id,
fmt.Sprintf("%s (%s · %s)", name, child, platform)); err != nil {
return nil, err
}
return s.GetDevice(ctx, id)
}
// SetDeviceLock writes the desired lock state for one device and appends one
// audit entry in the same transaction. When locked is false, until is ignored.
func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until *time.Time, actor, action string) (*Device, error) {
if !locked {
until = nil
}
if locked && until != nil {
t := until.UTC()
until = &t
}
if _, err := s.GetDevice(ctx, id); err != nil {
return nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("set state: %w", err)
}
defer func() { _ = tx.Rollback() }()
var untilArg any
detail := "unlocked"
if locked {
detail = "locked"
if until != nil {
untilArg = until.Format(time.RFC3339)
detail = "locked until " + until.Format(time.RFC3339)
} else {
detail = "locked (no end time)"
}
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO state (device_id, locked, until, grant_until, updated_at)
VALUES (?, ?, ?, NULL, datetime('now'))
ON CONFLICT(device_id) DO UPDATE SET
locked = excluded.locked,
until = excluded.until,
grant_until = NULL,
updated_at = datetime('now')`,
id, boolToInt(locked), untilArg); err != nil {
return nil, fmt.Errorf("set state: %w", err)
}
if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil {
return nil, err
}
if err := insertOverride(ctx, tx, id, actor, action, until); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit state: %w", err)
}
return s.GetDevice(ctx, id)
}
// ExpireLocks unlocks every device whose timed lock has passed and clears every
// expired grant, appending one audit entry per device whose lock lifted. It
// returns how many locks it cleared. It is safe to call often.
func (s *Store) ExpireLocks(ctx context.Context) (int, error) {
now := time.Now().UTC().Format(time.RFC3339)
rows, err := s.db.QueryContext(ctx,
`SELECT device_id FROM state WHERE locked = 1 AND until IS NOT NULL AND until <= ?`, now)
if err != nil {
return 0, fmt.Errorf("expire locks: %w", err)
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return 0, fmt.Errorf("scan expired lock: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, fmt.Errorf("expire locks: %w", err)
}
rows.Close()
for _, id := range ids {
if _, err := s.SetDeviceLock(ctx, id, false, nil, "system", "state.expired"); err != nil {
return 0, err
}
}
// A grant is silent: it changes no audit row and needs no device callback,
// because EffectiveState already ignores a past grant. Clearing it keeps the
// stored state tidy and the API response honest.
if _, err := s.db.ExecContext(ctx, `
UPDATE state SET grant_until = NULL, updated_at = datetime('now')
WHERE grant_until IS NOT NULL AND grant_until <= ?`, now); err != nil {
return len(ids), fmt.Errorf("expire grants: %w", err)
}
return len(ids), nil
}
// ListAudit returns the newest audit entries first (FR-15).
func (s *Store) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `
SELECT a.id, a.actor, a.action, a.device_id, COALESCE(d.name, ''),
COALESCE(a.detail, ''), a.created_at
FROM audit a
LEFT JOIN devices d ON d.id = a.device_id
ORDER BY a.id DESC
LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list audit: %w", err)
}
defer rows.Close()
out := make([]AuditEntry, 0, limit)
for rows.Next() {
var (
e AuditEntry
deviceID sql.NullInt64
created sql.NullString
)
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &deviceID,
&e.DeviceName, &e.Detail, &created); err != nil {
return nil, fmt.Errorf("scan audit: %w", err)
}
if deviceID.Valid {
id := deviceID.Int64
e.DeviceID = &id
}
e.CreatedAt = parseTime(created.String)
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list audit: %w", err)
}
return out, nil
}
// insertAudit appends one audit row using the given executor (a transaction or
// the database handle), so callers can audit atomically with a state change.
func insertAudit(ctx context.Context, ex interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}, actor, action string, deviceID *int64, detail string) error {
var id any
if deviceID != nil {
id = *deviceID
}
if _, err := ex.ExecContext(ctx,
`INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`,
actor, action, id, detail); err != nil {
return fmt.Errorf("audit insert: %w", err)
}
return nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// parseTime accepts the RFC3339 strings this package writes and the
// "YYYY-MM-DD HH:MM:SS" strings SQLite's datetime() writes.
func parseTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
func parseTimePtr(ns sql.NullString) *time.Time {
if !ns.Valid {
return nil
}
t := parseTime(ns.String)
if t.IsZero() {
return nil
}
return &t
}
// slug makes a short ASCII token for an MQTT topic from a device name.
func slug(name string) string {
var b strings.Builder
lastUnderscore := false
for _, r := range strings.ToLower(name) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastUnderscore = false
default:
if !lastUnderscore && b.Len() > 0 {
b.WriteByte('_')
lastUnderscore = true
}
}
}
out := strings.Trim(b.String(), "_")
if out == "" {
return "device"
}
return out
}