Skip to content

← 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

wardn's data model is deliberately small. A handful of tables carry the whole business: who exists, where they may go, and what proves it at the door. Everything else — the journals, the fleet heartbeat, the sync queue — is plumbing built around this core, not part of it. This is the shape worth holding in your head to reason about the system:

classDiagram
    class Workspace {
        +UUID id
        +string name
    }
    class Zone {
        +UUID id
        +string name
        +string timezone
    }
    class EdgeController {
        +UUID id
        +string deviceIdentifier
        +string status
    }
    class Door {
        +UUID id
        +string direction
        +string mode
    }
    class IdentificationDevice {
        +UUID id
        +UUID readerFormatId
    }
    class User {
        +UUID id
        +string name
    }
    class AccessRight {
        +UUID id
        +bool granted
        +datetime validFrom
        +datetime validTo
        +bool isActive
    }
    class Credential {
        +UUID id
        +string type
        +string value
        +string rawValue
    }

    Workspace "1" --> "*" Zone : owns
    Workspace "1" --> "*" User : has
    Zone "1" --> "*" EdgeController : is driven by
    EdgeController "1" --> "*" Door : drives
    Door "1" --> "*" IdentificationDevice : is watched by
    User "1" --> "*" AccessRight : holds
    AccessRight "*" --> "1" Zone : 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 controllers, and each controller covering the zone receives the whole set.

A right carries its own 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 only ever changes one right, not their whole identity in the system.

An access right

Field Meaning
userId Whose right it is
zoneId Where it applies
granted true allows, false explicitly denies. Not the same as having no right at all
validFrom / validTo The window, always stored in UTC. Includes the start, excludes the end
sourceTimezone The zone the caller expressed the window in, kept so it can be shown back the same way
externalId The caller's own reference for this right, if it came from another system
source manual when typed in the dashboard, api when provisioned by another system
metadata Free-form information the caller wants to keep attached to it
isActive / revokedAt Whether this version is the one currently in force
replacesId The version this one took over from
credentials[] One or more {type, value} pairs

A credential

The type is deliberately open-ended rather than a fixed list. Readers in the field produce all sorts of vocabularies, and locking it down would mean a change to the system every time a customer buys a different kind of reader.

The dashboard offers badgeNumber, licensePlate, qrCode and pinCode as a starting point; a site can use others, such as accessCode or cardholderId, just as well.

A credential keeps both the value as it is compared at the door and the value as it was originally typed or read, so a screen can always show what was actually entered.

What actually gets compared is normalised first — see chapter 4 — so the same plate spelled two different ways still matches.

What that looks like for a few types:

Type Value read at the door What gets compared
licensePlate 1-ABC-123 1ABC123. Separators stripped, upper-cased
badgeNumber BDG-0001 BDG-0001. Trimmed and upper-cased, dashes kept
qrCode QR-VISIT-0042 QR-VISIT-0042. Trimmed and upper-cased
number a raw card number straight off an RFID reader, with no attempt to interpret it unchanged, matched byte for byte

The same digits can mean two different things depending on how they arrived: read by a camera as a licensePlate, they get normalised before matching. The same digits handed over by an RFID reader with no plate to read belong under number instead — nothing about that value should be trimmed, folded, or reinterpreted as if it were a plate.

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 version is deactivated, with the date it stopped being valid kept alongside it.
  • A new version is created carrying the new credentials, linked back to the one it replaced.
  • Any external reference follows the active version, so a system that provisioned the original right and later updates it keeps addressing the right that is actually in force, without needing to track which version is current itself.

One version is in force at a time, and that holds even when two requests supersede the same right at once. The one that gets there first wins; the other is answered 409 and changes nothing, rather than producing a second active version. That second version would be the worse outcome by far: both badge sets would open the door, and revoking the version an operator can see would leave the other one working.

Revoking works the same way, minus the replacement: it deactivates the right and removes its credentials from every controller that held them.

A revoked right is kept for a month by default, so a passage from last week can still be explained by the right that allowed it, before it is finally cleared out. 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 Delivery queue
    participant M as Broker
    participant E as Controller

    C->>B: create or change a right
    B->>B: save it (one transaction)
    B->>Q: queue the change for every controller of the zone
    B-->>C: confirmed
    Note over Q: a new revision number, per controller
    Q->>M: publish it
    M->>E: deliver
    E->>E: apply it (one transaction)
    E->>M: acknowledge {revision, status}
    M->>B: the change landed

Two facts, deliberately kept separate: the server accepted it, and the door knows about it. The queue is what makes both of those observable on their own — and it is the second one that actually matters standing at the gate.

The queue

