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>
234 lines
7.8 KiB
Go
234 lines
7.8 KiB
Go
// Overrides and the shared device keycode (AGENTS.md §4 FR-10, §7 overrides,
|
|
// §10 rule 3, §11 Phase 8).
|
|
//
|
|
// An override is a deliberate change to a device's desired state: a lock, an
|
|
// unlock, or a grant of time. Every override is written to the `overrides`
|
|
// table and, through the caller, to the append-only `audit` log (FR-15). The
|
|
// state table holds only the current value; `overrides` is the history.
|
|
//
|
|
// The device keycode is a single code shared by every device (§10 rule 3). It
|
|
// is stored only as a bcrypt hash in `settings`, is seeded from the environment
|
|
// at start-up, and can be changed with `control keycode` (or, later, from the
|
|
// dashboard).
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// keycodeSettingKey is the settings row that holds the shared device keycode.
|
|
const keycodeSettingKey = "device_keycode_hash"
|
|
|
|
// ErrNoKeycode is returned when no device keycode has been set.
|
|
var ErrNoKeycode = errors.New("device keycode is not set")
|
|
|
|
// Override is one row of the override history.
|
|
type Override struct {
|
|
ID int64
|
|
DeviceID int64
|
|
Actor string
|
|
Action string
|
|
Until *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// insertOverride appends one override row using the given executor, so it can
|
|
// be written atomically with the state change it records.
|
|
func insertOverride(ctx context.Context, ex interface {
|
|
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
|
}, deviceID int64, actor, action string, until *time.Time) error {
|
|
var untilArg any
|
|
if until != nil {
|
|
untilArg = until.UTC().Format(time.RFC3339)
|
|
}
|
|
if _, err := ex.ExecContext(ctx,
|
|
`INSERT INTO overrides (device_id, actor, action, until) VALUES (?, ?, ?, ?)`,
|
|
deviceID, actor, action, untilArg); err != nil {
|
|
return fmt.Errorf("override insert: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListOverrides returns a device's override history, newest first.
|
|
func (s *Store) ListOverrides(ctx context.Context, deviceID int64, limit int) ([]Override, error) {
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 50
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, device_id, actor, action, until, created_at
|
|
FROM overrides WHERE device_id = ?
|
|
ORDER BY id DESC LIMIT ?`, deviceID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list overrides: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]Override, 0, limit)
|
|
for rows.Next() {
|
|
var (
|
|
o Override
|
|
until sql.NullString
|
|
created sql.NullString
|
|
)
|
|
if err := rows.Scan(&o.ID, &o.DeviceID, &o.Actor, &o.Action, &until, &created); err != nil {
|
|
return nil, fmt.Errorf("scan override: %w", err)
|
|
}
|
|
o.Until = parseTimePtr(until)
|
|
o.CreatedAt = parseTime(created.String)
|
|
out = append(out, o)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list overrides: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SetDeviceGrant gives a device a time-limited exemption: it is unlocked until
|
|
// `until`, even if a weekly window is active. It clears any manual lock, writes
|
|
// one override row and one audit row in the same transaction, and returns the
|
|
// fresh device. `until` must be in the future.
|
|
func (s *Store) SetDeviceGrant(ctx context.Context, id int64, until time.Time, actor, action string) (*Device, error) {
|
|
if until.IsZero() {
|
|
return nil, errors.New("grant needs an end time")
|
|
}
|
|
until = until.UTC()
|
|
if !until.After(time.Now()) {
|
|
return nil, errors.New("grant end time must be in the future")
|
|
}
|
|
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 grant: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO state (device_id, locked, until, grant_until, updated_at)
|
|
VALUES (?, 0, NULL, ?, datetime('now'))
|
|
ON CONFLICT(device_id) DO UPDATE SET
|
|
locked = 0,
|
|
until = NULL,
|
|
grant_until = excluded.grant_until,
|
|
updated_at = datetime('now')`,
|
|
id, until.Format(time.RFC3339)); err != nil {
|
|
return nil, fmt.Errorf("set grant: %w", err)
|
|
}
|
|
if err := insertAudit(ctx, tx, actor, action, &id,
|
|
"granted time until "+until.Format(time.RFC3339)); 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 grant: %w", err)
|
|
}
|
|
return s.GetDevice(ctx, id)
|
|
}
|
|
|
|
// ---------- device keycode ----------
|
|
|
|
// SetKeycodeHash stores the bcrypt hash of the shared device keycode and
|
|
// appends one audit row. The hash is validated first, so a corrupt value never
|
|
// replaces a working code.
|
|
func (s *Store) SetKeycodeHash(ctx context.Context, hash, actor string) error {
|
|
if _, err := bcrypt.Cost([]byte(hash)); err != nil {
|
|
return fmt.Errorf("device keycode hash is not a valid bcrypt hash: %w", err)
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("set keycode: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
|
keycodeSettingKey, hash); err != nil {
|
|
return fmt.Errorf("set keycode: %w", err)
|
|
}
|
|
if err := insertAudit(ctx, tx, actor, "keycode.updated", nil, "shared device keycode changed"); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit keycode: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// KeycodeHash returns the stored bcrypt hash, or ErrNoKeycode.
|
|
func (s *Store) KeycodeHash(ctx context.Context) (string, error) {
|
|
var value string
|
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, keycodeSettingKey).Scan(&value)
|
|
if errors.Is(err, sql.ErrNoRows) || value == "" {
|
|
return "", ErrNoKeycode
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("keycode hash: %w", err)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// VerifyKeycode reports whether code matches the stored shared keycode. A
|
|
// missing or invalid stored hash never matches, so a broken settings row cannot
|
|
// unlock a device by accident.
|
|
func (s *Store) VerifyKeycode(ctx context.Context, code string) bool {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return false
|
|
}
|
|
hash, err := s.KeycodeHash(ctx)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) == nil
|
|
}
|
|
|
|
// SeedKeycodeHash stores hash only when no code is stored yet. The Control
|
|
// Service calls it at start-up so CONTROL_DEVICE_KEYCODE_HASH in environment.d
|
|
// is the first-run source of the code. Once a code exists, the settings row is
|
|
// authoritative: a restart never overwrites a code changed with `control
|
|
// keycode`, so a runtime change is durable. It returns whether it stored a code.
|
|
func (s *Store) SeedKeycodeHash(ctx context.Context, hash string) (bool, error) {
|
|
hash = strings.TrimSpace(hash)
|
|
if hash == "" {
|
|
return false, nil
|
|
}
|
|
if _, err := bcrypt.Cost([]byte(hash)); err != nil {
|
|
return false, fmt.Errorf("CONTROL_DEVICE_KEYCODE_HASH is not a valid bcrypt hash: %w", err)
|
|
}
|
|
if _, err := s.KeycodeHash(ctx); err == nil {
|
|
return false, nil // already set; never overwrite at start-up
|
|
} else if !errors.Is(err, ErrNoKeycode) {
|
|
return false, err
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return false, fmt.Errorf("seed keycode: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
|
keycodeSettingKey, hash); err != nil {
|
|
return false, fmt.Errorf("seed keycode: %w", err)
|
|
}
|
|
if err := insertAudit(ctx, tx, "system", "keycode.updated", nil, "seeded from CONTROL_DEVICE_KEYCODE_HASH"); err != nil {
|
|
return false, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return false, fmt.Errorf("commit seeded keycode: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|