Device Authorization
:::info Status
Implemented. Source: docs/device-management-module-architecture.md §6.0, §6.0a, §6.1; fastapi-backend/core/device_authz.py.
:::
This page covers device-level authorization — whether a specific transmitter or stream player may operate at all. For role-based authorization of human users (superadmin / mosque admin / member), see the system-level Authorization page.
Five distinct questions
Conflating any two of these produces a security hole:
| # | Question | Mechanism | Failing it means |
|---|---|---|---|
| 1 | Identity — which unit is this? | device_uid lookup | Unknown device |
| 2 | Authentication — is it really that unit? | device_secret (bcrypt); per-device broker credential for MQTT | Impostor |
| 3 | Device authorization — may it operate at all? | lifecycle_status = ACTIVE + inventory | Unapproved / blocked / stolen |
| 4 | Binding + membership — entitled to this mosque now? | open assignment + live memberships | Right unit, wrong or lapsed mosque |
| 5 | Playback authorization — may it open this stream now? | stream token, mount- and subject-bound | Replay, or a borrowed token |
Links 1–4 are policy and differ by subject type (device vs. app install); link 5 is infrastructure and identical for both — this seam is what let the free mobile app ship without waiting on hardware work.
Passing an earlier link never implies a later one. A unit that
authenticates onto the broker has only proven it is itself — it can still
receive config with no stream_url if it fails link 3 or 4. MQTT
authentication is not an authorization decision, and every link is
re-evaluated per request: a play command that passed links 3–4 at publish
time is an instruction to try, not a grant — the device must still fetch a
stream token, re-checked against live membership. See
Playback Entitlement.
Device authorization vs. playback entitlement
| Device authorization | Playback entitlement | |
|---|---|---|
| Question | May this unit operate as an Azan360 device? | May this subject play this mosque's azaan now? |
| Granted by | Super Admin, once per unit | Derived, per request |
| Stored? | Yes — devices.lifecycle_status | Never |
| Applies to | devices only | devices and app installs |
| Lives in | core/device_authz.py (policy) | core/playback_authorization.py (resolver) |
DEVICE_AUTHORIZED(d, cred) ≡
d.device_kind ∈ {transmitter, stream_player}
∧ verify(cred, d.device_secret_hash)
∧ d.lifecycle_status = ACTIVE
∧ d.inventory_id IS NOT NULL
∧ inventory(d).status ∉ {LOST, SCRAPPED}
The invariant most often violated in practice: DEVICE_AUTHORIZED does
NOT imply PLAYBACK_ENTITLED. An implementer sees ACTIVE and reads it as
permission — but ACTIVE + UNBOUND and ACTIVE + MEMBERSHIP_LAPSED are both
authorized-and-not-entitled, and both are normal states for a unit between
mosques.
The authorization query — authorize_device
One query, one chain, first failure wins (core/device_authz.py):
SELECT d.id, d.device_kind, d.device_secret_hash, d.lifecycle_status, d.inventory_id,
i.status AS inventory_status, a.id AS active_assignment, a.mosque_id AS bound_mosque_id,
mem.status AS owner_membership_status
FROM devices d
LEFT JOIN device_inventory i ON i.id = d.inventory_id
LEFT JOIN device_assignments a ON a.device_id = d.id AND a.unassigned_at IS NULL
LEFT JOIN device_ownership o ON o.device_id = d.id AND o.ended_at IS NULL
LEFT JOIN memberships mem ON mem.user_id = o.user_id AND mem.mosque_id = a.mosque_id
WHERE d.device_uid = $1
| Step | Check | Failure reason |
|---|---|---|
| 1 | row exists | unknown_device |
| 2 | bcrypt verify | unknown_device (deliberately the same reason as step 1 — never reveal which half was wrong) |
| 3 | lifecycle_status = ACTIVE | not_verified / not_active / blocked / revoked / lost / retired |
| 4 | inventory_id IS NOT NULL | no_inventory |
| 5 | inventory_status ∉ {LOST, SCRAPPED} | inventory_invalid |
| 6 | an open assignment exists | unbound |
| 7 | assignment's mosque = requested | mosque_mismatch |
| 8 | stream players only: owner's membership = 'approved' | membership_lapsed |
Step 8 is the only branch between the two device kinds — a transmitter is
bolted to a mosque and has no owner whose membership could lapse. Step 7
reads bound_mosque_id from the open assignment row, not the
denormalized devices.mosque_id cache column; the membership join goes
through the open ownership row for the same reason — the cache columns
are read conveniences, never the source of truth.
Bluetooth never calls this predicate — see Device Architecture.
Propagation — approval and revocation are both fast, for different reasons
This is a desired-state system, not a command system: the backend publishes desired state (retained MQTT config, versioned), the device reports actual state, and a reconciler (every 15 minutes) closes any drift.
-
Denials are never cached. A
TTLCachecaches only allow decisions, for 60 seconds, invalidated synchronously on every lifecycle transition, every assignment open/close, and every membership write. -
A unit is already connected when approved. Claiming releases broker credentials and leaves the row
PENDING, so an unapproved unit sits on the broker, subscribed, but streams nothing (nostream_urlin its config). Approval is one retained publish onto that already-open session — which is why claim and authorization are separate steps. -
Two distinct wire actions on revocation:
unbind_mosquedeauthorizeTriggered by membership lapse, owner rebind, superadmin unassign revoke, block, retire, lost Frequency routine rare, terminal Credentials + broker session kept wiped / dropped Path back one config publish full re-claim Order matters on
deauthorize: publish it before clearingdevice_secret_hash— reversed, the unit never receives the command telling it to wipe itself. See Device Deployment — revoking a device.
Full latency budget (p50 ≈ 1s, p99 budget 5s, hard ceiling 10s) and the reachable-vs-unreachable revocation split: see Playback Entitlement.