Receiving an alarm should not require signing up, and it should not require pretending to be a user either: an anonymous subscriber is not an account, it is a subscription. The row is the identity. joinAlert now takes NO access token. The subscribe token from the QR is the authorisation, and the service answers with a deviceToken (32 CSPRNG bytes, base64url, stored only as SHA-256) returned exactly once. Later subscriber calls send it as X-Device-Token (?deviceToken= on the WebSocket upgrade, which cannot set headers): getAlert, alarmStatus, ackAlarm, removeSubscription, qrImage and GET /ws/alarm. The credential is deliberately narrow. It names one subscription on one alert, so an ack addresses the ledger row directly and 'am I subscribed' is a field comparison. createAlert/triggerAlert/disarmAlert/deleteAlert/listSubscriptions never see it, so they keep answering 401 for a subscriber without any new gate: owning an alert still needs an account, and the admin token stays the only secret that can ring or silence one. Re-joining from the same device rotates the token (one row per alert+device), so a lost credential is recoverable with the QR and a leaked one dies on the next join. ALARM_ANONYMOUS=false puts joinAlert back behind login and stores the subscription against the account, exactly like rev 9. Migration 010 is additive and idempotent: subscriber_user_id becomes nullable, device_token_hash gets a unique index, and alarm_events.actor_user_id admits NULL because an anonymous join logs an actor-less event. Verified through Maestro against the dev stack (full table in planning/platform/anonymous-access/plan.md): join with no token -> 200 and the auth.users count does not move; getAlert/alarmStatus/ackAlarm with the token; trigger -> 409 with the frame -> ack acked:1 destroyed:true; WS 101 with both credentials (403 wrong subscribe token, 401 bad device token); old token 401 after a re-join; admin actions 401.
76 lines
3.4 KiB
SQL
76 lines
3.4 KiB
SQL
-- Copyright (C) 2026 Dcntrlized
|
|
--
|
|
-- This program is free software: you can redistribute it and/or modify
|
|
-- it under the terms of the GNU Affero General Public License as published
|
|
-- by the Free Software Foundation, either version 3 of the License, or
|
|
-- (at your option) any later version.
|
|
--
|
|
-- This program is distributed in the hope that it will be useful,
|
|
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
-- GNU Affero General Public License for more details.
|
|
--
|
|
-- You should have received a copy of the GNU Affero General Public License
|
|
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
-- 010_device_tokens.sql — subscriber authentication without an account.
|
|
--
|
|
-- Contract rev 10. A subscriber is not a user: the subscription row IS the
|
|
-- identity, and a device token minted by THIS service at join time
|
|
-- authenticates it. No auth.users row, no synthetic email, no hub round-trip —
|
|
-- an unauthenticated path, not a fake account.
|
|
--
|
|
-- What the token proves is narrow and sufficient: "this device is subscribed to
|
|
-- this alert". It authorises reading the alert's public shape, the alarm
|
|
-- check-in, acking its own debt and removing its own subscription. It can never
|
|
-- create, trigger, disarm or delete an alert — those need the admin token, which
|
|
-- a subscriber is never given.
|
|
--
|
|
-- Additive and idempotent: accounts keep working exactly as before (a logged-in
|
|
-- user's join still records subscriber_user_id and needs no device token).
|
|
\c dcntrlized
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- A device identity has no account behind it.
|
|
ALTER TABLE alarm.subscriptions
|
|
ALTER COLUMN subscriber_user_id DROP NOT NULL;
|
|
|
|
-- SHA-256 (hex) of the device token. Never the token itself: a database
|
|
-- read must not hand out a credential. NULL for account-backed rows.
|
|
ALTER TABLE alarm.subscriptions
|
|
ADD COLUMN IF NOT EXISTS device_token_hash CHAR(64);
|
|
|
|
-- The event log records who acted. An anonymous subscriber may act (joining
|
|
-- is logged), and there is no user id to write, so the column must admit
|
|
-- NULL. Trigger/disarm stay account actions and always carry an actor.
|
|
ALTER TABLE alarm.alarm_events
|
|
ALTER COLUMN actor_user_id DROP NOT NULL;
|
|
|
|
-- One subscription per device token: the hash is the lookup key on every
|
|
-- subscriber request, so a collision would be ambiguous by construction.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_device_token_idx
|
|
ON alarm.subscriptions (device_token_hash)
|
|
WHERE device_token_hash IS NOT NULL;
|
|
|
|
-- One device is one subscription per alert, whichever path created it. The
|
|
-- pre-rev-10 unique key included the user id, so existing data cannot carry
|
|
-- duplicates in practice; the guard keeps a surprise from failing a
|
|
-- migration on a live instance (the join path rotates rather than inserts).
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM alarm.subscriptions
|
|
GROUP BY alert_id, device_id
|
|
HAVING count(*) > 1
|
|
) THEN
|
|
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_alert_device_idx
|
|
ON alarm.subscriptions (alert_id, device_id);
|
|
ELSE
|
|
RAISE NOTICE 'alarm.subscriptions has duplicate (alert_id, device_id) rows; skipping the unique index';
|
|
END IF;
|
|
END $$;
|