[feat](alarm): let devices subscribe without an account, with a token this service mints
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.
This commit is contained in:
+63
-7
@@ -25,6 +25,12 @@ import (
|
||||
)
|
||||
|
||||
// Subscription is one alarm.subscriptions row: a device that receives the alarm.
|
||||
//
|
||||
// A subscription is the SUBSCRIBER IDENTITY (contract rev 10). Either it hangs
|
||||
// off an account (`SubscriberUserID` set, the pre-rev-10 shape) or it stands
|
||||
// alone with a device token the service minted at join (`DeviceTokenHash` set,
|
||||
// `SubscriberUserID` is uuid.Nil). A subscriber is therefore never a fake user:
|
||||
// there is no auth.users row behind an anonymous one.
|
||||
type Subscription struct {
|
||||
ID uuid.UUID
|
||||
AlertID uuid.UUID
|
||||
@@ -35,15 +41,22 @@ type Subscription struct {
|
||||
Enabled bool
|
||||
LastSeenAt *time.Time
|
||||
Created time.Time
|
||||
// DeviceTokenHash is the SHA-256 of the token handed to the device once, at
|
||||
// join. Nil for account-backed subscriptions.
|
||||
DeviceTokenHash *string
|
||||
}
|
||||
|
||||
const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label,
|
||||
platform, enabled, last_seen_at, created`
|
||||
platform, enabled, last_seen_at, created, device_token_hash`
|
||||
|
||||
func scanSubscription(row pgx.Row) (*Subscription, error) {
|
||||
var s Subscription
|
||||
err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created)
|
||||
var user *uuid.UUID
|
||||
err := row.Scan(&s.ID, &s.AlertID, &user, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created, &s.DeviceTokenHash)
|
||||
if user != nil {
|
||||
s.SubscriberUserID = *user
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -65,19 +78,24 @@ func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, er
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var actor *uuid.UUID
|
||||
if s.SubscriberUserID != uuid.Nil {
|
||||
actor = &s.SubscriberUserID
|
||||
}
|
||||
|
||||
const q = `INSERT INTO alarm.subscriptions
|
||||
(alert_id, subscriber_user_id, device_id, device_label, platform)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
(alert_id, subscriber_user_id, device_id, device_label, platform, device_token_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING ` + subscriptionColumns
|
||||
row, err := scanSubscription(tx.QueryRow(ctx, q,
|
||||
s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform))
|
||||
s.AlertID, actor, s.DeviceID, s.DeviceLabel, s.Platform, s.DeviceTokenHash))
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := recordEvent(ctx, tx, s.AlertID, &s.SubscriberUserID, EventJoined, ""); err != nil {
|
||||
if err := recordEvent(ctx, tx, s.AlertID, actor, EventJoined, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
@@ -94,6 +112,44 @@ func GetSubscriptionByDevice(ctx context.Context, alertID, userID uuid.UUID, dev
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID))
|
||||
}
|
||||
|
||||
// FindSubscriptionByDevice looks a subscription up by alert and device digest,
|
||||
// whichever path created it. It is the idempotency lookup for an anonymous
|
||||
// join, where there is no user id to key on.
|
||||
func FindSubscriptionByDevice(ctx context.Context, alertID uuid.UUID, deviceID string) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions
|
||||
WHERE alert_id = $1 AND device_id = $2
|
||||
ORDER BY created
|
||||
LIMIT 1`
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, deviceID))
|
||||
}
|
||||
|
||||
// GetSubscriptionByToken resolves the subscriber behind a device token. The
|
||||
// token itself is never stored: the caller hashes what it received and this
|
||||
// lookup runs against the unique index.
|
||||
func GetSubscriptionByToken(ctx context.Context, tokenHash string) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions
|
||||
WHERE device_token_hash = $1`
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, tokenHash))
|
||||
}
|
||||
|
||||
// RotateDeviceToken replaces the token hash of a subscription. A re-join from
|
||||
// the same device rotates rather than inserts, so a lost token is recoverable
|
||||
// with the subscribe token while the old one stops working immediately.
|
||||
func RotateDeviceToken(ctx context.Context, subscriptionID uuid.UUID, tokenHash string) error {
|
||||
tag, err := Pool().Exec(ctx,
|
||||
`UPDATE alarm.subscriptions SET device_token_hash = $2 WHERE id = $1`,
|
||||
subscriptionID, tokenHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSubscription fetches one subscription scoped to its alert.
|
||||
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
|
||||
Reference in New Issue
Block a user