Playback Entitlement
:::info Status
Implemented. Source: docs/device-management-module-architecture.md §6.0a, §6.1, §6.2, §6.3, §6.3a, §6.3b.
:::
Playback entitlement answers: may this subject play this mosque's azaan, right now? Unlike device authorization, it is never stored — it's derived fresh on every request, and it applies to devices and app installs alike.
PLAYBACK_ENTITLED(s, m, t) ≡
CASE s.kind OF
transmitter → DEVICE_AUTHORIZED(s,cred) ∧ BOUND(s,m)
stream_player → DEVICE_AUTHORIZED(s,cred) ∧ BOUND(s,m) ∧ APPROVED(owner(s), m)
app_install → verify(cred, s.secret_hash) ∧ s.status=ACTIVE ∧ APPROVED(s.user, m)
END
Invariants:
| # | Invariant |
|---|---|
| I1 | PLAYBACK_ENTITLED(d,m,t) ⟹ DEVICE_AUTHORIZED(d,cred) for every device kind |
| I2 | DEVICE_AUTHORIZED ⇏ PLAYBACK_ENTITLED — the converse never holds |
| I3 | An app install's entitlement is defined without DEVICE_AUTHORIZED — there's nothing to check |
| I4 | BLUETOOTH_ALLOWED(d) ≡ TRUE, unconditionally |
| I5 | Neither predicate is ever a stored column |
| I6 | PLAYBACK_ENTITLED(s,m,t) ⇏ PLAYBACK_ENTITLED(s,m′,t) — entitlement is per mosque, never global |
Shared infrastructure, per-subject policy
core/playback_authorization.py SHARED resolver + Entitlement + policy registry + cache
resolve_playback_entitlement(subject_type, subject_uid, credential, mosque_id, purpose)
├── "device" → core/device_authz.py :: authorize_device() (Track A)
└── "app_install" → core/app_authz.py :: authorize_app_stream() (Track B)
core/stream_tokens.py SHARED mint / verify / jti revocation
api/v1/endpoints/stream_auth.py SHARED /token, /verify, /icecast
The resolver knows nothing about devices or memberships; the policies know nothing about tokens or Caddy — a third subject type later is a policy registration, not an endpoint edit. Full device-side query: Device Authorization.
Stream gate
/stream/* is protected in two layers:
- Caddy
forward_auth(primary) — every request to/stream/*is checked against/api/v1/stream-auth/verifybefore Icecast ever sees it. - Icecast mount URL auth (defense in depth) — a
listener_addhook pointing at/api/v1/stream-auth/icecast, in case something inside the Docker network bypasses layer 1. Icecast has no published host port in production for exactly this reason — see Backend Deployment — stream gate rollout.
Stream tokens are short-lived (120s TTL), subject-bound, and mount-bound —
one endpoint (POST /api/v1/stream-auth/token) dispatches on subject_type
with no if device / if app branch. Push payloads carry no pre-signed
token; a subject exchanges its own credential for a token at play time.
The entitlement lease — bounding a device that's gone dark
Two requirements are in direct tension for a device the backend can't reach: autonomy (keep playing from cached config through a broker outage) and instant revocation. The worst case is a stolen unit deliberately kept off the network, which naive autonomy would let play forever.
Revocation is instant for a reachable device, bounded for an unreachable one.
| Reachability | Azaan stops | Mechanism |
|---|---|---|
| Online | < 5s | deauthorize on the open session + broker session kick |
| Offline, reconnects later | At reconnect, before it can play | Config refresh precedes playback |
| Offline indefinitely | At lease expiry | Below |
Cached config carries entitlement.lease_seconds (default 604800 = 7
days), renewed only by a successful authenticated broker session, measured
from monotonic uptime since the last such session — not wall clock (no RTC,
an offline unit can't NTP). Past the lease, the device stops azaan
(including any local fallback recording), publishes
UNBOUND/lease_expired, keeps Bluetooth working, and keeps retrying
forever — resuming on the next successful session with no reboot or
re-claim needed.
| Lease | Bounds a stolen unit to | Risk to legitimate units |
|---|---|---|
| 24h | 1 day | Any outage or holiday over a day silences azaan — unacceptable |
| 7 days (default) | 1 week | Survives a long outage, maintenance, connectivity gaps |
| 30 days | 1 month | Operationally safe; a stolen unit plays for a month |
Broker recovery time and lease length are one coupled decision — see Design Decisions for why 7 days was chosen, and Operations — Monitoring for what a broker outage actually costs while the lease holds.
A lease is not a grace period — they're opposites:
| Grace period (rejected) | Entitlement lease (adopted) | |
|---|---|---|
| Delays | the backend deciding | the device acting on state it can't verify |
| Lives | server-side | device-side |
| Reachable device | would keep playing after revocation | none — stops in < 5s |
| Unreachable device | none | stops it, after the window |
Bluetooth is unaffected by lease expiry (I4) — a lease-expired unit is still the speaker its owner bought; Azan360 stops its service, not the hardware. Local-fallback audio stops with the lease too — exempting it would leave a flashed audio file as exactly the hole the lease exists to close.
effective_playback_state
One derived enum — the only thing anything else should read — first match wins:
| State | Meaning |
|---|---|
PLAYING | actively outputting azaan |
READY | would play at the next trigger — the healthy idle state |
NOT_AUTHORIZED | lifecycle ≠ ACTIVE, or inventory not ALLOCATED/DEPLOYED, or (stream player) no open ownership row |
NOT_BOUND | no open assignment |
MEMBERSHIP_LAPSED | open assignment, membership gone |
LEASE_EXPIRED | offline past the entitlement window |
SUPPRESSED | entitled, silenced by the user (mute, quiet hours, prayer disabled) |
UNREACHABLE | entitled, offline right now |
Only READY and PLAYING correspond to ACTIVE ∧ BOUND. SUPPRESSED and
UNREACHABLE are deliberately distinct from the authorization states — a
muted device and an unapproved device look identical to the member and are
opposite problems.
Playback confirmation outcomes are stored verbatim in the audit trail —
played, played_fallback, interrupted, refused, expired, failed,
not_confirmed, skipped — and refused/skipped (intentional
non-delivery) are never aggregated with failed: "40 devices did not play"
needs to distinguish real faults from people who were asleep with quiet
hours on. See Broadcast Audit.