Skip to main content

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:

#QuestionMechanismFailing it means
1Identity — which unit is this?device_uid lookupUnknown device
2Authentication — is it really that unit?device_secret (bcrypt); per-device broker credential for MQTTImpostor
3Device authorization — may it operate at all?lifecycle_status = ACTIVE + inventoryUnapproved / blocked / stolen
4Binding + membership — entitled to this mosque now?open assignment + live membershipsRight unit, wrong or lapsed mosque
5Playback authorization — may it open this stream now?stream token, mount- and subject-boundReplay, 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 authorizationPlayback entitlement
QuestionMay this unit operate as an Azan360 device?May this subject play this mosque's azaan now?
Granted bySuper Admin, once per unitDerived, per request
Stored?Yes — devices.lifecycle_statusNever
Applies todevices onlydevices and app installs
Lives incore/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
StepCheckFailure reason
1row existsunknown_device
2bcrypt verifyunknown_device (deliberately the same reason as step 1 — never reveal which half was wrong)
3lifecycle_status = ACTIVEnot_verified / not_active / blocked / revoked / lost / retired
4inventory_id IS NOT NULLno_inventory
5inventory_status ∉ {LOST, SCRAPPED}inventory_invalid
6an open assignment existsunbound
7assignment's mosque = requestedmosque_mismatch
8stream 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 TTLCache caches 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 (no stream_url in 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_mosquedeauthorize
    Triggered bymembership lapse, owner rebind, superadmin unassignrevoke, block, retire, lost
    Frequencyroutinerare, terminal
    Credentials + broker sessionkeptwiped / dropped
    Path backone config publishfull re-claim

    Order matters on deauthorize: publish it before clearing device_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.