[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:
2026-10-19 18:37:17 +01:00
parent 914fd6570d
commit 0e24c4e4f0
15 changed files with 777 additions and 98 deletions
+52 -19
View File
@@ -54,7 +54,7 @@ type ackAlarmResponse struct {
// alert no longer exists, which is the intended outcome, not a failure.
func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
const action = "ackAlarm"
p, ok := principal(w, r, action)
c, ok := callerFor(w, r, action)
if !ok {
return
}
@@ -79,34 +79,67 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
if c.IsDevice {
if !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := isAdminOrSubscriber(r, alert, c.UserID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
}
resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()}
// The owner has no delivery row (only subscribers are owed alarms), so an
// owner ack is a well-formed no-op rather than a validation error.
if alert.AdminUserID != p.ID {
raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId"))
if err != nil {
validationError(w, "%v", err)
return
}
cleared, err := db.AckAlarmDelivery(r.Context(), alertID, p.ID, deviceid.Hash(raw), eventID)
// A device token lives on the subscription, so the ledger row is addressed
// directly: no user id, no device-id echo, nothing to spoof.
if c.IsDevice {
cleared, err := db.AckDelivery(r.Context(), alertID, c.Sub.ID, eventID)
if err != nil {
dbError(w, action, err)
return
}
resp.Acked = cleared
if cleared > 0 {
if err := db.RecordDelivered(r.Context(), alertID, p.ID); err != nil {
if err := db.TouchSubscription(r.Context(), c.Sub.ID); err != nil {
dbError(w, action, err)
return
}
destroyed, err := db.CompleteAlertDeliveries(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
resp.Destroyed = destroyed
}
Debug(action, "POST", "alert=%s event=%s acked=%d destroyed=%t (device)", alertID, eventID, resp.Acked, resp.Destroyed)
writeJSON(w, http.StatusOK, resp)
return
}
// The owner has no delivery row (only subscribers are owed alarms), so an
// owner ack is a well-formed no-op rather than a validation error.
if alert.AdminUserID != c.UserID {
raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId"))
if err != nil {
validationError(w, "%v", err)
return
}
cleared, err := db.AckAlarmDelivery(r.Context(), alertID, c.UserID, deviceid.Hash(raw), eventID)
if err != nil {
dbError(w, action, err)
return
}
resp.Acked = cleared
if cleared > 0 {
if err := db.RecordDelivered(r.Context(), alertID, c.UserID); err != nil {
dbError(w, action, err)
return
}
+19 -10
View File
@@ -281,7 +281,7 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) {
// admin and by any subscribed user.
func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "getAlert"
p, ok := principal(w, r, action)
c, ok := callerFor(w, r, action)
if !ok {
return
}
@@ -296,16 +296,25 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err)
return
}
isAdmin := res.Alert.AdminUserID == p.ID
isAdmin := !c.IsDevice && res.Alert.AdminUserID == c.UserID
if !isAdmin {
allowed, err := db.IsSubscriber(r.Context(), alertID, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
if c.IsDevice {
// The device token names exactly one subscription, so "subscribed"
// is a field comparison rather than a query.
if !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := db.IsSubscriber(r.Context(), alertID, c.UserID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
}
}
count, err := db.CountSubscriptions(r.Context(), alertID)
+151
View File
@@ -0,0 +1,151 @@
// 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/>.
package actions
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"github.com/google/uuid"
)
// rev 10: an anonymous subscriber is a device credential, never a fake user.
func TestMintDeviceTokenIsRandomAndURLSafe(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 64; i++ {
tok, err := mintDeviceToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
if len(tok) != 43 {
t.Fatalf("token length = %d, want 43 (32 bytes base64url)", len(tok))
}
if strings.ContainsAny(tok, "+/=") {
t.Fatalf("token %q is not base64url", tok)
}
if seen[tok] {
t.Fatalf("mint repeated a token")
}
seen[tok] = true
}
}
func TestHashDeviceTokenIsStableSHA256Hex(t *testing.T) {
const tok = "device-token-under-test"
first := hashDeviceToken(tok)
if len(first) != 64 {
t.Fatalf("hash length = %d, want 64 hex", len(first))
}
if first != hashDeviceToken(tok) {
t.Fatalf("hash is not deterministic")
}
if first == hashDeviceToken(tok+"x") {
t.Fatalf("different tokens hashed the same")
}
}
func TestAnonymousEnabledSwitch(t *testing.T) {
cases := map[string]bool{
"": true,
"true": true,
"1": true,
"yes": true,
"on": true,
"false": false,
"0": false,
"no": false,
"off": false,
" OFF ": false,
"maybe": true,
}
for value, want := range cases {
t.Setenv("ALARM_ANONYMOUS", value)
if got := anonymousEnabled(); got != want {
t.Errorf("anonymousEnabled(%q) = %v, want %v", value, got, want)
}
}
}
func TestDeviceTokenReadsHeaderThenQuery(t *testing.T) {
r := httptest.NewRequest("GET", "/action/getAlert?deviceToken=from-query", nil)
if got := deviceToken(r); got != "from-query" {
t.Fatalf("query token = %q", got)
}
r = httptest.NewRequest("GET", "/action/getAlert?deviceToken=from-query", nil)
r.Header.Set(deviceTokenHeader, "from-header")
if got := deviceToken(r); got != "from-header" {
t.Fatalf("header token = %q", got)
}
r = httptest.NewRequest("GET", "/action/getAlert", nil)
if got := deviceToken(r); got != "" {
t.Fatalf("absent token = %q", got)
}
}
func TestCallerSubscribedToIsScopedToItsAlert(t *testing.T) {
alertID := uuid.New()
other := uuid.New()
device := &caller{IsDevice: true, Sub: &db.Subscription{AlertID: alertID}}
if !device.subscribedTo(alertID) {
t.Fatalf("device should be subscribed to its own alert")
}
if device.subscribedTo(other) {
t.Fatalf("a device token must not reach another alert")
}
// An account caller has no subscription object; the account check is done by
// the caller's own isAdminOrSubscriber path.
account := &caller{UserID: uuid.New()}
if account.subscribedTo(alertID) {
t.Fatalf("an account caller is not a device subscription")
}
}
func TestJoinResponseCarriesTokenButNoUserIdForANonAccount(t *testing.T) {
alertID, subID := uuid.New(), uuid.New()
raw, err := json.Marshal(joinResponse{
subscriptionView: newSubscriptionView(&db.Subscription{
ID: subID, AlertID: alertID, DeviceID: "hashed",
}),
DeviceToken: "tok",
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded["deviceToken"] != "tok" {
t.Fatalf("deviceToken missing from join response: %s", raw)
}
if _, present := decoded["userId"]; present {
t.Fatalf("an anonymous subscription must not report a userId: %s", raw)
}
// An account-backed subscription still reports it.
withUser := newSubscriptionView(&db.Subscription{
ID: subID, AlertID: alertID, SubscriberUserID: uuid.New(), DeviceID: "hashed",
})
if withUser.UserID == "" {
t.Fatalf("an account subscription must report its userId")
}
}
+102
View File
@@ -0,0 +1,102 @@
// 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/>.
package actions
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"github.com/google/uuid"
)
// deviceTokenHeader carries the subscriber credential on every request that
// does not come from an account. The query fallback exists for the WebSocket
// upgrade, which cannot set headers.
const deviceTokenHeader = "X-Device-Token"
// caller is the resolved identity of a subscriber-side request. Exactly one of
// the two shapes is populated:
//
// account — UserID set, Sub nil (a logged-in user, rev 9 behaviour)
// device — Sub set, IsDevice true (an anonymous subscriber, rev 10)
type caller struct {
UserID uuid.UUID
Sub *db.Subscription
IsDevice bool
}
// deviceToken reads the device credential from the header, then the query.
func deviceToken(r *http.Request) string {
if tok := strings.TrimSpace(r.Header.Get(deviceTokenHeader)); tok != "" {
return tok
}
return strings.TrimSpace(r.URL.Query().Get("deviceToken"))
}
// callerFor resolves the two credential kinds a subscriber-side action accepts.
// A device token is checked first: it is the cheaper path (one indexed lookup,
// no hub round-trip) and it must win when both are present, because a device
// credential is narrower than an account.
func callerFor(w http.ResponseWriter, r *http.Request, action string) (*caller, bool) {
if tok := deviceToken(r); tok != "" {
sub, err := db.GetSubscriptionByToken(r.Context(), hashDeviceToken(tok))
if err != nil {
if errors.Is(err, db.ErrNotFound) {
Warn(action, "auth", "unknown device token")
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "invalid device token")
return nil, false
}
dbError(w, action, err)
return nil, false
}
return &caller{Sub: sub, IsDevice: true}, true
}
p, ok := principal(w, r, action)
if !ok {
return nil, false
}
return &caller{UserID: p.ID}, true
}
// subscribedTo reports whether the caller may read the alert's subscriber
// surface. A device token is scoped to exactly one alert; an account is checked
// against the alert's admin and subscription rows by the caller.
func (c *caller) subscribedTo(alertID uuid.UUID) bool {
return c.IsDevice && c.Sub != nil && c.Sub.AlertID == alertID
}
// mintDeviceToken produces the 256-bit credential a device receives once, at
// join. base64url keeps it header-safe.
func mintDeviceToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// hashDeviceToken is the only transform applied to a device token: plain
// SHA-256 of 32 random bytes. The database never sees the token itself.
func hashDeviceToken(tok string) string {
sum := sha256.Sum256([]byte(tok))
return hex.EncodeToString(sum[:])
}
+12
View File
@@ -27,6 +27,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
@@ -108,6 +109,17 @@ func principal(w http.ResponseWriter, r *http.Request, action string) (*authclie
return p, true
}
// anonymousEnabled reports whether this instance accepts device-token joins.
// Default on; ALARM_ANONYMOUS=false/0/no/off makes joinAlert require an account
// again (subscriber-only access needs a login, exactly like rev 9).
func anonymousEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("ALARM_ANONYMOUS"))) {
case "false", "0", "no", "off":
return false
}
return true
}
// decodePayload unmarshals the {payload} wrapper's inner value into dst.
//
// Unknown fields are deliberately ignored: the clients are shipped separately
+39 -17
View File
@@ -187,7 +187,7 @@ type statusView struct {
// background job is needed to expire anything.
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
const action = "alarmStatus"
p, ok := principal(w, r, action)
c, ok := callerFor(w, r, action)
if !ok {
return
}
@@ -202,14 +202,21 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
if c.IsDevice {
if !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := isAdminOrSubscriber(r, res.Alert, c.UserID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
}
// Offline check-in, READ-ONLY. If this device still owes an alarm, answer
@@ -218,14 +225,29 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
// 409 lost in transit must not forfeit the alarm: the next check-in has to
// find the same row. A trigger that has since been disarmed is not news, so
// that case falls through to the normal body (active:false).
deviceHash := ""
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
deviceHash = deviceid.Hash(raw)
}
pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash)
if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err)
return
var pending *db.PendingDelivery
if c.IsDevice {
// The device token already identifies the subscription, so its whole
// pending set is the debt to report.
rows, err := db.PendingReplay(r.Context(), alertID, c.Sub.ID)
if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err)
return
}
if len(rows) > 0 {
pending = &rows[0]
}
} else {
deviceHash := ""
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
deviceHash = deviceid.Hash(raw)
}
var err error
pending, err = db.PendingTrigger(r.Context(), alertID, c.UserID, deviceHash)
if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err)
return
}
}
if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger {
writeOfflineAlarm(w, res.Alert, pending)
+16 -9
View File
@@ -40,7 +40,7 @@ const (
// through createAlert/qrPayload, the subscriber through the token they scanned).
func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
const action = "qrImage"
p, ok := principal(w, r, action)
c, ok := callerFor(w, r, action)
if !ok {
return
}
@@ -55,14 +55,21 @@ func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
if c.IsDevice {
if !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := isAdminOrSubscriber(r, alert, c.UserID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
}
scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale)
+104 -26
View File
@@ -28,7 +28,7 @@ import (
type subscriptionView struct {
ID string `json:"id"`
UserID string `json:"userId"`
UserID string `json:"userId,omitempty"`
Username string `json:"username,omitempty"`
DeviceLabel string `json:"deviceLabel,omitempty"`
Platform string `json:"platform,omitempty"`
@@ -52,19 +52,33 @@ type subscriptionView struct {
// schema, and the Alarm service does not read another service's tables. The
// field is omitted rather than sent empty.
func newSubscriptionView(s *db.Subscription) subscriptionView {
return subscriptionView{
view := subscriptionView{
ID: s.ID.String(),
UserID: s.SubscriberUserID.String(),
DeviceLabel: deref(s.DeviceLabel),
Platform: deref(s.Platform),
Enabled: s.Enabled,
LastSeenAt: rfc3339Ptr(s.LastSeenAt),
Created: rfc3339(s.Created),
}
// An anonymous subscriber has no account behind it, so there is no user id
// to report (the zero uuid would be a lie, not an empty value).
if s.SubscriberUserID != uuid.Nil {
view.UserID = s.SubscriberUserID.String()
}
return view
}
// ---- joinAlert --------------------------------------------------------------
// joinResponse is the subscription plus the device credential. The token is
// returned ONCE, on the call that created (or rotated) it; there is no way to
// fetch it again, which is why losing the app's storage means losing the
// subscriber identity.
type joinResponse struct {
subscriptionView
DeviceToken string `json:"deviceToken,omitempty"`
}
type joinRequest struct {
AlertID string `json:"alertId"`
SubscribeToken string `json:"subscribeToken"`
@@ -82,10 +96,6 @@ type joinRequest struct {
// race, where two inserts for the same device hit the unique index at once.
func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "joinAlert"
p, ok := principal(w, r, action)
if !ok {
return
}
var req joinRequest
if err := decodePayload(payload, &req); err != nil {
@@ -126,34 +136,62 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err)
return
}
// The subscribe token is the only thing that authorises a join: it is the
// proof that the caller was shown the QR. No account, no device identity —
// this is the unauthenticated path, not a fake user.
if !alarmtoken.EqualToken(alert.SubscribeToken, strings.TrimSpace(req.SubscribeToken)) {
Warn(action, "auth", "subscribe token mismatch alert=%s user=%s", alert.ID, p.ID)
Warn(action, "auth", "subscribe token mismatch alert=%s", alert.ID)
writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token")
return
}
existing, err := db.GetSubscriptionByDevice(r.Context(), alertID, p.ID, storedDeviceID)
// An operator can switch anonymous access off; then a join is an account
// action again and the subscription is owned by that user.
if !anonymousEnabled() {
joinAsAccount(w, r, action, alert, storedDeviceID, label, platform)
return
}
// Re-join from a known device: rotate the credential instead of creating a
// second row. The old token stops working immediately, which is what makes
// a lost/leaked device token recoverable with the subscribe token alone.
existing, err := db.FindSubscriptionByDevice(r.Context(), alertID, storedDeviceID)
switch {
case err == nil:
Debug(action, "POST", "idempotent re-join alert=%s sub=%s", alertID, existing.ID)
writeJSON(w, http.StatusOK, newSubscriptionView(existing))
token, terr := mintDeviceToken()
if terr != nil {
dbError(w, action, terr)
return
}
if err := db.RotateDeviceToken(r.Context(), existing.ID, hashDeviceToken(token)); err != nil {
dbError(w, action, err)
return
}
Debug(action, "POST", "re-join alert=%s sub=%s (token rotated)", alertID, existing.ID)
writeJSON(w, http.StatusOK, joinResponse{newSubscriptionView(existing), token})
return
case !errors.Is(err, db.ErrNotFound):
dbError(w, action, err)
return
}
token, err := mintDeviceToken()
if err != nil {
dbError(w, action, err)
return
}
tokenHash := hashDeviceToken(token)
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
AlertID: alertID,
SubscriberUserID: p.ID,
DeviceID: storedDeviceID,
DeviceLabel: label,
Platform: platform,
AlertID: alertID,
DeviceID: storedDeviceID,
DeviceLabel: label,
Platform: platform,
DeviceTokenHash: &tokenHash,
})
if errors.Is(err, db.ErrConflict) {
// Two concurrent joins for the same device: the sequential repeat path
// above already handled the normal re-scan case.
Warn(action, "POST", "concurrent duplicate join alert=%s user=%s", alertID, p.ID)
Warn(action, "POST", "concurrent duplicate join alert=%s", alertID)
writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
return
}
@@ -162,7 +200,42 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
return
}
Debug(action, "POST", "joined alert=%s user=%s sub=%s", alertID, p.ID, sub.ID)
Debug(action, "POST", "joined alert=%s sub=%s (anonymous)", alertID, sub.ID)
writeJSON(w, http.StatusOK, joinResponse{newSubscriptionView(sub), token})
}
// joinAsAccount is the ALARM_ANONYMOUS=false path: the same join, but the
// caller must hold an access token and the row carries their user id.
func joinAsAccount(w http.ResponseWriter, r *http.Request, action string, alert *db.Alert,
deviceID string, label, platform *string) {
p, ok := principal(w, r, action)
if !ok {
return
}
existing, err := db.GetSubscriptionByDevice(r.Context(), alert.ID, p.ID, deviceID)
switch {
case err == nil:
writeJSON(w, http.StatusOK, newSubscriptionView(existing))
return
case !errors.Is(err, db.ErrNotFound):
dbError(w, action, err)
return
}
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
AlertID: alert.ID,
SubscriberUserID: p.ID,
DeviceID: deviceID,
DeviceLabel: label,
Platform: platform,
})
if errors.Is(err, db.ErrConflict) {
writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
return
}
if err != nil {
dbError(w, action, err)
return
}
writeJSON(w, http.StatusOK, newSubscriptionView(sub))
}
@@ -203,7 +276,7 @@ type removeSubscriptionRequest struct {
// alert's admin may remove any device; a subscriber may remove their own.
func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
const action = "removeSubscription"
p, ok := principal(w, r, action)
c, ok := callerFor(w, r, action)
if !ok {
return
}
@@ -237,17 +310,22 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
return
}
if sub.SubscriberUserID != p.ID {
// Not the owner: only the alert admin may remove someone else's device.
// A stranger gets the same code as a missing row so the endpoint never
// confirms that the subscription exists.
// A device token is scoped to its own subscription: it can remove that row
// and nothing else. An account may remove its own device, or any device on
// an alert it administers.
if c.IsDevice {
if c.Sub.ID != sub.ID {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
return
}
} else if sub.SubscriberUserID != c.UserID {
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
if alert.AdminUserID != p.ID {
Warn(action, "auth", "user=%s may not remove sub=%s", p.ID, subscriptionID)
if alert.AdminUserID != c.UserID {
Warn(action, "auth", "user=%s may not remove sub=%s", c.UserID, subscriptionID)
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
return
}
@@ -257,7 +335,7 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err)
return
}
Debug(action, "POST", "removed sub=%s alert=%s by=%s", subscriptionID, alertID, p.ID)
Debug(action, "POST", "removed sub=%s alert=%s", subscriptionID, alertID)
writeJSON(w, http.StatusOK, map[string]any{
"alertId": alertID.String(),
"subscriptionId": subscriptionID.String(),