Backend · C# / .NET 8
Access Control API
A .NET 8 door-access API that fails closed, tells doors only yes or no, and hash-chains every read and change.
Operators manage cardholders, cards, doors, schedules and access groups; door controllers get only granted or denied.
$ dotnet run --project src/AccessControl.ApiDevelopment
- info: Microsoft.Hosting.Lifetime[14]Now listening on: http://localhost:5080
- info: AccessControl.Application.Access.AccessService[1000]Access granted at door 6b1e2c90-…: Granted (event c1f0e5aa-…)
- info: AccessControl.Application.Access.AccessService[1000]Access denied at door a40d7f13-…: NoPermissionForDoor (event 52be19d4-…)
- warn: AccessControl.Api.Endpoints.AccessEndpoints[4001]Controller key ctrl-lobby submitted a read for door e8c35b27-…, which it is not assigned to
- Runtime
- C# 12.NET 8ASP.NET Core minimal API
- Security
- Hashed API keysRole policiesRate limitingHMAC-SHA256
- Storage
- JSON document storeHash-chained JSONLAtomic rename
- Tests
- xUnitWebApplicationFactoryTimeProvider
- Ship
- Docker (non-root)GitHub ActionsSystem.Diagnostics.Metrics
- Denial reasons
- 9each one a unit test
- Tests
- 131xUnit, no external services
- Roles
- 4six named policies
- Controller limit
- 120/minper key; a 429 is a deny
- Build warnings
- 0warnings are errors in src/
AccessEvaluator.Evaluate(context)
Nine checks, in a fixed order. Anything missing is a deny.
Pick a card, a door and a time; the first failing check wins. Try Grace at the Front door at 19:00 — windows are half-open.
- 1door is not in lockdownokDoorInLockdown
- 2card number matches a credentialokUnknownCredential
- 3credential is ActiveokCredentialNotActive
- 4credential has not expiredokCredentialExpired
- 5its cardholder existsokCardholderNotFound
- 6cardholder is enabledokCardholderDisabled
- 7now is inside the validity periodokCardholderOutsideValidityPeriod
- 8one of their groups covers the doorokNoPermissionForDoor
- 9one of those groups' schedules is activeokOutsideSchedule
{
"doorId": "6b1e2c90-…",
"cardNumber": "4e-5f-60-71"
}{
"eventId": "c1f0e5aa-…",
"granted": true,
"evaluatedAt": "2026-10-07T05:00:00+00:00"
}{
"occurredAt": "2026-10-07T05:00:00+00:00",
"doorId": "6b1e2c90-…",
"cardNumber": "4E5F6071",
"credentialId": "3d9b41c2-…",
"cardholderId": "7a52e0f8-…",
"granted": true,
"reason": "Granted"
}The reason is only in the log. Schedules use Auckland time; events are stamped in UTC.
ChainedJsonLinesLog
Change a line, and the chain says which
Tamper with the file and watch audit/verify find it. The hashes are real, computed with the server's formula.
…
Each hash covers the sequence number, previous hash and data, so no line can change, move or vanish unnoticed.
Api/Auth/Policies.cs
Four roles, six policies, one per endpoint group
A controller key can do one thing, for its own doors.
| Policy | Roles | Covers |
|---|---|---|
| Read | AdminOperatorAuditor | every GET |
| Enrol | AdminOperator | cardholder and credential writes, group membership |
| Configure | Admin | door, schedule and group writes; release a lockdown |
| Lockdown | AdminOperator | doors/{id}/lockdown |
| Audit | AdminAuditor | access/events, audit/changes, audit/verify |
| DoorController | DoorController | access/requests, own doors only |
CLAUDE.md
Dependencies point inward; the domain references nothing
Four projects: a dependency-free Domain, Application services and ports, file-backed Infrastructure, and an API host for keys, policies and rate limits.
A badge read is audited before it is answered
Steps 1–4 run before the door is looked up, so a key cannot discover other doors. The event, reason included, is flushed before the answer; the controller hears only granted or not (dashed).
A commit writes its trail before it takes effect
FUNCTION DocumentStore.Commit(changes, actor)AWAIT commitGate # commits are serialised; reads never locknext ← apply(snapshot, changes)fail if any version moved # 409 concurrency_conflictfail if a unique index clashes # e.g. a card number reusedfail if a reference dangles # e.g. a door deleted while a group uses itchangeLog.Append(actor, before, after per aggregate)write next to store.json.tmp; fsync; renamesnapshot ← next # only now can a request see itFUNCTION ChainedJsonLinesLog.Append(data)seq ← last + 1hash ← H(seq + "\n" + prev + "\n" + data) # HMAC-SHA256 when keyedwrite {seq, prev, hash, data}; flush to diskprev ← hashThe trail is written before the store (highlighted): if the disk write fails, the log over-reports rather than under-reports.
The stack, layer by layer
The domain references nothing.
One badge read, from key to answer
One unit of work, from entry to the point someone reads the result.