feat(cloud,edge): event dedup (event_uuid) + site-scoped access control (P1-9, P3-2b)
Two approved data-model changes. P1-9 — ingest idempotency. A POST can succeed server-side but time out client-side; the edge then re-queues and re-sends the same event, so the cloud stored duplicate alerts. The edge Event now carries a stable event_uuid (uuid4 hex) generated once at creation and preserved through the offline queue and every retry. Cloud: events.event_uuid CHAR(32) NULL UNIQUE; EventValidator accepts/validates it; IngestController returns the already-stored event (200, dedup:true) on a repeat uuid, race-safe against the UNIQUE key (catches the insert-race PDOException and returns the winner). P3-2b — site-scoped access control. site_scope was stored in the session but never enforced, so any authenticated user could read every site's worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto events.site (server-derived from the authenticated device_id, never client-supplied). App\Security\SiteScope centralizes the rule (NULL scope = global operator). The event list filters in SQL (page(...,$site)); event detail + acknowledge and the snapshot proxy guard per-row and return 404 (not 403) cross-site so existence never leaks. Schema migration ALTERs and per-device/user provisioning UPDATEs are documented at the bottom of schema.sql. Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one row; list scoped to caller site; detail cross-site 404; snapshot cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing (4 skipped: PDO), pytest 158 passing (2 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
This commit is contained in:
parent
70c9f4e0fe
commit
930490d1b4
23 changed files with 355 additions and 27 deletions
|
|
@ -91,7 +91,7 @@ $auth = new AuthController($users, $audit, $rate);
|
||||||
$csrfCtl = new CsrfController();
|
$csrfCtl = new CsrfController();
|
||||||
$list = new EventListController($events, $sess);
|
$list = new EventListController($events, $sess);
|
||||||
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
||||||
$proxy = new SnapshotProxy($snaps, $sess);
|
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
||||||
$zoneCtl = new ZoneConfigController($zones, $audit, $sess);
|
$zoneCtl = new ZoneConfigController($zones, $audit, $sess);
|
||||||
|
|
||||||
$render = function (string $view): Response {
|
$render = function (string $view): Response {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ CREATE TABLE users (
|
||||||
CREATE TABLE devices (
|
CREATE TABLE devices (
|
||||||
device_id VARCHAR(64) NOT NULL,
|
device_id VARCHAR(64) NOT NULL,
|
||||||
name VARCHAR(190) NULL,
|
name VARCHAR(190) NULL,
|
||||||
|
site VARCHAR(64) NULL, -- access-control scope; matches users.site_scope (NULL = unassigned)
|
||||||
secret VARCHAR(255) NOT NULL, -- per-device HMAC key; rotate via admin CLI, never log
|
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
|
config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config
|
||||||
last_fps DOUBLE NULL,
|
last_fps DOUBLE NULL,
|
||||||
|
|
@ -66,7 +67,8 @@ CREATE TABLE devices (
|
||||||
reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat
|
reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat
|
||||||
last_heartbeat_at BIGINT UNSIGNED NULL,
|
last_heartbeat_at BIGINT UNSIGNED NULL,
|
||||||
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
|
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
|
||||||
PRIMARY KEY (device_id)
|
PRIMARY KEY (device_id),
|
||||||
|
KEY ix_devices_site (site)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
@ -112,7 +114,9 @@ CREATE TABLE zones (
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
CREATE TABLE events (
|
CREATE TABLE events (
|
||||||
event_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
event_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
event_uuid CHAR(32) NULL, -- edge-generated idempotency key (dedup a re-sent event); NULL for legacy rows
|
||||||
device_id VARCHAR(64) NOT NULL,
|
device_id VARCHAR(64) NOT NULL,
|
||||||
|
site VARCHAR(64) NULL, -- denormalized from devices.site at ingest; access-control scope
|
||||||
camera_id VARCHAR(64) NOT NULL,
|
camera_id VARCHAR(64) NOT NULL,
|
||||||
event_type VARCHAR(32) NOT NULL,
|
event_type VARCHAR(32) NOT NULL,
|
||||||
severity VARCHAR(16) NOT NULL,
|
severity VARCHAR(16) NOT NULL,
|
||||||
|
|
@ -125,8 +129,13 @@ CREATE TABLE events (
|
||||||
acknowledged_at BIGINT UNSIGNED NULL,
|
acknowledged_at BIGINT UNSIGNED NULL,
|
||||||
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
|
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
|
||||||
PRIMARY KEY (event_id),
|
PRIMARY KEY (event_id),
|
||||||
|
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
|
||||||
|
-- but the ACK timed out client-side) collide instead of duplicating. MySQL allows multiple
|
||||||
|
-- NULLs in a UNIQUE index, so legacy rows without a uuid do not conflict.
|
||||||
|
UNIQUE KEY uq_events_uuid (event_uuid),
|
||||||
KEY ix_events_occurred_at (occurred_at),
|
KEY ix_events_occurred_at (occurred_at),
|
||||||
KEY ix_events_severity_occurred_at (severity, occurred_at),
|
KEY ix_events_severity_occurred_at (severity, occurred_at),
|
||||||
|
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
|
||||||
KEY ix_events_device (device_id),
|
KEY ix_events_device (device_id),
|
||||||
CONSTRAINT chk_events_type CHECK (event_type IN (
|
CONSTRAINT chk_events_type CHECK (event_type IN (
|
||||||
'fall','no_motion','immobility','ghost','zone_warning',
|
'fall','no_motion','immobility','ghost','zone_warning',
|
||||||
|
|
@ -217,3 +226,21 @@ CREATE TABLE audit_log (
|
||||||
KEY ix_audit_log_ts (ts),
|
KEY ix_audit_log_ts (ts),
|
||||||
KEY ix_audit_log_actor (actor)
|
KEY ix_audit_log_actor (actor)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- MIGRATIONS for an EXISTING database (fresh installs already have the columns
|
||||||
|
-- above). Apply once via phpMyAdmin, then set each device's site with:
|
||||||
|
-- UPDATE devices SET site = 'factory-a' WHERE device_id = 'pi-1';
|
||||||
|
-- and scope a dashboard user with:
|
||||||
|
-- UPDATE users SET site_scope = 'factory-a' WHERE username = '...';
|
||||||
|
-- A user with site_scope = NULL is a global operator (sees every site).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- P3-2b (site-scoped access control):
|
||||||
|
-- ALTER TABLE devices ADD COLUMN site VARCHAR(64) NULL AFTER name,
|
||||||
|
-- ADD KEY ix_devices_site (site);
|
||||||
|
-- ALTER TABLE events ADD COLUMN site VARCHAR(64) NULL AFTER device_id,
|
||||||
|
-- ADD KEY ix_events_site_occurred_at (site, occurred_at);
|
||||||
|
-- P1-9 (ingest idempotency / dedup):
|
||||||
|
-- ALTER TABLE events ADD COLUMN event_uuid CHAR(32) NULL AFTER event_id,
|
||||||
|
-- ADD UNIQUE KEY uq_events_uuid (event_uuid);
|
||||||
|
-- ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace App\Dashboard;
|
||||||
|
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\{SessionStore, Rbac};
|
use App\Auth\{SessionStore, Rbac};
|
||||||
use App\Security\Csrf;
|
use App\Security\{Csrf, SiteScope};
|
||||||
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
|
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
|
||||||
|
|
||||||
final class EventDetailController
|
final class EventDetailController
|
||||||
|
|
@ -14,12 +14,20 @@ final class EventDetailController
|
||||||
private AuditRepository $audit, private SessionStore $sess
|
private AuditRepository $audit, private SessionStore $sess
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// A scoped operator must not read — or acknowledge — an event outside their site. Return
|
||||||
|
// 404 (not 403) so a cross-site id is indistinguishable from a non-existent one (no leak).
|
||||||
|
private function outOfScope(array $event): bool
|
||||||
|
{
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
return !SiteScope::allows(is_string($scope) ? $scope : null, $event['site'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
public function show(Request $req, array $params): Response
|
public function show(Request $req, array $params): Response
|
||||||
{
|
{
|
||||||
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
$event = $this->events->find($id);
|
$event = $this->events->find($id);
|
||||||
if ($event === null) return Response::json(['error' => 'not_found'], 404);
|
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
|
||||||
return Response::json(['event' => $event, 'snapshots' => $this->snaps->forEvent($id)], 200);
|
return Response::json(['event' => $event, 'snapshots' => $this->snaps->forEvent($id)], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,6 +39,10 @@ final class EventDetailController
|
||||||
return Response::json(['error' => 'csrf'], 403);
|
return Response::json(['error' => 'csrf'], 403);
|
||||||
}
|
}
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
// Scope BEFORE the write: without the find()+guard a scoped user could acknowledge another
|
||||||
|
// site's event by guessing its id.
|
||||||
|
$event = $this->events->find($id);
|
||||||
|
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
|
||||||
$user = (string)$this->sess->get('username');
|
$user = (string)$this->sess->get('username');
|
||||||
if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404);
|
if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404);
|
||||||
$this->audit->append($user, 'acknowledge', "event:$id", [], []);
|
$this->audit->append($user, 'acknowledge', "event:$id", [], []);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,11 @@ final class EventListController
|
||||||
$severity = $req->query('severity');
|
$severity = $req->query('severity');
|
||||||
$limit = self::clampLimit($req->query('limit'));
|
$limit = self::clampLimit($req->query('limit'));
|
||||||
[$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor'));
|
[$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor'));
|
||||||
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId);
|
// Site-scope access control (P3-2b): a scoped operator sees only their site's events; a
|
||||||
|
// null/empty scope is a global operator (no site filter). Pushed into the SQL WHERE.
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
$site = (is_string($scope) && $scope !== '') ? $scope : null;
|
||||||
|
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site);
|
||||||
|
|
||||||
// Advertise a next cursor only on a full page — a short page means we hit the end.
|
// Advertise a next cursor only on a full page — a short page means we hit the end.
|
||||||
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
|
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ namespace App\Dashboard;
|
||||||
|
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\SessionStore;
|
use App\Auth\SessionStore;
|
||||||
use App\Repository\SnapshotRepository;
|
use App\Security\SiteScope;
|
||||||
|
use App\Repository\{SnapshotRepository, EventRepository};
|
||||||
|
|
||||||
final class SnapshotProxy
|
final class SnapshotProxy
|
||||||
{
|
{
|
||||||
public function __construct(private SnapshotRepository $snaps, private SessionStore $sess) {}
|
public function __construct(
|
||||||
|
private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess
|
||||||
|
) {}
|
||||||
|
|
||||||
public function serve(Request $req, array $params): Response
|
public function serve(Request $req, array $params): Response
|
||||||
{
|
{
|
||||||
|
|
@ -16,6 +19,17 @@ final class SnapshotProxy
|
||||||
$uuid = (string)($params['uuid'] ?? '');
|
$uuid = (string)($params['uuid'] ?? '');
|
||||||
$path = $this->snaps->pathByUuid($uuid);
|
$path = $this->snaps->pathByUuid($uuid);
|
||||||
if ($path === null || !is_file($path)) return Response::json(['error' => 'not_found'], 404);
|
if ($path === null || !is_file($path)) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
// Site-scope (P3-2b): a snapshot inherits its owning event's site; a scoped operator must
|
||||||
|
// not fetch another site's worker image (PIPA). 404, not 403, so it can't be used to probe
|
||||||
|
// which uuids exist. Global operators skip the lookup.
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
if (!SiteScope::isGlobal(is_string($scope) ? $scope : null)) {
|
||||||
|
$eventId = $this->snaps->eventIdByUuid($uuid);
|
||||||
|
$event = $eventId !== null ? $this->events->find($eventId) : null;
|
||||||
|
if ($event === null || !SiteScope::allows((string)$scope, $event['site'] ?? null)) {
|
||||||
|
return Response::json(['error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200);
|
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,12 @@ final class EventValidator
|
||||||
if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id';
|
if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id';
|
||||||
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
|
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
|
||||||
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
|
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
|
||||||
|
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
|
||||||
|
// it on retry so the cloud can dedup. Enforce the exact shape (matches CHAR(32) + the
|
||||||
|
// snapshot-uuid convention); a malformed key is a bug, not a silent pass.
|
||||||
|
if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) {
|
||||||
|
$errors[] = 'bad:event_uuid';
|
||||||
|
}
|
||||||
|
|
||||||
// Length caps matching the DB columns (device_id/camera_id VARCHAR(64), zone_id
|
// Length caps matching the DB columns (device_id/camera_id VARCHAR(64), zone_id
|
||||||
// CHAR(32)). Without these an overlong id is a MySQL 'Data too long' 500 that the edge
|
// CHAR(32)). Without these an overlong id is a MySQL 'Data too long' 500 that the edge
|
||||||
|
|
@ -42,6 +48,7 @@ final class EventValidator
|
||||||
if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []];
|
if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []];
|
||||||
|
|
||||||
$clean = [
|
$clean = [
|
||||||
|
'event_uuid' => isset($p['event_uuid']) ? (string)$p['event_uuid'] : null,
|
||||||
'device_id' => (string)$p['device_id'],
|
'device_id' => (string)$p['device_id'],
|
||||||
'camera_id' => (string)$p['camera_id'],
|
'camera_id' => (string)$p['camera_id'],
|
||||||
'event_type' => (string)$p['event_type'],
|
'event_type' => (string)$p['event_type'],
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,29 @@ final class IngestController
|
||||||
$v = EventValidator::validate($payload);
|
$v = EventValidator::validate($payload);
|
||||||
if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400);
|
if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400);
|
||||||
|
|
||||||
$eventId = $this->events->insert($v['clean']);
|
$clean = $v['clean'];
|
||||||
|
// Site denormalization (P3-2b): the dashboard filters events by site for access control,
|
||||||
|
// so stamp the DEVICE's site onto the event here. Server-derived from the authenticated
|
||||||
|
// device_id — a client-supplied site is never trusted.
|
||||||
|
$clean['site'] = $this->devices->siteFor($deviceId);
|
||||||
|
|
||||||
|
// Idempotency (P1-9): a POST can succeed server-side but time out client-side; the edge
|
||||||
|
// then re-queues and re-sends the SAME event_uuid. Return the already-stored event
|
||||||
|
// instead of inserting a duplicate alert.
|
||||||
|
$uuid = $clean['event_uuid'] ?? null;
|
||||||
|
if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) {
|
||||||
|
return Response::json(['event_id' => $existing, 'dedup' => true], 200);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$eventId = $this->events->insert($clean);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// Lost the insert race to a concurrent retry with the same uuid (the UNIQUE key
|
||||||
|
// fired). That is a successful dedup, not a 500 — return the winning row.
|
||||||
|
if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) {
|
||||||
|
return Response::json(['event_id' => $existing, 'dedup' => true], 200);
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
foreach (($payload['snapshots'] ?? []) as $b64) {
|
foreach (($payload['snapshots'] ?? []) as $b64) {
|
||||||
$this->storeSnapshot($eventId, (string)$b64);
|
$this->storeSnapshot($eventId, (string)$b64);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ namespace App\Repository;
|
||||||
interface DeviceRepository
|
interface DeviceRepository
|
||||||
{
|
{
|
||||||
public function secretFor(string $deviceId): ?string;
|
public function secretFor(string $deviceId): ?string;
|
||||||
|
/** The device's access-control site (devices.site), or null if unassigned/unknown. */
|
||||||
|
public function siteFor(string $deviceId): ?string;
|
||||||
public function touchHeartbeat(string $deviceId, array $meta): void;
|
public function touchHeartbeat(string $deviceId, array $meta): void;
|
||||||
public function configVersion(string $deviceId): int;
|
public function configVersion(string $deviceId): int;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,19 @@ namespace App\Repository;
|
||||||
interface EventRepository
|
interface EventRepository
|
||||||
{
|
{
|
||||||
public function insert(array $event): int;
|
public function insert(array $event): int;
|
||||||
|
/** The event_id of a previously-ingested event with this uuid, or null. Backs ingest
|
||||||
|
* idempotency (a re-sent event dedups instead of duplicating). */
|
||||||
|
public function idByUuid(string $uuid): ?int;
|
||||||
public function recent(int $limit, ?string $severity): array;
|
public function recent(int $limit, ?string $severity): array;
|
||||||
/**
|
/**
|
||||||
* Keyset (cursor) page over the (occurred_at DESC, event_id DESC) sort key.
|
* Keyset (cursor) page over the (occurred_at DESC, event_id DESC) sort key.
|
||||||
* Pass null/null for the first page; then the (occurred_at, event_id) of the last
|
* Pass null/null for the first page; then the (occurred_at, event_id) of the last
|
||||||
* returned row to fetch strictly older events. Stable under concurrent inserts —
|
* returned row to fetch strictly older events. Stable under concurrent inserts —
|
||||||
* new events land at the head and never shift a page window.
|
* new events land at the head and never shift a page window.
|
||||||
|
* $site (when non-null) restricts the page to events whose denormalized site matches —
|
||||||
|
* the SQL half of the site-scope access control (see App\Security\SiteScope).
|
||||||
*/
|
*/
|
||||||
public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId): array;
|
public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId, ?string $site = null): array;
|
||||||
public function find(int $id): ?array;
|
public function find(int $id): ?array;
|
||||||
public function acknowledge(int $id, string $user): bool;
|
public function acknowledge(int $id, string $user): bool;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,15 @@ final class PdoDeviceRepository implements DeviceRepository
|
||||||
return $secret === false ? null : (string)$secret;
|
return $secret === false ? null : (string)$secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function siteFor(string $deviceId): ?string
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare('SELECT site FROM devices WHERE device_id = ? LIMIT 1');
|
||||||
|
$st->execute([$deviceId]);
|
||||||
|
$site = $st->fetchColumn();
|
||||||
|
// false = no such device; null = device exists but unassigned. Both -> null (unscoped).
|
||||||
|
return ($site === false || $site === null) ? null : (string)$site;
|
||||||
|
}
|
||||||
|
|
||||||
public function touchHeartbeat(string $deviceId, array $meta): void
|
public function touchHeartbeat(string $deviceId, array $meta): void
|
||||||
{
|
{
|
||||||
// A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController)
|
// A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use PDO;
|
||||||
final class PdoEventRepository implements EventRepository
|
final class PdoEventRepository implements EventRepository
|
||||||
{
|
{
|
||||||
private const SELECT_COLUMNS =
|
private const SELECT_COLUMNS =
|
||||||
'event_id, device_id, camera_id, event_type, severity, track_id, zone_id,
|
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
|
||||||
occurred_at, duration_s, metrics_json, acknowledged_by, acknowledged_at';
|
occurred_at, duration_s, metrics_json, acknowledged_by, acknowledged_at';
|
||||||
|
|
||||||
public function __construct(private PDO $pdo) {}
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
@ -17,12 +17,14 @@ final class PdoEventRepository implements EventRepository
|
||||||
{
|
{
|
||||||
$st = $this->pdo->prepare(
|
$st = $this->pdo->prepare(
|
||||||
'INSERT INTO events
|
'INSERT INTO events
|
||||||
(device_id, camera_id, event_type, severity, track_id, zone_id,
|
(event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
|
||||||
occurred_at, duration_s, metrics_json, received_at)
|
occurred_at, duration_s, metrics_json, received_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
$st->execute([
|
$st->execute([
|
||||||
|
$event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy)
|
||||||
(string)$event['device_id'],
|
(string)$event['device_id'],
|
||||||
|
$event['site'] ?? null, // denormalized from devices.site by IngestController
|
||||||
(string)$event['camera_id'],
|
(string)$event['camera_id'],
|
||||||
(string)$event['event_type'],
|
(string)$event['event_type'],
|
||||||
(string)$event['severity'],
|
(string)$event['severity'],
|
||||||
|
|
@ -36,6 +38,14 @@ final class PdoEventRepository implements EventRepository
|
||||||
return (int)$this->pdo->lastInsertId();
|
return (int)$this->pdo->lastInsertId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function idByUuid(string $uuid): ?int
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare('SELECT event_id FROM events WHERE event_uuid = ? LIMIT 1');
|
||||||
|
$st->execute([$uuid]);
|
||||||
|
$id = $st->fetchColumn();
|
||||||
|
return $id === false ? null : (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
public function recent(int $limit, ?string $severity): array
|
public function recent(int $limit, ?string $severity): array
|
||||||
{
|
{
|
||||||
if ($severity !== null) {
|
if ($severity !== null) {
|
||||||
|
|
@ -58,7 +68,7 @@ final class PdoEventRepository implements EventRepository
|
||||||
return array_map(self::mapRow(...), $st->fetchAll());
|
return array_map(self::mapRow(...), $st->fetchAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId): array
|
public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId, ?string $site = null): array
|
||||||
{
|
{
|
||||||
// Bind list as [value, PDO type] pairs, positional in SQL order.
|
// Bind list as [value, PDO type] pairs, positional in SQL order.
|
||||||
$where = [];
|
$where = [];
|
||||||
|
|
@ -67,6 +77,11 @@ final class PdoEventRepository implements EventRepository
|
||||||
$where[] = 'severity = ?';
|
$where[] = 'severity = ?';
|
||||||
$binds[] = [$severity, PDO::PARAM_STR];
|
$binds[] = [$severity, PDO::PARAM_STR];
|
||||||
}
|
}
|
||||||
|
if ($site !== null) {
|
||||||
|
// Site-scope access control: a scoped operator sees only their site's events.
|
||||||
|
$where[] = 'site = ?';
|
||||||
|
$binds[] = [$site, PDO::PARAM_STR];
|
||||||
|
}
|
||||||
// Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id)
|
// Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id)
|
||||||
// on the (occurred_at DESC, event_id DESC) order. occurred_at is bound as a string so the
|
// on the (occurred_at DESC, event_id DESC) order. occurred_at is bound as a string so the
|
||||||
// DECIMAL comparison keeps full precision (no float round-trip in the driver).
|
// DECIMAL comparison keeps full precision (no float round-trip in the driver).
|
||||||
|
|
@ -119,7 +134,9 @@ final class PdoEventRepository implements EventRepository
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'event_id' => (int)$row['event_id'],
|
'event_id' => (int)$row['event_id'],
|
||||||
|
'event_uuid' => $row['event_uuid'] ?? null,
|
||||||
'device_id' => $row['device_id'],
|
'device_id' => $row['device_id'],
|
||||||
|
'site' => $row['site'] ?? null,
|
||||||
'camera_id' => $row['camera_id'],
|
'camera_id' => $row['camera_id'],
|
||||||
'event_type' => $row['event_type'],
|
'event_type' => $row['event_type'],
|
||||||
'severity' => $row['severity'],
|
'severity' => $row['severity'],
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,17 @@ final class PdoSnapshotRepository implements SnapshotRepository
|
||||||
return rtrim($this->snapshotDir, '/\\') . '/' . $uuid . '.jpg';
|
return rtrim($this->snapshotDir, '/\\') . '/' . $uuid . '.jpg';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function eventIdByUuid(string $uuid): ?int
|
||||||
|
{
|
||||||
|
if (strlen($uuid) !== 32 || !ctype_xdigit($uuid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$st = $this->pdo->prepare('SELECT event_id FROM event_snapshots WHERE file_uuid = ? LIMIT 1');
|
||||||
|
$st->execute([$uuid]);
|
||||||
|
$id = $st->fetchColumn();
|
||||||
|
return $id === false ? null : (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
public function purgeOlderThan(int $cutoff, int $limit = 500): int
|
public function purgeOlderThan(int $cutoff, int $limit = 500): int
|
||||||
{
|
{
|
||||||
// Bounded batch: a single request must not stall unlinking thousands of files on a
|
// Bounded batch: a single request must not stall unlinking thousands of files on a
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ interface SnapshotRepository
|
||||||
public function add(int $eventId, string $fileUuid, string $contentHash): void;
|
public function add(int $eventId, string $fileUuid, string $contentHash): void;
|
||||||
public function forEvent(int $eventId): array;
|
public function forEvent(int $eventId): array;
|
||||||
public function pathByUuid(string $uuid): ?string;
|
public function pathByUuid(string $uuid): ?string;
|
||||||
|
/** The event_id that owns this snapshot uuid, or null. Lets the proxy resolve a
|
||||||
|
* snapshot's site (via the owning event) for access control. */
|
||||||
|
public function eventIdByUuid(string $uuid): ?int;
|
||||||
|
|
||||||
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
|
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
|
||||||
* worker images must not be kept forever. Returns the number purged. */
|
* worker images must not be kept forever. Returns the number purged. */
|
||||||
|
|
|
||||||
29
cloud/src/Security/SiteScope.php
Normal file
29
cloud/src/Security/SiteScope.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row-level access control by site. A user's session carries `site_scope`
|
||||||
|
* (users.site_scope); a device/event carries `site` (devices.site, denormalized
|
||||||
|
* onto events.site at ingest).
|
||||||
|
*
|
||||||
|
* A NULL/empty caller scope is a GLOBAL operator — sees every site. A scoped
|
||||||
|
* caller sees only rows whose site matches exactly (a row with no site is never
|
||||||
|
* visible to a scoped caller). List queries push this into SQL; per-row reads
|
||||||
|
* (event detail, snapshot serve) call allows() before returning anything.
|
||||||
|
*/
|
||||||
|
final class SiteScope
|
||||||
|
{
|
||||||
|
public static function isGlobal(?string $callerScope): bool
|
||||||
|
{
|
||||||
|
return $callerScope === null || $callerScope === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function allows(?string $callerScope, ?string $rowSite): bool
|
||||||
|
{
|
||||||
|
if (self::isGlobal($callerScope)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $rowSite === $callerScope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,9 +69,59 @@ final class DashboardTest extends TestCase
|
||||||
}
|
}
|
||||||
public function testSnapshotProxyAuthAnd404(): void {
|
public function testSnapshotProxyAuthAnd404(): void {
|
||||||
$snaps = new InMemorySnapshotRepository();
|
$snaps = new InMemorySnapshotRepository();
|
||||||
$proxy = new SnapshotProxy($snaps, new ArraySessionStore()); // unauth
|
$events = new InMemoryEventRepository();
|
||||||
|
$proxy = new SnapshotProxy($snaps, $events, new ArraySessionStore()); // unauth
|
||||||
$this->assertSame(401, $proxy->serve(new Request('GET', '/snap/x', [], '', [], []), ['uuid' => 'x'])->status());
|
$this->assertSame(401, $proxy->serve(new Request('GET', '/snap/x', [], '', [], []), ['uuid' => 'x'])->status());
|
||||||
$proxy2 = new SnapshotProxy($snaps, $this->authed());
|
$proxy2 = new SnapshotProxy($snaps, $events, $this->authed());
|
||||||
$this->assertSame(404, $proxy2->serve(new Request('GET', '/snap/missing', [], '', [], []), ['uuid' => 'missing'])->status());
|
$this->assertSame(404, $proxy2->serve(new Request('GET', '/snap/missing', [], '', [], []), ['uuid' => 'missing'])->status());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authedSite(string $site): ArraySessionStore {
|
||||||
|
$s = $this->authed('operator'); $s->set('site_scope', $site); return $s;
|
||||||
|
}
|
||||||
|
public function testListIsScopedToTheCallersSite(): void {
|
||||||
|
$repo = new InMemoryEventRepository();
|
||||||
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1.0, 'site' => 'factory-a']);
|
||||||
|
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 2.0, 'site' => 'factory-b']);
|
||||||
|
// A factory-a operator sees only the factory-a event.
|
||||||
|
$scoped = new EventListController($repo, $this->authedSite('factory-a'));
|
||||||
|
$rows = json_decode($scoped->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
|
||||||
|
$this->assertCount(1, $rows);
|
||||||
|
$this->assertSame('factory-a', $rows[0]['site']);
|
||||||
|
// A global operator (no site_scope) sees both.
|
||||||
|
$global = new EventListController($repo, $this->authed('operator'));
|
||||||
|
$all = json_decode($global->json(new Request('GET', '/api/events', [], '', [], []))->body(), true)['events'];
|
||||||
|
$this->assertCount(2, $all);
|
||||||
|
}
|
||||||
|
public function testDetailHidesOtherSitesEvent(): void {
|
||||||
|
$repo = new InMemoryEventRepository();
|
||||||
|
$id = $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'site' => 'factory-b']);
|
||||||
|
$c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authedSite('factory-a'));
|
||||||
|
// 404 (not 403) so a cross-site id is indistinguishable from a non-existent one.
|
||||||
|
$this->assertSame(404, $c->show(new Request('GET', "/events/$id", [], '', [], []), ['id' => (string)$id])->status());
|
||||||
|
// The owning site sees it.
|
||||||
|
$own = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authedSite('factory-b'));
|
||||||
|
$this->assertSame(200, $own->show(new Request('GET', "/events/$id", [], '', [], []), ['id' => (string)$id])->status());
|
||||||
|
}
|
||||||
|
public function testSnapshotServeHidesOtherSitesImage(): void {
|
||||||
|
// A real on-disk file so the 404 can only come from the site guard, not a missing file.
|
||||||
|
$repo = new InMemoryEventRepository();
|
||||||
|
$id = $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'site' => 'factory-b']);
|
||||||
|
$file = sys_get_temp_dir() . '/snaptest' . bin2hex(random_bytes(4)) . '.jpg';
|
||||||
|
file_put_contents($file, 'jpegbytes');
|
||||||
|
$uuid = str_repeat('a', 32);
|
||||||
|
$snaps = new class($file, $id, $uuid) implements \App\Repository\SnapshotRepository {
|
||||||
|
public function __construct(private string $file, private int $eid, private string $uuid) {}
|
||||||
|
public function add(int $e, string $u, string $h): void {}
|
||||||
|
public function forEvent(int $e): array { return []; }
|
||||||
|
public function pathByUuid(string $u): ?string { return $u === $this->uuid ? $this->file : null; }
|
||||||
|
public function eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; }
|
||||||
|
public function purgeOlderThan(int $c): int { return 0; }
|
||||||
|
};
|
||||||
|
$cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a'));
|
||||||
|
$this->assertSame(404, $cross->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status());
|
||||||
|
$same = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-b'));
|
||||||
|
$this->assertSame(200, $same->serve(new Request('GET', '/s', [], '', [], []), ['uuid' => $uuid])->status());
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,22 @@ final class EventValidatorTest extends TestCase
|
||||||
$ok = $this->valid(); $ok['device_id'] = str_repeat('x', 64);
|
$ok = $this->valid(); $ok['device_id'] = str_repeat('x', 64);
|
||||||
$this->assertTrue(EventValidator::validate($ok)['ok']);
|
$this->assertTrue(EventValidator::validate($ok)['ok']);
|
||||||
}
|
}
|
||||||
|
public function testAcceptsAndCleansEventUuid(): void {
|
||||||
|
$p = $this->valid(); $p['event_uuid'] = str_repeat('a', 32);
|
||||||
|
$r = EventValidator::validate($p);
|
||||||
|
$this->assertTrue($r['ok'], implode(',', $r['errors']));
|
||||||
|
$this->assertSame(str_repeat('a', 32), $r['clean']['event_uuid']);
|
||||||
|
// Optional: omitting it is valid and clean carries null (legacy edges send no uuid).
|
||||||
|
$none = EventValidator::validate($this->valid());
|
||||||
|
$this->assertTrue($none['ok']);
|
||||||
|
$this->assertNull($none['clean']['event_uuid']);
|
||||||
|
}
|
||||||
|
public function testRejectsMalformedEventUuid(): void {
|
||||||
|
foreach (['XYZ', str_repeat('a', 31), str_repeat('A', 32), 'g' . str_repeat('a', 31)] as $bad) {
|
||||||
|
$p = $this->valid(); $p['event_uuid'] = $bad;
|
||||||
|
$this->assertFalse(EventValidator::validate($p)['ok'], "uuid '$bad' must be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
public function testRejectsUnknownEventType(): void {
|
public function testRejectsUnknownEventType(): void {
|
||||||
$p = $this->valid(); $p['event_type'] = 'explode';
|
$p = $this->valid(); $p['event_type'] = 'explode';
|
||||||
$this->assertFalse(EventValidator::validate($p)['ok']);
|
$this->assertFalse(EventValidator::validate($p)['ok']);
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ final class IngestControllerTest extends TestCase
|
||||||
public function add(int $e, string $u, string $h): void { throw new \RuntimeException('db down'); }
|
public function add(int $e, string $u, string $h): void { throw new \RuntimeException('db down'); }
|
||||||
public function forEvent(int $e): array { return []; }
|
public function forEvent(int $e): array { return []; }
|
||||||
public function pathByUuid(string $u): ?string { return null; }
|
public function pathByUuid(string $u): ?string { return null; }
|
||||||
|
public function eventIdByUuid(string $u): ?int { return null; }
|
||||||
public function purgeOlderThan(int $c): int { return 0; }
|
public function purgeOlderThan(int $c): int { return 0; }
|
||||||
};
|
};
|
||||||
$events = new InMemoryEventRepository();
|
$events = new InMemoryEventRepository();
|
||||||
|
|
@ -104,6 +105,36 @@ final class IngestControllerTest extends TestCase
|
||||||
'orphan snapshot file must be removed when the DB index write fails');
|
'orphan snapshot file must be removed when the DB index write fails');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDeduplicatesRepeatedEventUuid(): void {
|
||||||
|
// A POST that succeeded server-side but timed out client-side is re-sent with the SAME
|
||||||
|
// event_uuid (fresh nonce). The cloud must return the already-stored event, not duplicate.
|
||||||
|
[$c, $events] = $this->make();
|
||||||
|
$uuid = str_repeat('a', 32);
|
||||||
|
$p = $this->payload(); $p['event_uuid'] = $uuid;
|
||||||
|
$r1 = $c->handle($this->signedReq('sekret', $p, nonce: 'n1'), now: 1000, clientIp: '1.2.3.4');
|
||||||
|
$this->assertSame(201, $r1->status());
|
||||||
|
$r2 = $c->handle($this->signedReq('sekret', $p, nonce: 'n2'), now: 1000, clientIp: '1.2.3.4');
|
||||||
|
$this->assertSame(200, $r2->status());
|
||||||
|
$this->assertTrue(json_decode($r2->body(), true)['dedup']);
|
||||||
|
$this->assertCount(1, $events->rows, 'a re-sent event_uuid must not create a second event');
|
||||||
|
$this->assertSame(
|
||||||
|
json_decode($r1->body(), true)['event_id'],
|
||||||
|
json_decode($r2->body(), true)['event_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStampsDeviceSiteOntoTheEvent(): void {
|
||||||
|
// Access control filters events by site, so ingest denormalizes the DEVICE's site onto
|
||||||
|
// the event (server-derived from the authenticated device_id, not client-supplied).
|
||||||
|
$cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, 'rate_limit_per_min' => 60]);
|
||||||
|
$dev = new InMemoryDeviceRepository(['pi-1' => 'sekret'], [], ['pi-1' => 'factory-a']);
|
||||||
|
$events = new InMemoryEventRepository();
|
||||||
|
$c = new IngestController($cfg, $dev, new InMemoryNonceRepository(),
|
||||||
|
new InMemoryRateLimiter(), $events, new InMemorySnapshotRepository());
|
||||||
|
$c->handle($this->signedReq('sekret', $this->payload()), now: 1000, clientIp: '1.2.3.4');
|
||||||
|
$this->assertCount(1, $events->rows);
|
||||||
|
$this->assertSame('factory-a', $events->rows[1]['site']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testIpFloodRateLimited(): void {
|
public function testIpFloodRateLimited(): void {
|
||||||
// Attacker without a valid device secret floods from one IP; the pre-auth
|
// Attacker without a valid device secret floods from one IP; the pre-auth
|
||||||
// IP limiter must trip BEFORE any HMAC/device-secret work, protecting
|
// IP limiter must trip BEFORE any HMAC/device-secret work, protecting
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,11 @@ final class PdoIntegrationTest extends TestCase
|
||||||
public function testEventInsertFindAcknowledge(): void
|
public function testEventInsertFindAcknowledge(): void
|
||||||
{
|
{
|
||||||
$repo = new PdoEventRepository($this->pdo);
|
$repo = new PdoEventRepository($this->pdo);
|
||||||
|
$uuid = str_repeat('a', 32);
|
||||||
$id = $repo->insert([
|
$id = $repo->insert([
|
||||||
|
'event_uuid' => $uuid,
|
||||||
'device_id' => 'dev-1',
|
'device_id' => 'dev-1',
|
||||||
|
'site' => 'factory-a',
|
||||||
'camera_id' => 'cam-1',
|
'camera_id' => 'cam-1',
|
||||||
'event_type' => 'fall',
|
'event_type' => 'fall',
|
||||||
'severity' => 'critical',
|
'severity' => 'critical',
|
||||||
|
|
@ -63,10 +66,20 @@ final class PdoIntegrationTest extends TestCase
|
||||||
$found = $repo->find($id);
|
$found = $repo->find($id);
|
||||||
$this->assertNotNull($found);
|
$this->assertNotNull($found);
|
||||||
$this->assertSame('dev-1', $found['device_id']);
|
$this->assertSame('dev-1', $found['device_id']);
|
||||||
|
$this->assertSame('factory-a', $found['site']);
|
||||||
|
$this->assertSame($uuid, $found['event_uuid']);
|
||||||
$this->assertSame('critical', $found['severity']);
|
$this->assertSame('critical', $found['severity']);
|
||||||
$this->assertSame(['confidence' => 0.9], $found['metrics']);
|
$this->assertSame(['confidence' => 0.9], $found['metrics']);
|
||||||
$this->assertNull($found['acknowledged_by']);
|
$this->assertNull($found['acknowledged_by']);
|
||||||
|
|
||||||
|
// Idempotency key lookup (P1-9).
|
||||||
|
$this->assertSame($id, $repo->idByUuid($uuid));
|
||||||
|
$this->assertNull($repo->idByUuid(str_repeat('z', 32)));
|
||||||
|
|
||||||
|
// Site-scoped page (P3-2b): matches its own site, excluded from another.
|
||||||
|
$this->assertCount(1, $repo->page(10, null, null, null, 'factory-a'));
|
||||||
|
$this->assertCount(0, $repo->page(10, null, null, null, 'factory-b'));
|
||||||
|
|
||||||
$recent = $repo->recent(10, 'critical');
|
$recent = $repo->recent(10, 'critical');
|
||||||
$this->assertCount(1, $recent);
|
$this->assertCount(1, $recent);
|
||||||
$this->assertSame([], $repo->recent(10, 'info'));
|
$this->assertSame([], $repo->recent(10, 'info'));
|
||||||
|
|
@ -142,7 +155,9 @@ final class PdoIntegrationTest extends TestCase
|
||||||
|
|
||||||
CREATE TABLE events (
|
CREATE TABLE events (
|
||||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_uuid TEXT UNIQUE,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
|
site TEXT,
|
||||||
camera_id TEXT NOT NULL,
|
camera_id TEXT NOT NULL,
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
severity TEXT NOT NULL,
|
severity TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
|
||||||
|
|
||||||
final class InMemoryDeviceRepository implements DeviceRepository {
|
final class InMemoryDeviceRepository implements DeviceRepository {
|
||||||
public array $heartbeats = [];
|
public array $heartbeats = [];
|
||||||
public function __construct(private array $secrets = [], private array $versions = []) {}
|
public function __construct(private array $secrets = [], private array $versions = [],
|
||||||
|
private array $sites = []) {}
|
||||||
public function secretFor(string $d): ?string { return $this->secrets[$d] ?? null; }
|
public function secretFor(string $d): ?string { return $this->secrets[$d] ?? null; }
|
||||||
|
public function siteFor(string $d): ?string { return $this->sites[$d] ?? null; }
|
||||||
public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; }
|
public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; }
|
||||||
public function configVersion(string $d): int { return $this->versions[$d] ?? 1; }
|
public function configVersion(string $d): int { return $this->versions[$d] ?? 1; }
|
||||||
}
|
}
|
||||||
|
|
@ -24,14 +26,19 @@ final class InMemoryRateLimiter implements RateLimiterRepository {
|
||||||
final class InMemoryEventRepository implements EventRepository {
|
final class InMemoryEventRepository implements EventRepository {
|
||||||
public array $rows = []; private int $seq = 0;
|
public array $rows = []; private int $seq = 0;
|
||||||
public function insert(array $e): int { $id = ++$this->seq; $this->rows[$id] = $e + ['event_id' => $id, 'acknowledged_by' => null]; return $id; }
|
public function insert(array $e): int { $id = ++$this->seq; $this->rows[$id] = $e + ['event_id' => $id, 'acknowledged_by' => null]; return $id; }
|
||||||
|
public function idByUuid(string $uuid): ?int {
|
||||||
|
foreach ($this->rows as $id => $r) if (($r['event_uuid'] ?? null) === $uuid) return (int)$id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
public function recent(int $limit, ?string $sev): array {
|
public function recent(int $limit, ?string $sev): array {
|
||||||
$r = array_reverse($this->rows, true);
|
$r = array_reverse($this->rows, true);
|
||||||
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
|
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
|
||||||
return array_slice(array_values($r), 0, $limit);
|
return array_slice(array_values($r), 0, $limit);
|
||||||
}
|
}
|
||||||
public function page(int $limit, ?string $sev, ?float $beforeTs, ?int $beforeId): array {
|
public function page(int $limit, ?string $sev, ?float $beforeTs, ?int $beforeId, ?string $site = null): array {
|
||||||
$r = array_values($this->rows);
|
$r = array_values($this->rows);
|
||||||
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
|
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
|
||||||
|
if ($site !== null) $r = array_filter($r, fn($x) => ($x['site'] ?? null) === $site);
|
||||||
// (occurred_at DESC, event_id DESC); events inserted without occurred_at sort as 0.
|
// (occurred_at DESC, event_id DESC); events inserted without occurred_at sort as 0.
|
||||||
usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]);
|
usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]);
|
||||||
if ($beforeTs !== null && $beforeId !== null) {
|
if ($beforeTs !== null && $beforeId !== null) {
|
||||||
|
|
@ -48,6 +55,10 @@ final class InMemorySnapshotRepository implements SnapshotRepository {
|
||||||
public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time(); }
|
public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time(); }
|
||||||
public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
|
public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
|
||||||
public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
|
public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
|
||||||
|
public function eventIdByUuid(string $u): ?int {
|
||||||
|
foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
public function purgeOlderThan(int $cutoff): int {
|
public function purgeOlderThan(int $cutoff): int {
|
||||||
$n = 0;
|
$n = 0;
|
||||||
foreach ($this->created as $uuid => $ts) {
|
foreach ($this->created as $uuid => $ts) {
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,14 @@ misconfig/degradation fail LOUD.
|
||||||
forever at the head, blocking the whole queue. **Fix:** drop/quarantine after N failures.
|
forever at the head, blocking the whole queue. **Fix:** drop/quarantine after N failures.
|
||||||
DONE — `attempts` column; a row is dropped after `max_send_attempts` (default 20) so a
|
DONE — `attempts` column; a row is dropped after `max_send_attempts` (default 20) so a
|
||||||
poison head unwedges the queue; test locks the drain-past-poison behavior.
|
poison head unwedges the queue; test locks the drain-past-poison behavior.
|
||||||
- [ ] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
|
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
|
||||||
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
|
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
|
||||||
client-generated `event_id` in the payload; cloud dedups on it.
|
client-generated `event_id` in the payload; cloud dedups on it.
|
||||||
DEFERRED to the cloud-ingest batch (needs a schema column + IngestController dedup);
|
DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at
|
||||||
sequenced with Phase 4 / P4-7.
|
creation, preserved through the offline queue + every retry). Cloud: `events.event_uuid`
|
||||||
|
CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the
|
||||||
|
already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key.
|
||||||
|
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (cloud).
|
||||||
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
|
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
|
||||||
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
|
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
|
||||||
DONE — `frame_interval_from_env` validates; test_config.py locks it.
|
DONE — `frame_interval_from_env` validates; test_config.py locks it.
|
||||||
|
|
@ -128,13 +131,15 @@ misconfig/degradation fail LOUD.
|
||||||
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
|
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
|
||||||
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
|
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
|
||||||
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
|
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
|
||||||
- [ ] **P3-2b [M]** `cloud/src/Dashboard/EventListController.php:17` — `site_scope` is stored
|
- [x] **P3-2b [M]** `cloud/src/Dashboard/EventListController.php:17` — `site_scope` is stored
|
||||||
in the session but never enforced; any authenticated user sees every device's worker
|
in the session but never enforced; any authenticated user sees every device's worker
|
||||||
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
|
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
|
||||||
NEEDS A DECISION — the schema has `users.site_scope` (free string) but `devices` has NO site
|
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
|
||||||
column and `events` link only by `device_id`. Enforcing scope needs a site↔device model
|
`events.site` (server-derived, never client-supplied). `App\Security\SiteScope` centralizes the
|
||||||
(e.g. add `devices.site`, filter events by the caller's site). Deferred until that is
|
rule (NULL scope = global operator). The event LIST filters in SQL (`page(...,$site)`); event
|
||||||
decided. Interim mitigation: keep every dashboard account admin/single-site so scope is moot.
|
DETAIL + ACK and the SNAPSHOT proxy guard per-row and return 404 (not 403) cross-site so
|
||||||
|
existence never leaks. Migration ALTERs + provisioning UPDATEs documented in schema.sql.
|
||||||
|
Tests: list scoped, detail cross-site 404, snapshot cross-site 404 / same-site 200.
|
||||||
- [x] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref
|
- [x] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref
|
||||||
with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
|
with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
|
||||||
`fetch_hailo_hef.py`.
|
`fetch_hailo_hef.py`.
|
||||||
|
|
@ -247,6 +252,13 @@ misconfig/degradation fail LOUD.
|
||||||
|
|
||||||
(updated as each item lands — newest first)
|
(updated as each item lands — newest first)
|
||||||
|
|
||||||
|
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
|
||||||
|
P1-9: edge `event_uuid` idempotency key (stable through the queue) + cloud dedup on a UNIQUE
|
||||||
|
column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event
|
||||||
|
list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration +
|
||||||
|
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
|
||||||
|
P2-6/7/8 (safety-timing / tracking-match — deferred to on-hardware commissioning).
|
||||||
|
|
||||||
- **2026-07-17 — off-hardware solo work COMPLETE (35/43).** Landed this pass, each test-locked:
|
- **2026-07-17 — off-hardware solo work COMPLETE (35/43).** Landed this pass, each test-locked:
|
||||||
P4-8 (login CSRF), P4-6 (zone 400-not-500), P4-5 (cursor pagination), P4-7 (snapshot atomicity
|
P4-8 (login CSRF), P4-6 (zone 400-not-500), P4-5 (cursor pagination), P4-7 (snapshot atomicity
|
||||||
+ orphan cleanup), P5-3 (HEF in the offline bundle + fixed the broken build), P5-4 (pin + hash +
|
+ orphan cleanup), P5-3 (HEF in the offline bundle + fixed the broken build), P5-4 (pin + hash +
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from dataclasses import dataclass
|
import uuid as _uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,10 +50,15 @@ class Event:
|
||||||
duration_s: float
|
duration_s: float
|
||||||
metrics: dict
|
metrics: dict
|
||||||
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
|
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
|
||||||
|
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the
|
||||||
|
# offline queue and every retry, so a POST that succeeded server-side but timed out
|
||||||
|
# client-side is deduplicated by the cloud (see cloud IngestController) instead of stored twice.
|
||||||
|
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
|
||||||
|
|
||||||
def to_payload(self, device_id: str) -> dict:
|
def to_payload(self, device_id: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
|
"event_uuid": self.event_uuid,
|
||||||
"camera_id": self.camera_id,
|
"camera_id": self.camera_id,
|
||||||
"event_type": self.event_type.value,
|
"event_type": self.event_type.value,
|
||||||
"severity": self.severity.value,
|
"severity": self.severity.value,
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,15 @@ class OfflineQueue:
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]:
|
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]:
|
||||||
|
# Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the
|
||||||
|
# cloud dedups it. Rows queued before this field existed simply get a fresh uuid (they
|
||||||
|
# predate dedup, so a first-and-only delivery is correct for them).
|
||||||
|
uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {}
|
||||||
e = Event(EventType(payload["event_type"]), Severity(payload["severity"]),
|
e = Event(EventType(payload["event_type"]), Severity(payload["severity"]),
|
||||||
payload["camera_id"], payload["track_id"], payload["zone_id"],
|
payload["camera_id"], payload["track_id"], payload["zone_id"],
|
||||||
payload["occurred_at"], payload["duration_s"], payload["metrics"],
|
payload["occurred_at"], payload["duration_s"], payload["metrics"],
|
||||||
tuple(payload["spatial"]) if payload.get("spatial") else None)
|
tuple(payload["spatial"]) if payload.get("spatial") else None,
|
||||||
|
**uuid_kw)
|
||||||
return e, snaps
|
return e, snaps
|
||||||
|
|
||||||
def enqueue(self, event: Event, snapshots: list[bytes]) -> None:
|
def enqueue(self, event: Event, snapshots: list[bytes]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,26 @@ def test_queue_is_bounded_and_keeps_newest(tmp_path):
|
||||||
assert q.client.sent == [15, 16, 17, 18, 19] # the newest 5 survived
|
assert q.client.sent == [15, 16, 17, 18, 19] # the newest 5 survived
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_uuid_survives_the_queue_roundtrip(tmp_path):
|
||||||
|
# A re-sent event (failed once -> queued -> flushed) must carry the SAME event_uuid so the
|
||||||
|
# cloud dedups it instead of storing a duplicate alert. This is the whole point of the key.
|
||||||
|
class _CaptureClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.fail = True
|
||||||
|
self.uuids = []
|
||||||
|
def send(self, event, snapshots):
|
||||||
|
self.uuids.append(event.event_uuid)
|
||||||
|
return not self.fail
|
||||||
|
client = _CaptureClient()
|
||||||
|
q = OfflineQueue(str(tmp_path / "q.db"), client)
|
||||||
|
ev = _event(1)
|
||||||
|
q.try_send(ev, [b"x"]) # fails -> queued (1st send attempt)
|
||||||
|
client.fail = False
|
||||||
|
assert q.flush() == 1 # re-sent from the queue (2nd send attempt)
|
||||||
|
assert len(client.uuids) == 2
|
||||||
|
assert client.uuids[0] == client.uuids[1] == ev.event_uuid
|
||||||
|
|
||||||
|
|
||||||
def test_poison_head_is_dropped_and_unblocks_the_queue(tmp_path):
|
def test_poison_head_is_dropped_and_unblocks_the_queue(tmp_path):
|
||||||
# A permanently-rejected event at the head must not wedge the whole queue forever; after
|
# A permanently-rejected event at the head must not wedge the whole queue forever; after
|
||||||
# max_send_attempts it is dropped so the events behind it can drain.
|
# max_send_attempts it is dropped so the events behind it can drain.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue