← 8. The dashboard · Contents · Next → 10. Security
9. The API¶
In production, access rights are not typed by hand: an existing booking system pushes them into wardn. That is what the API is for.
make api # authentication, confinement, provisioning, audit
open http://localhost:3000/docs # the interactive OpenAPI reference
The generated reference at /docs is always exactly what the code serves. The same document, exported to a file, is also embedded in this site: see the OpenAPI reference. This chapter is the part a generated document cannot tell you: the rules, the guarantees, and what to do with them.
Everything lives under /api/v1, except the two health probes.
Authenticating¶
Every route requires an identity except the health probes.
flowchart TD
REQ["Incoming request"] --> RL{"Within the<br/>rate limit?"}
RL -->|"no"| T429["429 Too Many Requests"]
RL -->|"yes"| H{"Which credential?"}
H -->|"X-API-Key"| K["Find the row by its indexed prefix,<br/>then verify the bcrypt hash"]
H -->|"Authorization: Bearer"| J["Verify the Keycloak<br/>token signature"]
H -->|"neither"| U401["401 Unauthorized"]
K --> KP["API key: confined to ONE tenant"]
J --> JP["Person: sees ALL tenants"]
KP --> SCOPE["Every query takes the caller's scope"]
JP --> SCOPE A Keycloak token, for a person¶
Authorization: Bearer <access token>
A person sees every tenant. That is the dashboard operator, who supervises the whole estate.
TOKEN=$(curl -s -X POST \
http://localhost:8080/realms/wardn/protocol/openid-connect/token \
-d grant_type=password -d client_id=wardn-spa \
-d username=operator -d password=operator \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/tenants
Signing keys are fetched from the realm's JWKS and cached, so a key rotation is picked up without a restart and wardn never holds a signing secret.
An API key, for a machine¶
X-API-Key: wapi_dev_acme_0000000000000001
A key is confined to a single tenant. Always.
curl -H "X-API-Key: wapi_dev_acme_0000000000000001" \
http://localhost:3000/api/v1/zones
Why the asymmetry? The two populations do not have the same need. An operator supervises a multi-customer fleet; a booking system at Acme has no reason to see Northwind. Modelling them as one type with an optional tenant would make confinement easy to forget, which is exactly what the code avoids: two distinct types, and every query has to say which scope it applies to.
What roles change¶
Almost nothing, and that is deliberate.
| Caller | Reach |
|---|---|
| Any valid realm token | Every tenant, every action except one |
A token holding wardn-admin | Adds POST /devices/{id}/actions/ota |
| A tenant API key | One tenant, and not POST /maintenance/retention |
POST /maintenance/retention is closed to API keys not because of a role but because the sweep is fleet-wide: an action that reaches across every tenant must not be reachable by a caller confined to one, whatever it is allowed to do inside its own.
Confinement in practice¶
# With Acme's key, only Acme's zones come back.
curl -H "X-API-Key: wapi_dev_acme_0000000000000001" \
http://localhost:3000/api/v1/zones
# Asking for a Northwind resource with Acme's key: 404.
curl -H "X-API-Key: wapi_dev_acme_0000000000000001" \
http://localhost:3000/api/v1/zones/0b000000-0000-4000-8000-000000000003
404, not 403. Answering "forbidden" would confirm the resource exists. For a client, another tenant's resource does not exist. A status that distinguishes "does not exist" from "not yours" lets a client map what it cannot read.
The filter is applied inside the query, not after it, so a caller can never be handed a row it should not have seen.
The endpoints¶
Health, no authentication¶
| Method | Path | Answer |
|---|---|---|
GET | /health | {status, instance}: the process is up, and which instance answered |
GET | /health/ready | {status, activityRows}: the database actually answers, or 503 |
instance matters behind a load balancer: it is the difference between "the backend is misbehaving" and "one of them is".
Tenants¶
| Method | Path | Notes |
|---|---|---|
GET | /tenants | Paginated |
GET | /tenants/{id} | |
POST | /tenants | {name, description?} |
GET | /tenants/{id}/api-keys | Never reveals a secret |
POST | /tenants/{id}/api-keys | {name, expiresAt?}: the plaintext is in this response and nowhere else |
DELETE | /tenants/{id}/api-keys/{keyId} | 204, effective immediately |
Zones¶
| Method | Path | Notes |
|---|---|---|
GET | /zones | Paginated |
GET | /zones/{id} | |
POST | /zones | {tenantId, name, description?, externalSiteId?, timezone?} |
timezone is an IANA zone and is what every instant belonging to this site is rendered in.
Hardware¶
| Method | Path | Notes |
|---|---|---|
GET | /doors | Filters: zoneId, controllerId |
GET | /doors/{id} | |
PATCH | /doors/{id} | {mode}: normal · unlocked · locked. Stored here and pushed to the cabinet |
POST | /doors/{id}/actions/open | Opens it now |
GET | /identification-devices | Filters: doorId, zoneId |
GET | /identification-devices/{id} |
Fleet¶
| Method | Path | Notes |
|---|---|---|
GET | /devices | Paginated. Filter: zoneId |
GET | /devices/fleet | One row per cabinet with its last report attached |
GET | /devices/{id} | |
GET | /devices/{id}/telemetry | The last report. 404 if it has never reported |
GET | /devices/{id}/links | Router admin pages, monitoring boards |
GET | /devices/{id}/documentation | Wiring diagrams, guides |
POST | /devices/{id}/actions/log-level | {level}: applied without restarting |
POST | /devices/{id}/actions/debug | {enabled, durationSeconds?}: capped |
POST | /devices/{id}/actions/full-resync | Reload the whole rights set |
POST | /devices/{id}/actions/restart | Restart the controller service |
POST | /devices/{id}/actions/reboot | Reboot the machine |
POST | /devices/{id}/actions/ota | {version}: wardn-admin only |
GET | /firmware | Published releases, newest first, with their digests |
GET | /alerts | limit up to 200, most recent first |
All the actions endpoints are asynchronous: they return once the command has been published. The cabinet's acknowledgement lands in the technical journal, and the next heartbeat is what proves it applied.
Identity and rights¶
| Method | Path | Notes |
|---|---|---|
GET | /users | Paginated |
GET | /users/{id} | |
POST | /users | {tenantId, email?, fullName?, description?, externalSubjectId?} |
DELETE | /users/{id} | 204 |
GET | /users/{id}/personal-data | Everything held about one person |
DELETE | /users/{id}/personal-data | Erase them, anonymise their passages |
GET | /access-rights | Filters: zoneId, activeOnly |
GET | /access-rights/{id} | |
POST | /access-rights | Creates a right and its credentials |
POST | /access-rights/{id}/credentials | Supersedes it with new credentials |
DELETE | /access-rights/{id} | 204, revokes it |
POST | /provisioning/access-rights | Idempotent create-or-replace |
email and fullName are both optional: a visitor may legitimately be known only by the QR code they were sent.
Activity¶
| Method | Path | Filters |
|---|---|---|
GET | /activity-logs | zoneId, doorId, controllerId, level, deniedOnly, from, to, plus pagination |
Instants in the response are UTC; each row carries zoneTimezone, which is the zone it should be rendered in.
Maintenance¶
| Method | Path | Notes |
|---|---|---|
POST | /maintenance/retention | Runs the purge now. Idempotent. Not open to an API key |
Provisioning¶
POST /api/v1/provisioning/access-rights is the front door for integrations, one right per call.
curl -X POST -H "X-API-Key: $KEY" -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
It is idempotent on (zoneId, externalId): the identifier the caller uses on its own side. Replaying the same request returns the same right instead of creating a second one. 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.
If the credentials differ, the existing right is superseded rather than edited, and the externalId follows the new revision. See 5. Access rights.
Omitting externalId makes the call an ordinary create, with no idempotency.
The fields¶
| Field | Required | Notes |
|---|---|---|
userId | yes | Must belong to the caller's tenant |
zoneId | yes | Must belong to the caller's tenant |
doorGroupId | no | Which doors the right reaches. Omitted means every door of the zone |
granted | no, default true | false is an explicit denial, which is not the same as no right |
validFrom / validTo | yes | ISO 8601. Inclusive / exclusive. validTo must be after validFrom |
sourceTimezone | no | The zone the window was expressed in, kept for display |
externalId | no | The idempotency key |
metadata | no | Free-form JSON, yours |
credentials | yes, at least one | [{type, value}] |
credentials[].type is free-form. Readers in the field produce vocabularies nobody controls, so a closed enum would mean a migration for each new reader. Use textRaw or numberRaw for an identification number whose structure wardn cannot interpret: unlike every other type, these two are matched exactly as given, with no trimming and no case-folding. See 5. Access rights.
Errors¶
Every failure is application/problem+json (RFC 9457), with the same shape:
{
"type": "https://wardn.local/problems/not-found",
"title": "not found",
"status": 404,
"detail": "no zone 0b000000-0000-4000-8000-000000000003",
"instance": "/api/v1/zones/0b000000-0000-4000-8000-000000000003",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
detail is the sentence written for a human, and the one the dashboard shows. On a validation failure it is joined from the individual messages, which are also listed in errors.
Anything unrecognised is a bug on our side, and the client learns nothing about it beyond the status.
| Status | When |
|---|---|
400 | The payload failed validation, or a cursor is malformed |
401 | No credential, or one that does not verify |
403 | wardn-admin required, or a tenant key on a fleet-wide action |
404 | Does not exist, or belongs to another tenant |
409 | Superseding a right that is already superseded |
429 | Past the rate limit |
500 | A bug here |
503 | A dependency did not answer (object storage, the database on /health/ready) |
Unknown properties in a request body are rejected, not ignored: a typo in a field name is a silent no-op otherwise.
Pagination¶
Collections answer:
{ "items": [ … ], "nextCursor": "MjAyNi0wOC0xMFQwNzoxNDoyMi4xMDRafDBkNWI…" }
Pass nextCursor back as cursor to get the next page. Its absence means this was the last one.
limit defaults to API_DEFAULT_PAGE_SIZE (50) and is capped at API_MAX_PAGE_SIZE (500), so a caller cannot ask for a million rows at once.
Why a cursor rather than an offset? Rows are inserted constantly here. An offset would silently skip or repeat entries as the table grows underneath a client walking it. The cursor is a keyset position: an instant and an id, which is stable whatever happens around it.
curl -H "Authorization: Bearer $TOKEN" \
'http://localhost:3000/api/v1/activity-logs?deniedOnly=true&limit=100'
Rate limiting¶
A global ceiling applies before authentication: RATE_LIMIT_MAX (600) requests per RATE_LIMIT_WINDOW_MS (60 000 ms).
Why before? Verifying a key costs a deliberately slow hash, and verifying a token costs a signature check. Without a ceiling, an unauthenticated caller can spend that work at will.
The live socket carries its own, lower ceiling: WS_HANDSHAKE_LIMIT (60) per address per window, because the upgrade is handled below the backend's routing and the first guard never sees it. A dashboard opens two sockets and keeps them; anything approaching that rate is a client in a reconnect loop or somebody trying keys.
Tracing¶
Every response carries X-Trace-Id, and every error carries the same id in its body.
curl -i -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
-H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/zones
The backend joins the caller's trace rather than starting its own, so an id minted by a gateway or by a test survives the whole way through.
An operator saying "it failed around two" gives support a haystack. The same operator quoting a trace id gives them the request, its queries and its broker calls.
Spans are created whether or not anything collects them, which is what keeps the id on every response. Set OTEL_EXPORTER_OTLP_ENDPOINT to export them, or OTEL_TRACES_CONSOLE=true to print them next to the logs.
Everything that changes state is audited¶
Every non-GET request that succeeds is recorded in control_plane_audit_logs with the caller's identity (person or key), the action, the target, the request payload and the source address.
This is driven by the HTTP method and registered globally, not by a decorator on each route: an action is otherwise audited only if somebody remembered to mark it, and the ones people forget are exactly the ones worth having in the trail.
Fields whose name contains password, secret, token, key or apiKey are replaced by [redacted].
Failed requests are not recorded: a rejected request changed nothing, and filling the trail with failures buries the actions that did happen.
That trail is immutable at the database level: the application role has neither UPDATE nor DELETE on it. See 11. Personal data.
Managing API keys¶
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"acme-booking-connector","expiresAt":"2027-01-01T00:00:00Z"}' \
http://localhost:3000/api/v1/tenants/$TENANT/api-keys
The response contains plainKey. That is the only time it exists; only a bcrypt hash is stored, and it is computed inside PostgreSQL so the plaintext never travels further than that response.
A key looks like wapi_live_ followed by 24 random bytes in base64url. The first sixteen characters are stored in the clear and indexed.
flowchart LR
K["wapi_live_AbCdEf…"] --> P["first 16 characters<br/>indexed lookup"]
P --> ROW["exactly one row"]
ROW --> H["bcrypt compares<br/>the full key"]
H --> OK["accepted / refused"] Why that detail matters. Without a usable prefix, verifying a key means comparing it against every active key, each at the price of a deliberately slow hash. At a hundred customers that is a hundred computations per authenticated request. And since an invalid key is only known to be invalid after the last comparison, an unauthenticated caller could set that work going at will.
The prefix narrows; it never decides. Somebody who guesses one gains a row lookup and still has to produce the key.
A key can carry an expiry, and revoking it takes effect immediately.
The live socket¶
Two paths, both taking the same credentials as the REST surface.
| Path | Who | What it carries |
|---|---|---|
/ws/activity | Person or API key | ready, activity, fleet messages |
/ws/debug?device=edge-01 | Person only | ready, debug messages from that one cabinet |
wscat -c "ws://localhost:3000/ws/activity?token=$TOKEN"
wscat -c "ws://localhost:3000/ws/activity?apiKey=$KEY"
A browser cannot set headers on a WebSocket handshake, so the credential travels in the query string. That is the one place it is unavoidable, and the connection is expected to be TLS in production.
Messages are filtered per subscriber as they are sent, with the same tenant confinement the REST surface applies. The debug console is closed to API keys (403): raw frames carry no tenant, and confining what cannot be confined is worse than saying no.
The socket keeps no history. On attach, re-read what you missed over REST.
A worked integration¶
# 1. Get a key for your tenant (once, by an operator).
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"booking-connector"}' \
http://localhost:3000/api/v1/tenants/$TENANT/api-keys
# 2. Find the zone you are provisioning for.
curl -H "X-API-Key: $KEY" http://localhost:3000/api/v1/zones
# 3. Create the person, if they are new to you.
curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"tenantId":"'$TENANT'","fullName":"Sophie Martin","email":"sophie@acme.example"}' \
http://localhost:3000/api/v1/users
# 4. Provision the right. Replay this as often as you like.
curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"userId":"'$USER'","zoneId":"'$ZONE'",
"validFrom":"2026-08-11T07:00:00Z","validTo":"2026-08-11T19:00:00Z",
"externalId":"booking-88213",
"credentials":[{"type":"licensePlate","value":"1-ABC-123"}]}' \
http://localhost:3000/api/v1/provisioning/access-rights
# 5. Read back what happened at the doors.
curl -H "X-API-Key: $KEY" \
'http://localhost:3000/api/v1/activity-logs?zoneId='$ZONE'&limit=100'
The right reaches the cabinet within seconds of step 4, and works at the door even if the site loses its uplink immediately afterwards.
The interface is understood. Let us look at what protects the whole.
Next → 10. Security