// 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 }