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>
This commit is contained in:
2026-10-13 16:27:46 +11:00
parent b58993d994
commit 4f233a8012
19 changed files with 1833 additions and 67 deletions

View File

@@ -41,9 +41,13 @@ type Device struct {
UpdatedAt time.Time
// Desired state, from the state table.
Locked bool
Until *time.Time
StateAt time.Time
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).
@@ -61,7 +65,7 @@ type AuditEntry struct {
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.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`
@@ -69,21 +73,22 @@ func scanDevice(row interface {
Scan(dest ...any) error
}) (*Device, error) {
var (
dev Device
online int
lastSeen, until, stateAt sql.NullString
createdAt, updatedAt sql.NullString
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, &stateAt,
&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
}
@@ -268,11 +273,12 @@ func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO state (device_id, locked, until, updated_at)
VALUES (?, ?, ?, datetime('now'))
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)
@@ -280,15 +286,18 @@ func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until
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 appends one
// audit entry per device. It returns how many locks it cleared. It is safe to
// call often.
// 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,
@@ -316,6 +325,14 @@ func (s *Store) ExpireLocks(ctx context.Context) (int, error) {
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
}