Files
Behavision/server/migrations/002_auth.sql
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

95 lines
4.7 KiB
PL/PgSQL

-- Migration 002: who is allowed to ask, and what a fresh PC is told.
--
-- 001 built the data. Nothing could read it: the only writer was the MQTT
-- consumer, authenticated by the broker. This adds the request/response half —
-- staff logging in from the desktop app, and a newly installed store PC
-- collecting its own broker credentials.
BEGIN;
-- ============================================================ sessions =====
-- Opaque tokens in a table, not JWTs.
--
-- A JWT cannot be revoked without a blocklist, which is a session table with
-- extra steps and worse failure modes. This system holds biometric data on
-- shop-floor PCs that get lost, resold and shared between staff, so "log that
-- device out, now" has to actually work. At this volume the lookup is one
-- indexed read.
--
-- Only the SHA-256 of each token is stored. A database dump then contains no
-- usable session — and SHA-256 rather than bcrypt because the token is 256
-- bits from crypto/rand, so there is no dictionary to slow an attacker down
-- through, only a per-request cost to pay.
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
-- Denormalised from app_users so the auth lookup is one row, and so a
-- session cannot outlive a user being moved between clients.
client_id uuid REFERENCES clients(id) ON DELETE CASCADE,
access_hash bytea NOT NULL UNIQUE,
refresh_hash bytea NOT NULL UNIQUE,
access_expires_at timestamptz NOT NULL,
refresh_expires_at timestamptz NOT NULL,
revoked_at timestamptz,
-- Enough to tell one device from another in a session list. Not an
-- identifier, and deliberately not an IP address: a shop's IP tells us
-- where a customer's staff live, which we have no reason to keep.
device text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz
);
CREATE INDEX sessions_user_idx ON sessions (user_id) WHERE revoked_at IS NULL;
CREATE INDEX sessions_expiry_idx ON sessions (refresh_expires_at);
-- ============================================================ enrolment ====
-- A one-shot token that turns an anonymous install into a known site.
--
-- The installer ships with no credentials at all, so a leaked build hands out
-- nothing. The operator types this code once; the server answers with the
-- broker credentials for exactly one site and marks the token spent.
CREATE TABLE site_enrolment_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
token_hash bytea NOT NULL UNIQUE,
label text NOT NULL DEFAULT '',
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX site_enrolment_site_idx ON site_enrolment_tokens (site_id);
-- The broker password this site publishes with, encrypted with the server's
-- own key (AES-GCM, BEHAVISION_SECRET_KEY).
--
-- It has to be recoverable, not hashed: enrolment HANDS IT OUT. Encrypting it
-- means a stolen database dump is not a set of live broker logins, which a
-- plaintext column would be. Mosquitto keeps its own hashed copy in its passwd
-- file; this is the second half of a pair that must be provisioned together.
ALTER TABLE agents ADD COLUMN mqtt_password_enc bytea;
-- ============================================================ health =======
-- Reported by the heartbeat, kept on the agent row because they describe the
-- site right now, not a history worth querying.
--
-- fraction_below_gate is the one that matters: the share of faces this site's
-- cameras saw that fell under the enrolment gate. A footfall number from a
-- badly placed camera is wrong in a way nobody can see from the number itself,
-- so the figure has to travel with its own confidence. Measured on the Office1
-- camera it was 0.727 — 73% of visitors seen and discarded, while the report
-- said what looked like a quiet week.
ALTER TABLE agents ADD COLUMN fraction_below_gate real;
ALTER TABLE agents ADD COLUMN cameras_total integer NOT NULL DEFAULT 0;
ALTER TABLE agents ADD COLUMN cameras_up integer NOT NULL DEFAULT 0;
ALTER TABLE agents ADD COLUMN spool_queued integer NOT NULL DEFAULT 0;
-- Events a site lost because it was offline long enough to overflow its own
-- queue. Cumulative and never reset: a footfall figure with known-lost events
-- behind it must say so.
ALTER TABLE agents ADD COLUMN spool_dropped bigint NOT NULL DEFAULT 0;
COMMIT;