A change waiting to reach a controller sits in a delivery queue until it is confirmed.

  • Each controller carries its own counter, which only ever increases. Every change queued for it bumps that counter and carries the new value along.
  • Several backend instances can drain the same queue at once, safely, with no single one of them in charge — one going down mid-delivery simply lets another pick its work back up.
  • A delivery that fails is retried, spaced further apart each time, up to a cap.
  • After ten failed attempts the change is abandoned and an alert is raised instead. A controller left running on stale rights is exactly the failure that lets someone in who was supposed to have been revoked — giving up quietly is not an acceptable outcome here.
  • Once a change is confirmed delivered, it is only kept around for a week before being cleared out — long enough to investigate a recent issue, not so long that a queue nobody ever empties starts to outweigh every other record kept. Anything still pending or failed stays until it either succeeds or is escalated.

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" }
  ],
  "doors": [
    { "id": "0d000000-…0001", "name": "North entry", "direction": "IN",
      "mode": "normal", "relayPulseMs": 2000, "osdpAddress": null }
  ],
  "readers": [
    { "id": "0e000000-…0001", "doorId": "0d000000-…0001", "osdpAddress": 1,
      "readerFormat": { "id": "00000000-…0101",
        "definition": { "kind": "emit", "identifierType": "badgeNumber", "value": { "kind": "raw" } } } }
  ]
}

The server sends a scrambled form of the value, never the value itself. A controller is physically reachable, so it only ever holds enough to recognise a credential again, never enough to read it back.

A removal carries only an id. The controller 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 controller does with it

In one transaction, all or nothing. Applying half of a change would leave the controller deciding from a set that never actually existed on the server's side either.

Three guards sit in front of that transaction:

  1. A change that is not newer than what's already held is ignored. Since the counter only ever goes up, anything at or below the current one is either a message arriving twice, or one that a later message has already overtaken. The controller still answers, but with the revision it actually holds — never the stale one it was just sent. The server does not take that number on trust either: it records the revision from its own queue, and only ever upwards. A delta arriving late can therefore never make the record say a door stepped backwards.
  2. An incomplete change is rejected outright, never guessed at. A credential with a made-up validity window would open a door on the strength of a bug.
  3. An empty "here is everything" message never empties a controller that already holds something. Emptying a whole zone is a real, deliberate act, and an empty message is indistinguishable from a bug that forgot to include anything — obeying it would leave the door refusing everybody until someone physically drove out to the site to fix it. The one exception: a controller that is already empty accepting an empty "everything" message is fine, because a zone whose last right was just revoked really is meant to end up with nothing.

A rejected change is acknowledged as rejected rather than left to be retried, since retrying it would only fail the same way again — and that acknowledgement itself raises an alert.

make sync   # change a right through the API, watch the door follow

A set too large for one message

Every broker caps a message, and IoT Core caps it at 128 KB with no way to raise it. An upsert costs about 375 bytes on the wire, so a zone's whole set stops fitting somewhere around three hundred rights — well before the zone is large.

So "here is everything" travels in as many messages as it needs. Each is a queued task of its own, with its own revision and its own acknowledgement, and each carries a set naming the part it holds and the revision the whole set answers for:

{ "revision": 412, "chunk": 2, "chunks": 5 }

The controller stamps every right it applies with that shared revision as the parts arrive, and once all of them have landed it drops whatever still carries less. Until then it holds the old set and the new one at once, which is deliberate: a part lost on the way leaves a door opening for somebody it should no longer open for, and the alternative — sweeping early — leaves it refusing everybody until somebody drives out to the site. A set that never completes is caught by the fingerprint check and sent again.

A controller running firmware from before this existed reads each part as a whole set and ends up holding only the last one. It is not silent — its fingerprint disagrees, and it says so every time it reports — but it does not converge either. A zone over SYNC_OPERATIONS_PER_MESSAGE rights therefore wants its controllers upgraded first; a smaller one still travels in a single message and is unaffected.

Two consequences worth naming. Parts of one set are interchangeable, so the rule that a revision at or below the one held is ignored does not apply to them: a part retried after the rest arrives behind them and must still land. And the rule that an empty "here is everything" never empties a populated controller is checked once, against the assembled set, not against each part — one carrying nothing is only a part that happened to be empty.

Topology rides along

Every change sent to a controller also carries its controller's whole set of doors and readers, not just the rights themselves. That set is small and rarely changes, so it simply travels along in full every time, rather than being tracked separately — one less thing that could quietly drift out of step with what the controller actually holds.

The set is read at the instant the counter is taken, on the same connection and behind the same row lock. Two changes queued for one controller at once therefore leave the higher revision carrying the newer set, never the older one. That order matters more than it looks: a controller settled on a high revision holding an old set still reports as in sync, because the fingerprint compares credentials rather than doors and readers, and the mismatch would go unseen until the nightly full sync.

Doors and readers are both fully replaced, and so is the normalization catalog. Nothing in any of the three is specific to one controller's own local setup any more, so one created, moved, or removed through the API reaches the controller the same way a right does — including one going missing from the set, which removes it there too.

The wiring travels with them. A door carries the relay terminal it fires, or the OSDP address of its own node on the bus, and a controller carrying nothing but its certificate learns both from the first report it receives. Nothing needs to be placed on the machine.

