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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user