Files
Ekklesia/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md
T
ekklesiaandClaude Opus 5 80189c8ae2
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s
feat: give display devices their own paired identity
ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page.
Together, the only way to light a television in a foyer was to sign a
human being into it and leave that session running on a screen anyone can
walk up to: a person's credentials on a wall, a person's session as the
thing signage depends on, and the display going dark when they are
revoked or their session simply expires.

A screen is not a person. Ekklesia now recognizes three principal classes
— anonymous, authenticated human, and managed device — and a device is a
first-class principal with its own credential and its own explicit
permissions. It has no email, no password, and no row in the identity
tables, and it satisfies the `authenticated` baseline never, so every
route that settles for "somebody is signed in" stays closed to it.

Device permissions live in their own table because
ekklesia_core_grant.user_id is a non-null foreign key into the user table
and making it polymorphic would rewrite the platform's central
authorization record to serve one narrow case. That is a schema decision,
not a second decision point: the evaluator gained one branch, so a device
is allowed or denied by the same function, with the same deny-by-default,
as anyone else — with no owner path it could reach and no baseline that
stands in for a grant.

The six-digit code is not the credential. It is printed across a screen in
a public hallway, so it is treated as public: it identifies a request to
an administrator and is worth nothing alone. Collecting the credential
also requires a 256-bit claim secret held only in the requesting browser's
HttpOnly cookie, and approving requires the restricted core.devices.manage
permission. Without that split, reading a code off a wall would hand over
the screen's identity.

The permission a pairing asks for is read from the module's validated
display declaration, never from the request, so a browser cannot ask to be
granted core.devices.manage or another module's permission. Approval
grants exactly that one permission.

Credentials are stored only as SHA-256; the raw value exists in the
Set-Cookie that issues it and the device's own cookie jar, and is never
logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so
the module display code running in that same page cannot read it.
Revocation is a server-side column checked on every principal resolution,
and re-pairing mints a new identity so an old credential can never come
back. State transitions are conditional updates, so one code cannot be
approved twice or claimed into two credentials under a race.

/api/v1/principal still reports a device as anonymous: it is what the
Dashboard uses to decide whether a person is signed in. Displays read
their viewer from the descriptor instead, which already performed the
authorization — so displays are descriptor-first while the Dashboard stays
principal-first, and neither infers an identity by elimination.

Proven end to end against real PostgreSQL and real HTTP: an unpaired
screen shows a code, an owner approves it, the device opens the display
descriptor and a realtime stream, and revocation closes both while the
device still holds its cookie. An already-open SSE stream is bounded by
realtime's existing five-minute reconnect rather than closed instantly,
and that bound is documented rather than called immediate.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:41:03 -05:00

193 lines
11 KiB
Markdown

