move_detect/cloud/schema.sql
유창욱 9aa7f44ea4 feat(cloud): PDO repositories + schema + Cafe24 deploy doc
Implements Task 16 (final, P1-B): App\Db\Database::connect(Config): PDO
factory and the 9 App\Repository\Pdo\Pdo*Repository classes backing the
repository interfaces consumed by controllers since T1-T15, all-prepared
statements, plus cloud/schema.sql (MySQL 8 DDL) and cloud/docs/cloud-deploy.md.

Security hardening incorporated per accumulated task reviews:
- PdoNonceRepository: nonces.nonce is PK; duplicate remember() throws instead
  of racing seen() (closes T7 TOCTOU).
- PdoRateLimiterRepository::hit(): atomic INSERT ... ON DUPLICATE KEY UPDATE
  upsert on (rate_key, window_start), no read-modify-write race.
- PdoSnapshotRepository::pathByUuid(): validates 32-hex uuid before building
  any filesystem path (closes T12 path-traversal risk).
- PdoZoneRepository::save(): update path is ownership-scoped by
  (zone_id, device_id, camera_id); throws on mismatch instead of silently
  overwriting an unrelated zone (closes T13 cross-tenant overwrite risk).
- Database::connect(): ERRMODE_EXCEPTION, EMULATE_PREPARES=false, FETCH_ASSOC.

cloud/tests/PdoIntegrationTest.php round-trips Nonce/Event/Zone against a
real sqlite :memory: DB, gated via markTestSkipped when pdo_sqlite is
unavailable (this host has pdo_mysql only, no pdo_sqlite) -- suite stays
green with 4 tests skipped.

Full suite: 59 tests, 114 assertions, 4 skipped, 0 failures, 0
warnings/deprecations. php -l passes on all new files and on
cloud/public/index.php (T15), which references all new FQCNs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
2026-07-12 01:58:05 +09:00

219 lines
12 KiB
SQL