What the cloud cannot do is check the terminal. A dry contact has no return path: the controller can fire terminal 3, and nothing on the wire says a door moved, let alone which one. So a terminal is declared and taken at its word until somebody watches the door open and signs for it (POST /doors/{id}/actions/verify-relay). An OSDP address is the opposite — the node answers or it does not, and the bus reports which.

A door with neither is a real state, not a gap: declared in the dashboard, wired to nothing on site. The controller holds it, decides on it, records the passage, and refuses with DOOR_NOT_COMMISSIONED. That is worth far more to whoever has to fix it than a door missing from the controller altogether.

A reader named against a door the same report does not carry is a delta contradicting itself. It is skipped on its own and named in the technical journal —

WARN skipped readers the topology report named no door for
     readers=["0e00…00ff"]

— while everything else in that report lands: the doors it travelled with, the readers beside it, the normalization catalog.

Adding or removing an address on the reader bus changes what the controller should be listening for, and that only gets re-read when the controller's own software restarts. So a change like that triggers a restart on its own, half a second after acknowledging it — enough time for the acknowledgement itself to get through first, so nobody is left wondering whether the change arrived.

A change to a door's mode, name, or how long its relay fires needs no such restart: none of that affects what the bus is listening for.

Keeping the two ends in agreement

Deltas alone are not quite enough. Two things can still put a controller out of step with what it should hold:

  • It was unreachable when a change happened.
  • A change was lost while the link was up, or the controller's own copy quietly stopped matching what it had actually confirmed.

Three separate mechanisms cover that, each with a deliberately different way of failing:

flowchart TD
    A["1. On every connection<br/>the controller asks for everything"] --> R["The controller holds<br/>what the server holds"]
    B["2. Every night, in local time,<br/>the server sends everything anyway"] --> R
    C["3. Every couple of hours,<br/>the server double-checks by comparison"] --> R

1. The controller asks, on every connect

The moment a controller establishes a fresh connection, it asks for its whole set of rights, and the server sends it.

A controller has no way to know what it missed while it was unreachable, so reconnecting is the one moment where asking for everything is worth the cost. Drift while the connection stays up is a different problem, one the two remaining mechanisms exist to catch.

2. The nightly pass

Every controller is sent its whole set once a night, at three in the morning in its own site's local time, spread out over the following hour so an entire fleet spanning many timezones doesn't all ask for everything at the exact same moment.

That slot is always the same for a given controller, night after night — and if a backend instance happens to be restarting exactly when a controller's turn comes up, it catches that controller up as soon as it's back rather than skipping the night entirely.

This pass has no off switch. It cannot go wrong in a way that hides a real problem, which is precisely what keeps any fault in the two mechanisms around it bounded to, at most, a single night.

3. The fingerprint check

Every controller reports, alongside its regular heartbeat, a short digest standing in for everything it currently holds — enough to compare without ever shipping the actual set back and forth. Every couple of hours, the server compares that digest against the one it computes for the same site.

The two sides have to compute that digest in exactly the same way, or the comparison means nothing — and that agreement is tested and pinned down explicitly on both ends, precisely because a silent disagreement there would be far worse than an obvious one.

A controller with a delivery still in flight is skipped. It is legitimately, temporarily behind, and treating that as drift would only resynchronise it on top of a delivery that is already under way.

When the two digests disagree, the controller is sent its whole set again, and a counter of how often that has happened in a row goes up; when they agree, that counter resets. After three disagreements in a row, the server stops trying to fix it automatically and raises an alert instead:

A digest that still disagrees after being sent the complete set three times running is far more likely to mean the two sides are computing it differently than a controller genuinely losing the same rights over and over. Left unguarded, that situation would just keep resending the whole set forever — exactly the behaviour this limit exists to stop. The nightly pass still covers the controller in the meantime, while somebody looks into it.

An operator can trigger the same remedy by hand at any time, from the fleet screen in the dashboard, or through the API.

Entering rights

By hand

The dashboard's Access rights screen creates, replaces and revokes rights. It works well for a handful of people. It is not how a whole building's worth of rights gets managed.

Through the API

A dedicated endpoint exists for integrations — a booking system, an HR platform, anything that needs to grant or update rights on its own. It is built to be replayed safely: sending the same request again for a right that already exists with the same credentials changes nothing, and sending it again with different credentials creates a new version rather than a duplicate.

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 version, linked back

That is what lets an integrator that has lost track of exactly what it already sent simply resend everything without breaking anything — which is the normal way a booking system actually operates.

Full endpoint reference: 9. The API.

What the controller actually stores

A controller's own local copy of a credential keeps only what a door actually needs to decide: which right it belongs to, its type and scrambled value, who it belongs to, whether it grants or denies, and its validity window.

No names, no emails, no plaintext values, no history of past versions. Everything else about a person stays on the server. If a controller were ever physically taken apart and its storage read directly, this is the absolute ceiling on what it could reveal.


Rights are in place. Now cut the network.

Next → 6. Going offline