# 0036: A display device is its own principal, paired by an administrator
- Status: Accepted
- Date: 2026-08-24
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
[0032](0032-native-module-realtime-transport.md),
[0033](0033-display-kiosk-host-contract.md),
[0034](0034-host-owned-browser-authentication.md),
[0035](0035-initial-platform-owner-bootstrap.md)
## Context
[ADR 0033](0033-display-kiosk-host-contract.md) gave Ekklesia a chrome-free display host, and
[ADR 0034](0034-host-owned-browser-authentication.md) gave it a login page. Put together, the only
way to light up a television in a foyer was to sign a human being into it and leave that session
running on a screen anyone can walk up to.
That is not a display model. It means a person's credentials live on a wall, a person's session is
what a screen depends on, and revoking that person — or their session simply expiring — takes the
signage down. The alternatives are worse: a public unauthenticated display, a hardcoded token, or a
credential in a URL.
A screen is not a person, and pretending otherwise is the problem.
## Decision
Ekklesia recognizes **three** principal classes: anonymous, authenticated human, and managed device.
A device is a first-class principal with its own identity, its own credential, and its own explicit
permissions.
```text
screen opens /display/announcements → no identity → shows a short code
administrator types the code in the Dashboard, names the screen, approves
screen collects a credential → device principal → display and realtime work
administrator revokes → the screen loses access and shows a code again
```
### Devices are not users
There is no device row in the identity tables and no foreign key from a device to a user. A device
has no email, no password, and no human session.
This matters beyond tidiness. Every "is a person signed in?" question in the platform is answered by
`isAuthenticatedPrincipal`, and a device that satisfied it would inherit whatever a signed-in human
may do. So a device satisfies the `authenticated` baseline **never** — not with one permission, not
with ten. The only thing that makes a device allowed is an explicit permission granted to that exact
device.
### Device permissions are their own table, and the evaluator is still the only judge
`ekklesia_core_grant.user_id` is a non-null foreign key into the user table. Generalizing it into a
polymorphic subject would rewrite the platform's central authorization record — its
relationship-derived grants, its audit semantics, its indexes — to serve one narrow case. Device
permissions therefore live in `ekklesia_core_device_permission`.
That is a schema decision, **not** a second decision point. `createAuthorizationEvaluator` gained one
branch, so a device is allowed or denied by the same function, with the same deny-by-default, as
anyone else. There is no `if (device) bypass`, no owner check a device could reach — a device is
never a platform owner — and no baseline that stands in for a grant. A deployment that composed no
device support at all simply denies every device.
### The pairing code is not the credential
Three secrets are involved and conflating any two of them would break the model:
| value | who sees it | what it does |
| --- | --- | --- |
| pairing code (6 digits) | **anyone who can see the screen** | identifies the request to an administrator |
| claim secret (256 bits) | only the requesting browser, HttpOnly | proves "I am the screen that asked" |
| device credential (256 bits) | only the paired browser, HttpOnly | is the device's identity |
The code is printed across a television in a public hallway. It is therefore treated as public: it
is stored in the clear, and knowing it is worth nothing on its own. Approving it requires an
administrator holding `core.devices.manage`, and **collecting** the resulting credential requires the
claim cookie, which no onlooker has. A flow that issued a credential to whoever presented the code
would hand a screen's identity to anyone who read it off the wall.
Six digits are therefore not carrying the security of this design. They are bounded anyway: ten
minutes, single-use, one live request per code enforced by a partial unique index, and an
administrator in the loop.
### The permission comes from the display, never from the request
A pairing request names only the module whose display the screen is sitting on, and even that is not
taken at its word — the module is looked up in the publication registry and the permission is read
from its validated display declaration. A browser cannot ask to be granted `core.devices.manage`,
another module's permission, or anything else. Approval grants exactly the one permission the host
derived, never a bundle.
### State machine
`pending → approved → consumed`, plus expiry derived from `expires_at`.
Expiry is deliberately *not* a fourth state: nothing has to run for a code to stop working, so there
is no sweeper whose failure would leave live codes lying around. Each transition is a conditional
`UPDATE` — approval matches only a `pending`, unexpired row, and consumption matches only an
`approved` one — so approving twice, approving something expired, and two screens racing to claim one
approval all affect zero rows rather than racing. Exactly one credential can ever come out of one
code.
### The credential is hashed, and revocation is server-side
The device receives an opaque 256-bit credential in an HttpOnly cookie; the server stores only
SHA-256 of it. A fast hash is correct here — unlike a password, there is nothing to brute-force in
CSPRNG output, and a slow KDF would only add latency to every request a display makes. The raw value
exists in exactly two places: the response that sets the cookie, and the device's own cookie jar. It
is never logged.
Revocation is `revoked_at` on the device row, checked on **every** principal resolution. It is not a
request that a remote browser discard anything: a revoked screen keeps its cookie and stops working.
### Cookies
Two host-owned cookies, named nothing like Better Auth's: `ekklesia_device_pairing` (short-lived,
cleared on use) and `ekklesia_device_session`. Both are `HttpOnly` — module display code runs in this
very page and must never be able to read the credential — `SameSite=Strict`, host-only, `Path=/`, and
`Secure` whenever the deployment's own base URL is https. Nothing about the credential is ever
returned in JSON, put in a query string, or written to `localStorage`.
**A human session always wins.** The device cookie is not even read when Better Auth resolves a
person, so an administrator who opens a display on their laptop stays themself.
### `/api/v1/principal` stays human-only
A device is reported as `anonymous` there, deliberately.
That endpoint is what the Dashboard shell uses to decide whether someone is signed in, and its
shape — "not anonymous means logged in" — was load-bearing in the shell before this pass. Returning a
device from it would have made a television a Dashboard user. The shell's test is now `=== "authenticated"`
as a second lock, but the first lock is that the endpoint never says "device" at all.
The display host instead reads its viewer from the **descriptor**, which is the display's actual
authorization boundary and already performed the check. So displays are descriptor-first while the
Dashboard remains principal-first, and neither has to infer an identity by elimination.
### The display page is served to anyone; the descriptor is not
An unpaired screen has no identity, so a permission gate on the page would leave a new television
with nowhere to render a code. The page is the same inert, module-agnostic bytes for every display —
no module id, no data, one already-public script URL — and every authorization decision stays in the
descriptor. What this does reveal to an anonymous caller is that a module with a display exists at
that path, which any pairing flow must reveal to the screen it is pairing.
### Session lifetime
Device credentials last 180 days; human sessions are unchanged. A screen mounted near a ceiling
should not need re-pairing on a fortnightly cycle. What makes a long horizon safe is that it is not
the security boundary — the server re-checks revocation on every request — and the expiry exists so
an abandoned, forgotten screen eventually stops being an identity at all. `lastSeenAt` is updated at
most every 15 minutes, in SQL as well as in the resolver, so a display polling realtime does not
become a write load.
## Consequences
### Revocation is prompt, but not instantaneous, and this ADR says so
A revoked device fails its next HTTP request immediately, and its next realtime *connection*
immediately. An **already open** SSE stream is not torn down at the moment of revocation: realtime
already forces a reconnect every five minutes (ADR 0032), so the worst case is a bounded five-minute
window during which a screen keeps receiving a channel it has just lost. Closing live streams on
revocation would mean reaching into the realtime registry by principal, which is a change to a
subsystem this pass has no other reason to touch. The bound is documented rather than described as
instant.
### Re-pairing creates a new identity
A revoked screen that is paired again becomes a **new** device row with a new credential. The old
credential is never reactivated, and the revoked row stays in the list as evidence. Reactivating the
original would mean a revoked credential could come back, which is exactly what revocation must
prevent.
### What was deliberately not built
QR codes (a future scanner can encode the same short code — the backend protocol does not change,
and a QR must never carry the credential, an admin token, or a session), per-device content
targeting, device groups, health monitoring, remote control, and mobile dashboards. The pairing API
is not desktop-specific, so a future mobile dashboard host can consume it unchanged.
### Ekklesia Lite has none of this
Lite is a minimal trusted-network module runtime: module loading, config, database, storage, HTTP,
realtime, and UI hosting. It deliberately provides no human authentication, no permission
enforcement, and **no managed device identity or pairing** — a Lite-hosted display simply runs on a
trusted local network. Device identity is a full-Ekklesia capability, and nothing here belongs in a
future Lite contract. No module code branches on which host is running it.
### Module UI is untouched
A module's display receives a principal and its permissions, exactly as before. The principal union
gained a `device` variant so a module can render honestly for a screen with nobody behind it, and
that is the whole change — there are no pairing controls in `ModuleUiContext`, and a module cannot
approve, list, or revoke anything.