← 4. A decision · Contents · Next → 6. Going offline
5. Access rights¶
A right is the sentence "this person, with these credentials, may pass at this zone, between these two instants". This chapter covers how you say it, how it is stored, and how it reaches a door that may be offline when you say it.
The model¶
erDiagram
TENANT ||--o{ ZONE : owns
TENANT ||--o{ USER : has
ZONE ||--o{ CONTROLLER : "is driven by"
CONTROLLER ||--o{ DOOR : drives
DOOR ||--o{ READER : "is watched by"
USER ||--o{ ACCESS_RIGHT : holds
ZONE ||--o{ ACCESS_RIGHT : "applies at"
ACCESS_RIGHT ||--o{ CREDENTIAL : carries
ACCESS_RIGHT ||--o| ACCESS_RIGHT : replaces wardn's data model is deliberately small. Nine tables carry the whole business: who exists, where they may go, and what proves it at the door. Everything else, the three journals, the fleet telemetry, the sync queue, is plumbing built around this core, not part of it. The same nine classes below are what a developer needs to hold in their head to reason about the system.
classDiagram
class Tenant {
+UUID id
+string name
}
class Zone {
+UUID id
+string name
+string timezone
}
class DoorGroup {
+UUID id
+string name
}
class EdgeController {
+UUID id
+string deviceIdentifier
+string status
}
class Door {
+UUID id
+string direction
+string mode
}
class IdentificationDevice {
+UUID id
+string deviceType
}
class User {
+UUID id
+string email
+string fullName
}
class AccessRight {
+UUID id
+bool granted
+datetime validFrom
+datetime validTo
+bool isActive
}
class Credential {
+UUID id
+string type
+string valueHash
+string rawValue
}
Tenant "1" --> "*" Zone : owns
Tenant "1" --> "*" User : has
Zone "1" --> "*" EdgeController : is driven by
Zone "1" --> "*" DoorGroup : groups doors of
EdgeController "1" --> "*" Door : drives
Door "1" --> "*" IdentificationDevice : is watched by
DoorGroup "0..*" -- "0..*" Door : restricts to
User "1" --> "*" AccessRight : holds
Zone "1" --> "*" AccessRight : applies at
AccessRight "1" --> "*" Credential : carries
AccessRight "0..1" --> "0..1" AccessRight : replaces Two things about this shape are worth naming.
A right applies at a zone, not at a door. A site is the unit people think in. Think "Sophie has access to Brussels Central". A zone may be driven by several cabinets, and each cabinet covering the zone receives the whole set.
A right carries its credentials. They are not attached to the person: the same person can hold a badge for one site and a plate for another, and losing a badge changes one right rather than an identity.
An access right¶
| Field | Meaning |
|---|---|
userId | Whose right it is |
zoneId | Where it applies |
doorGroupId | Which doors it reaches. Absent means every door of the zone |
granted | true allows, false explicitly denies. Not the same as having no right |
validFrom / validTo | The window. Inclusive / exclusive |
sourceTimezone | The zone the caller expressed the window in, kept for display |
externalId | The caller's own key. Unique per zone, and the idempotency key |
source | manual when typed in the dashboard, api when provisioned |
metadata | Free-form JSON the caller owns |
isActive / revokedAt | Whether this revision is the one in force |
replacesId | The revision this one supersedes |
credentials[] | One or more {type, value} |
A credential¶
type is deliberately free-form. Readers in the field produce vocabularies nobody controls, and a closed enum would mean a schema migration each time a customer buys a different reader. The seed and the dashboard use badgeNumber, licensePlate, qrCode, pinCode, accessCode, cardholderId, textRaw, numberRaw; anything else is accepted.
value is stored normalised (see chapter 4), with the caller's original text kept beside it in rawValue so a screen can show what was typed.
licensePlateis matched by prefix, solicensePlateFrontandlicensePlateBEnormalise the same way.textRawandnumberRawsit at the other end: nothing is stripped, trimmed, or upper-cased. The value is matched exactly as the reader produced it, byte for byte. Reach for one of these two when a device hands over an identification number whose shape wardn has no way to interpret, and any normalisation would risk turning two different badges into one.
Rights are never edited¶
Changing the credentials of a right creates a new right, and deactivates the old one.
flowchart LR
R1["Right #1<br/>badge BDG-0004<br/>isActive: false<br/>revokedAt: 14 March"]
R2["Right #2<br/>badge BDG-0104<br/>isActive: true<br/>replacesId: #1"]
R1 -.->|"replaced by"| R2
P["A passage on 2 March<br/>with BDG-0004"] --> R1 Tom lost his badge on 14 March. Editing right #1 in place would rewrite the past: the passage recorded on 2 March would appear to have been made with a badge that did not exist yet, and "why could this person get in that day?" would have no answer.
Instead:
- the previous revision is deactivated and stamped with
revokedAt; - a new revision is created carrying the new credentials, pointing back through
replacesId; - the
externalIdfollows the active revision, so a provisioning client upserting the same key keeps addressing the right that is in force.
curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"credentials":[{"type":"badgeNumber","value":"BDG-0104"}]}' \
http://localhost:3000/api/v1/access-rights/$ID/credentials
Revoking is the same idea without a replacement: DELETE /access-rights/{id} deactivates the right and removes its credentials from every cabinet.
A revoked right is kept for ACCESS_RIGHT_RETENTION_DAYS (30 by default) so a passage from last week can still be explained by the right that allowed it, then purged. See 11. Personal data.
How a right reaches a door¶
sequenceDiagram
participant C as Caller (API or dashboard)
participant B as Backend
participant Q as sync_tasks
participant M as Broker
participant E as Cabinet
C->>B: POST /access-rights
B->>B: insert right + credentials (one transaction)
B->>Q: queue a delta for every cabinet of the zone
B-->>C: 201 Created
Note over Q: revision = revision + 1, per cabinet
Q->>M: publish on rights/sync (QoS 1)
M->>E: deliver
E->>E: apply in one transaction
E->>M: ack {refId, revision, status}
M->>B: the delta landed Two facts, deliberately separate: the cloud accepted it and the door knows about it. The queue is what makes both observable, and the second is the one that matters at the gate.
The queue¶
sync_tasks holds deltas waiting to reach a cabinet.
- Each cabinet carries a monotonic
rights_revision. Every delta queued for it increments that counter and carries the new value. - A dispatcher in every backend instance claims tasks with
FOR UPDATE SKIP LOCKEDplus a lease (SYNC_TASK_LEASE_S, 30 s), so several instances drain the same queue concurrently and a crashed one releases its work rather than blocking the queue. - Delivery failures are retried with exponential backoff, capped at five minutes.
- After
SYNC_TASK_MAX_ATTEMPTS(10) the task is abandoned and async_failedalert is raised. A cabinet left on stale rights is the failure that opens a door for somebody who was revoked; giving up quietly is not an option. - Acknowledged tasks are purged after
SYNC_TASK_RETENTION_DAYS(7). Each row carries a whole rights payload, so a queue nobody empties outweighs every log table within days. Only acknowledged tasks go. One still queued or failed is a right that never reached its door.
What travels on the wire¶
{
"syncId": "8d8af706-cec6-4159-8939-7274f8a04f77",
"revision": 7,
"fullSync": false,
"operations": [
{
"op": "upsert",
"credentialId": "1b000000-0000-4000-8000-000000000001",
"accessRightId": "1a000000-0000-4000-8000-000000000001",
"credentialType": "badgeNumber",
"valueHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"userId": "0f000000-0000-4000-8000-000000000001",
"granted": true,
"validFrom": "2026-08-01T00:00:00Z",
"validTo": "2026-12-31T00:00:00Z"
},
{ "op": "remove", "credentialId": "1b000000-0000-4000-8000-000000000004" }
]
}
The cloud sends hashes, never values. A cabinet is physically reachable, so it stores the SHA-256 of the normalised value and nothing else.
A removal carries only an id. The cabinet does not need to know what it is forgetting, and sending the rest would put credential data on the wire for no reason.
What the cabinet does with it¶
In one transaction, all or nothing. A partially applied delta would leave the cabinet deciding from a set that never existed upstream.
Three guards sit in front of that transaction:
- A delta not ahead of what is held is ignored. Revisions are strictly increasing per cabinet, so anything at or below the current one is a QoS 1 redelivery or a delta a newer one has already overtaken. It is acknowledged with the revision actually held, never the stale one. The cloud reads that number as what the cabinet holds, and a lower one would make it believe the door had stepped backwards.
- An incomplete upsert is rejected, not defaulted. A credential with a guessed validity window would open a door on the strength of a bug.
- A full sync carrying nothing does not empty a populated cabinet. Emptying a zone is a deliberate act; a full sync that arrived carrying nothing is indistinguishable from one, and obeying it would leave the door refusing everybody until somebody drove out to the site. An empty full sync on an already-empty cabinet is applied, because a zone whose last right was revoked is a legitimate empty set and has to converge.
A rejected delta is acknowledged as rejected rather than left to retry: replaying it would break the same way, so the cloud is told to stop rather than to try again. That acknowledgement raises a sync_failed alert.
make sync # change a right through the API, watch the door follow
Keeping the two ends in agreement¶
Deltas alone are not enough. Two things can put a cabinet out of step:
- it was unreachable when a change happened;
- a delta was lost while the link was up, or its local store stopped matching what it acknowledged.
Three mechanisms cover those, with deliberately different failure modes.
flowchart TD
A["1. On every connect<br/>the cabinet asks for everything"] --> R["The cabinet holds<br/>what the cloud holds"]
B["2. Every night, in local time,<br/>the cloud sends everything"] --> R
C["3. Every couple of hours,<br/>the cloud compares fingerprints"] --> R 1. The cabinet asks, on every connect¶
The moment an MQTT session is established, the cabinet publishes on rights/request, and the cloud queues its whole set.
A controller cannot know what changed while it was unreachable, so reconnecting is the one moment asking for everything is worth its cost. Drift while the link is up is the cloud's problem to notice. It holds the rights, and the fingerprint it needs already rides the heartbeat.
2. The nightly pass¶
Every cabinet is sent its whole set once a night, at RIGHTS_FULL_SYNC_LOCAL_HOUR (3 by default) in its own zone's timezone, spread over RIGHTS_FULL_SYNC_JITTER_MINUTES (60).
- Local rather than one fleet-wide instant: a fleet spread across timezones has no single "quiet hours".
- The jitter is derived from the cabinet's id, so it is stable. The same cabinet gets the same slot every night, and the fleet does not ask in the same minute.
- It is expressed as due and not yet done rather than fired at an instant: a backend that was restarting at three catches the night up instead of skipping it, and one that ticks twice in the same minute still only sends once.
This pass has no off switch. It detects nothing, but it cannot be wrong, and that is what bounds a fault in the mechanism below to a single night.
3. The fingerprint check¶
Every cabinet reports, with its heartbeat, a digest over the credentials it holds and how many there are. Every RIGHTS_VERIFY_INTERVAL_S (two hours by default) the cloud compares that digest against the one it computes for the same zone.
line = credentialId ":" valueHash ":" granted(0|1) ":" validFrom ":" validTo "\n"
digest = sha256( sort(lines) )
Instants are epoch seconds; granted is rendered as 0 or 1; the lines are sorted, which orders them by credential id since every id is a canonical UUID of the same length.
Three details make the comparison trustworthy:
- The expected digest is computed from the very operations the cloud would send, not from a second SQL rendering of the same data. The cabinet stores
sha256(normalise(type, value)), and reproducing that normalisation in SQL would be a second definition destined to drift from the first. - It is computed once per zone, not once per cabinet: several cabinets of one zone must agree on the same set.
- The format is pinned on both sides by the same golden vector. The Rust test in
edge/wardn-edge/src/store.rsand the TypeScript test inbackend/src/sync/fingerprint.spec.tsassert the same hexadecimal string. This is one format with two implementations. Left unpinned, a divergence would surface in production as a cabinet resynchronised on every pass.
Cabinets with a delta still in flight are skipped. They are legitimately behind, and reading that as drift would resynchronise them on top of the delivery already under way.
When a digest disagrees, the cabinet is sent its whole set and a mismatch counter is incremented. When it agrees, the counter is cleared. After RIGHTS_MAX_FINGERPRINT_MISMATCHES (3) consecutive disagreements the cloud stops resynchronising and raises a rights_drift alert instead:
A digest that still disagrees after being sent the whole set three times is far more likely to be the two ends computing it differently than a cabinet losing the same rights every time. Left unguarded, that fault would send a full set on every pass. That is the exact behaviour this mechanism exists to end. The nightly pass still covers the cabinet while somebody looks.
An operator can force the same remedy by hand:
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/devices/$DEVICE/actions/full-resync
Entering rights¶
By hand¶
The dashboard's Access rights screen creates, replaces and revokes rights. Useful for a handful of people; not how a real estate is run.
Through the API¶
POST /api/v1/provisioning/access-rights is the front door for integrations, one right per call, idempotent on (zoneId, externalId).
curl -X POST -H "X-API-Key: wapi_dev_acme_0000000000000001" \
-H 'Content-Type: application/json' \
-d '{
"userId": "0f000000-0000-4000-8000-000000000001",
"zoneId": "0b000000-0000-4000-8000-000000000001",
"granted": true,
"validFrom": "2026-08-11T07:00:00Z",
"validTo": "2026-08-11T19:00:00Z",
"externalId":"booking-88213",
"credentials":[{"type":"qrCode","value":"QR-VISIT-0042"}]
}' \
http://localhost:3000/api/v1/provisioning/access-rights
What replaying that same request does:
| The stored right… | Result |
|---|---|
| does not exist | it is created |
| exists with the same credentials | it is returned unchanged, no second right |
| exists with different credentials | it is superseded: a new revision, linked back |
An integrator that no longer knows what it has already sent can therefore resend everything without breaking anything, which is the normal operating mode of a booking system.
Full endpoint reference: 9. The API.
What the cabinet actually stores¶
local_credentials(
id, access_right_id, credential_type, value_hash,
user_id, granted, valid_from, valid_to, revision
)
No names, no emails, no plaintext values, no history. Everything else about the person lives in the cloud. The single index on the critical path is (credential_type, value_hash, valid_to), so a read resolves without a scan.
Rights are in place. Now cut the network.
Next → 6. Going offline