-- cloud/schema.sql
-- MySQL 8.0+ DDL for the P1-B cloud ingest + dashboard subset
-- (backing store for App\Repository\Pdo\* -- see cloud/src/Repository/Pdo/).
--
-- Scope: this is the P1-B subset of the full design-spec schema (design doc §7.4).
-- Tables/columns that belong to later phases are intentionally OMITTED here (YAGNI):
-- sites, escalations, oncall, compliance, retention_policy, audit hash-chaining
-- (prev_hash/hash on audit_log), and events.disposition/is_test/legal_hold/lifecycle_state.
-- See docs/superpowers/specs/2026-07-11-workplace-safety-ai-cctv-design.md §7.4 and the
-- Task 16 brief self-review for the P1-B / P2 / P3 split.
--
-- sqlite compatibility notes (cloud/tests/PdoIntegrationTest.php uses its own minimal
-- sqlite-flavored DDL for the tables it round-trips -- this file targets MySQL only,
-- these notes exist to explain the mechanical translation if that ever needs to grow):
-- * `INT ... AUTO_INCREMENT` -> sqlite: `INTEGER PRIMARY KEY AUTOINCREMENT` (must be the
-- table's sole INTEGER PRIMARY KEY column)
-- * `ENGINE=InnoDB` -> sqlite: omit (no storage engines)
-- * `DEFAULT CHARSET=...` -> sqlite: omit
-- * `JSON` column type -> sqlite: `TEXT` (no native JSON type; app always
-- reads/writes JSON-encoded strings either way)
-- * `DEFAULT (UNIX_TIMESTAMP())` -> sqlite: `DEFAULT (strftime('%s','now'))`
-- * `INSERT ... ON DUPLICATE KEY UPDATE` (rate_limits upsert) -> sqlite:
-- `INSERT ... ON CONFLICT(rate_key, window_start)
-- DO UPDATE SET count = count + 1`
-- * `CHECK (...)` on enum-like columns -> sqlite supports CHECK natively, no change
-- * `TINYINT(1)` -> sqlite: `INTEGER` (0/1)
-- * `BIGINT UNSIGNED` -> sqlite: `INTEGER` (sqlite ints are always 64-bit signed)
--
-- All app-level SQL against this schema uses prepared statements with `?` placeholders --
-- no string interpolation of values anywhere in App\Repository\Pdo\*.
SET NAMES utf8mb4;
-- ---------------------------------------------------------------------------
-- users: dashboard accounts (auth + RBAC + brute-force lockout).
-- No PdoUserRepository::create() exists in P1-B -- the first admin account is
-- provisioned out-of-band via the CLI snippet in cloud/docs/cloud-deploy.md.
-- ---------------------------------------------------------------------------
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(190) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- Argon2id, see App\Security\Password::hash()
role VARCHAR(32) NOT NULL, -- app-whitelisted: admin|operator|viewer (App\Auth\Rbac)
site_scope VARCHAR(190) NULL,
failed_attempts INT UNSIGNED NOT NULL DEFAULT 0,
locked_until BIGINT UNSIGNED NOT NULL DEFAULT 0, -- unix timestamp; 0 = not locked
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
PRIMARY KEY (id),
UNIQUE KEY uq_users_username (username),
CONSTRAINT chk_users_role CHECK (role IN ('admin','operator','viewer'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- devices: one row per Pi/edge device; holds the per-device HMAC ingest secret.
-- No PdoDeviceRepository::create() exists -- rows are provisioned by an admin
-- (per-device secret issuance) before the device is allowed to sync/ingest.
-- ---------------------------------------------------------------------------
CREATE TABLE devices (
device_id VARCHAR(64) NOT NULL,
name VARCHAR(190) NULL,
secret VARCHAR(255) NOT NULL, -- per-device HMAC key; rotate via admin CLI, never log
config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config
last_fps DOUBLE NULL,
last_temp DOUBLE NULL,
last_throttle TINYINT(1) NULL,
reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat
last_heartbeat_at BIGINT UNSIGNED NULL,
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
PRIMARY KEY (device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- cameras: per-device camera inventory (design doc §7.4). No dedicated
-- PdoCameraRepository exists in P1-B -- zones/events reference camera_id
-- directly as a free-form string. Kept for documentation/future per-camera
-- config and to avoid a breaking migration when that repository is added.
-- ---------------------------------------------------------------------------
CREATE TABLE cameras (
camera_id VARCHAR(64) NOT NULL,
device_id VARCHAR(64) NOT NULL,
name VARCHAR(190) NULL,
role VARCHAR(32) NULL, -- app-whitelisted: fall_critical|zone|low
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
PRIMARY KEY (camera_id),
KEY ix_cameras_device (device_id),
CONSTRAINT chk_cameras_role CHECK (role IS NULL OR role IN ('fall_critical','zone','low'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- zones: polygon regions per camera, edited via the zone-config dashboard.
-- No FK to cameras/devices: zones may legitimately be created before a
-- `cameras` row exists for a given camera_id (no camera-registration flow in
-- P1-B), so a FK here would spuriously reject valid app writes.
-- ---------------------------------------------------------------------------
CREATE TABLE zones (
zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids
device_id VARCHAR(64) NOT NULL,
camera_id VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS)
polygon_json JSON NOT NULL,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (zone_id),
KEY ix_zones_camera (camera_id),
KEY ix_zones_device (device_id),
CONSTRAINT chk_zones_level CHECK (level IN ('warning','restricted','pinch'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- events: ingested detections. event_type/severity are already validated in
-- App\Ingest\EventValidator before insert(); the CHECK constraints below are
-- defense-in-depth only, not the primary validation layer.
-- ---------------------------------------------------------------------------
CREATE TABLE events (
event_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
camera_id VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
track_id INT NULL,
zone_id CHAR(32) NULL,
occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float)
duration_s DOUBLE NOT NULL DEFAULT 0,
metrics_json JSON NULL,
acknowledged_by VARCHAR(190) NULL,
acknowledged_at BIGINT UNSIGNED NULL,
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
PRIMARY KEY (event_id),
KEY ix_events_occurred_at (occurred_at),
KEY ix_events_severity_occurred_at (severity, occurred_at),
KEY ix_events_device (device_id),
CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault'
)),
CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- event_snapshots: re-encoded JPEG references. Files live under storage/
-- (webroot-adjacent, served only via the authenticated SnapshotProxy). FK to
-- events is safe because IngestController always inserts the parent event row
-- before calling SnapshotRepository::add() for its snapshots.
-- ---------------------------------------------------------------------------
CREATE TABLE event_snapshots (
snapshot_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id INT UNSIGNED NOT NULL,
file_uuid CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)); on-disk filename = <uuid>.jpg
content_hash CHAR(64) NOT NULL, -- sha256 of the re-encoded bytes
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (snapshot_id),
UNIQUE KEY uq_event_snapshots_uuid (file_uuid),
KEY ix_event_snapshots_event (event_id),
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- thresholds: per-device (optionally per-camera/zone) tunables synced via
-- GET /api/config. No PdoThresholdRepository write method exists in P1-B --
-- rows are seeded/edited directly (phpMyAdmin) until a management UI lands.
-- ---------------------------------------------------------------------------
CREATE TABLE thresholds (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
camera_id VARCHAR(64) NULL,
zone_id CHAR(32) NULL,
`key` VARCHAR(64) NOT NULL,
`value` VARCHAR(255) NOT NULL,
PRIMARY KEY (id),
KEY ix_thresholds_device (device_id),
UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- nonces: replay-protection store for ingest/config/heartbeat HMAC requests.
-- `nonce` is the PRIMARY KEY (unique) so PdoNonceRepository::remember() is
-- safe against a duplicate-insert race: IngestController already calls
-- seen() before remember(), and a thrown duplicate-key error on a racing
-- remember() is an acceptable, expected outcome (closes the seen()/remember()
-- TOCTOU flagged in the Task 7 review -- the PK uniqueness is the real guard,
-- not the earlier seen() check).
-- ---------------------------------------------------------------------------
CREATE TABLE nonces (
nonce VARCHAR(190) NOT NULL,
expires_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (nonce),
KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM nonces WHERE expires_at < ?` cleanup job
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- rate_limits: per-key sliding-window counters for ingest rate limiting. The
-- (rate_key, window_start) primary key backs an atomic
-- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in
-- PdoRateLimiterRepository::hit() -- no read-modify-write race window.
-- ---------------------------------------------------------------------------
CREATE TABLE rate_limits (
rate_key VARCHAR(190) NOT NULL,
window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket
count INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (rate_key, window_start)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- audit_log: append-only action log (acknowledge / zone save+delete / login).
-- Tamper-evident hash-chaining (design doc §9.2) is deferred to P3 per the
-- Task 16 brief self-review -- this table is the plain append-only record only.
-- ---------------------------------------------------------------------------
CREATE TABLE audit_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
actor VARCHAR(190) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(190) NOT NULL,
before_json JSON NOT NULL,
after_json JSON NOT NULL,
ts BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id),
KEY ix_audit_log_ts (ts),
KEY ix_audit_log_actor (actor)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;