Files
Paperclip 9af543c394 Phase 6: schedules, blocklists, allowlists (editors + effective state)
- Weekly schedule editor per device: a Monday-Sunday grid of blocking
  windows, including windows that cross midnight.
- Blocklist/allowlist editor per device: app (Android package / Windows
  process) and site (hostname) entries, tagged by the device platform.
- Effective desired state: a manual lock, an active schedule window, or
  neither. While blocked the allowlist is honoured (Spotify stays while
  other apps are blocked). Published retained with reason schedule/manual.
- Boundary-aware republish loop so a window turns on and off on time,
  and manual lock expiry is cleared promptly.
- CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so
  wall-clock windows are evaluated in the family zone, not the container.
- Every change is written to the append-only audit log (FR-15).

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

283 lines
8.2 KiB
Go

// Per-device allow and block lists, and device lookup by MQTT topic
// (AGENTS.md §4 FR-3/FR-4, §7 blocklist/allowlist, §11 Phase 6).
//
// A list entry is one value tagged with a kind: "app" (an Android package name
// or a Windows process name) or "site" (a hostname). The editor on a device
// page edits the entries scoped to that device. A row with a NULL device_id
// applies to every device (a group entry, §7); the data model supports it and
// the effective state merges it, but the Phase 6 editor only manages
// device-scoped rows.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// List table names. They are package constants, never caller input, so they are
// safe to interpolate into a query.
const (
TableBlocklist = "blocklist"
TableAllowlist = "allowlist"
)
// List kind values allowed by the blocklist/allowlist CHECK constraint.
const (
ListKindApp = "app"
ListKindSite = "site"
)
// ListEntry is one blocklist or allowlist row.
type ListEntry struct {
ID int64
DeviceID *int64 // nil = applies to every device
Kind string
Value string
CreatedAt time.Time
}
// ValidListKind reports whether kind is "app" or "site".
func ValidListKind(kind string) bool {
return kind == ListKindApp || kind == ListKindSite
}
// listTable maps a public constant to the real table name, rejecting anything
// else so a caller can never select an arbitrary table.
func listTable(table string) (string, error) {
switch table {
case TableBlocklist:
return "blocklist", nil
case TableAllowlist:
return "allowlist", nil
default:
return "", fmt.Errorf("unknown list %q", table)
}
}
// normalizeListValue trims a value and rejects blanks, over-long values and
// control characters. Hostnames are lower-cased because DNS is
// case-insensitive; app identifiers keep their case.
func normalizeListValue(kind, value string) (string, error) {
if !ValidListKind(kind) {
return "", errors.New(`kind must be "app" or "site"`)
}
value = strings.TrimSpace(value)
if value == "" {
return "", errors.New("value is required")
}
if len(value) > 200 {
return "", errors.New("value must be 200 characters or fewer")
}
for _, r := range value {
if r < 0x20 || r == 0x7f {
return "", errors.New("value must not contain control characters")
}
}
if kind == ListKindSite {
value = strings.ToLower(value)
}
return value, nil
}
// DeviceLists returns the values that stay allowed and the values that are
// blocked for one device. A row with a NULL device_id applies to every device
// (§7). Rows are returned in insertion order, de-duplicated.
func (s *Store) DeviceLists(ctx context.Context, id int64) (allow, block []string, err error) {
if allow, err = s.listValues(ctx, TableAllowlist, id); err != nil {
return nil, nil, err
}
if block, err = s.listValues(ctx, TableBlocklist, id); err != nil {
return nil, nil, err
}
return allow, block, nil
}
func (s *Store) listValues(ctx context.Context, table string, id int64) ([]string, error) {
name, err := listTable(table)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT value FROM `+name+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id)
if err != nil {
return nil, fmt.Errorf("list %s: %w", name, err)
}
defer rows.Close()
out := make([]string, 0, 4)
seen := make(map[string]struct{}, 4)
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("scan %s: %w", name, err)
}
v = strings.TrimSpace(v)
if v == "" {
continue
}
key := v
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list %s: %w", name, err)
}
return out, nil
}
// ListEntries returns one device's blocklist or allowlist rows, oldest first.
func (s *Store) ListEntries(ctx context.Context, deviceID int64, table string) ([]ListEntry, error) {
name, err := listTable(table)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, device_id, kind, value, created_at
FROM `+name+` WHERE device_id = ?
ORDER BY id`, deviceID)
if err != nil {
return nil, fmt.Errorf("list entries: %w", err)
}
defer rows.Close()
out := make([]ListEntry, 0, 8)
for rows.Next() {
var (
e ListEntry
devID sql.NullInt64
created sql.NullString
)
if err := rows.Scan(&e.ID, &devID, &e.Kind, &e.Value, &created); err != nil {
return nil, fmt.Errorf("scan entry: %w", err)
}
if devID.Valid {
id := devID.Int64
e.DeviceID = &id
}
e.CreatedAt = parseTime(created.String)
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list entries: %w", err)
}
return out, nil
}
// AddListEntry appends one blocklist or allowlist value for a device and
// appends one audit entry in the same transaction. A value that is already
// present for the device (same kind and value, case-insensitively) is refused,
// so repeated submits do not pile up.
func (s *Store) AddListEntry(ctx context.Context, deviceID int64, table, kind, value, actor string) (*ListEntry, error) {
name, err := listTable(table)
if err != nil {
return nil, err
}
value, err = normalizeListValue(kind, value)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("add entry: %w", err)
}
defer func() { _ = tx.Rollback() }()
var exists int
err = tx.QueryRowContext(ctx,
`SELECT 1 FROM `+name+` WHERE device_id = ? AND kind = ? AND lower(value) = lower(?) LIMIT 1`,
deviceID, kind, value).Scan(&exists)
if err == nil {
return nil, fmt.Errorf("%s %q is already on the list", kind, value)
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("check entry: %w", err)
}
res, err := tx.ExecContext(ctx,
`INSERT INTO `+name+` (device_id, kind, value) VALUES (?, ?, ?)`, deviceID, kind, value)
if err != nil {
return nil, fmt.Errorf("insert entry: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("entry id: %w", err)
}
listName := "allowlist"
if table == TableBlocklist {
listName = "blocklist"
}
if err := insertAudit(ctx, tx, actor, "list.added", &deviceID,
fmt.Sprintf("%s %s %s", listName, kind, value)); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit entry: %w", err)
}
return &ListEntry{ID: id, DeviceID: &deviceID, Kind: kind, Value: value}, nil
}
// DeleteListEntry removes one device-scoped row and appends one audit entry.
// A row that belongs to another device is not touched: the id and device_id
// must both match, so a guessed id cannot edit someone else's list.
func (s *Store) DeleteListEntry(ctx context.Context, deviceID, entryID int64, table, actor string) error {
name, err := listTable(table)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("delete entry: %w", err)
}
defer func() { _ = tx.Rollback() }()
var kind, value string
err = tx.QueryRowContext(ctx,
`SELECT kind, value FROM `+name+` WHERE id = ? AND device_id = ?`,
entryID, deviceID).Scan(&kind, &value)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("read entry: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM `+name+` WHERE id = ?`, entryID); err != nil {
return fmt.Errorf("delete entry: %w", err)
}
listName := "allowlist"
if table == TableBlocklist {
listName = "blocklist"
}
if err := insertAudit(ctx, tx, actor, "list.removed", &deviceID,
fmt.Sprintf("%s %s %s", listName, kind, value)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete: %w", err)
}
return nil
}
// DeviceByConfigTopic finds a device from a topic the device wrote to, for
// example "restrict/harry_phone_2/status". Only the Control Service reads the
// topic table, so a topic the service does not know is ignored.
func (s *Store) DeviceByConfigTopic(ctx context.Context, topic string) (*Device, error) {
dev, err := scanDevice(s.db.QueryRowContext(ctx, selectDevice+` WHERE d.topic = ?`, topic))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("device by topic: %w", err)
}
return dev, nil
}