Skip to content
.NETAccessControl.ApiC# · .NET 8 · ASP.NET Core

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.Api

  1. info: Microsoft.Hosting.Lifetime[14]Now listening on: http://localhost:5080
  2. info: AccessControl.Application.Access.AccessService[1000]Access granted at door 6b1e2c90-…: Granted (event c1f0e5aa-…)
  3. info: AccessControl.Application.Access.AccessService[1000]Access denied at door a40d7f13-…: NoPermissionForDoor (event 52be19d4-…)
  4. 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.

Card, as the reader sends it
Door
When (Pacific/Auckland)
  1. 1door is not in lockdownokDoorInLockdown
  2. 2card number matches a credentialokUnknownCredential
  3. 3credential is ActiveokCredentialNotActive
  4. 4credential has not expiredokCredentialExpired
  5. 5its cardholder existsokCardholderNotFound
  6. 6cardholder is enabledokCardholderDisabled
  7. 7now is inside the validity periodokCardholderOutsideValidityPeriod
  8. 8one of their groups covers the doorokNoPermissionForDoor
  9. 9one of those groups' schedules is activeokOutsideSchedule
POST /api/v1/access/requestsX-Api-Key: •••• (DoorController)
{
  "doorId": "6b1e2c90-…",
  "cardNumber": "4e-5f-60-71"
}
200 · what the controller hearsgranted
{
  "eventId": "c1f0e5aa-…",
  "granted": true,
  "evaluatedAt": "2026-10-07T05:00:00+00:00"
}
access-events.jsonl · data, written first
{
  "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.

Someone with write access to the file
Server configuration
data/access-events.jsonl
    GET /api/v1/audit/verify
    …

    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.

    PolicyRolesCovers
    ReadAdminOperatorAuditorevery GET
    EnrolAdminOperatorcardholder and credential writes, group membership
    ConfigureAdmindoor, schedule and group writes; release a lockdown
    LockdownAdminOperatordoors/{id}/lockdown
    AuditAdminAuditoraccess/events, audit/changes, audit/verify
    DoorControllerDoorControlleraccess/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.

    Fig. 1

    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).

    Fig. 2

    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 ← hash

    The trail is written before the store (highlighted): if the disk write fails, the log over-reports rather than under-reports.

    Fig. 3

    The stack, layer by layer

    L01AccessControl.ApiMinimal API hostHashed API keys; unsafe keys, no HMAC key or AllowedHosts=* stop startupFixed-window rate limits per key and per IP; /health has its own budgetErrors as RFC 7807 problems with stable codesL02AccessControl.InfrastructureAdapters for the portsDocument store: immutable snapshot, unique indexes, foreign keys, optimistic versionsAudit logs: rotation, signed retention anchor, crash repairL03AccessControl.ApplicationOne service per resource, one commit per commandRepository, unit-of-work, event-log and actor portsNo ASP.NET Core reference — a test enforces itL04AccessControl.DomainAggregates and the evaluatorCard numbers canonicalised, so 0a:1b matches 0A1BLost and Revoked credentials are terminal

    The domain references nothing.

    Fig. 4

    One badge read, from key to answer

    1AuthenticateHash the key, rate-limit it, check the door is on its list.2ClockRead the time once for decision and event.3LoadEverything from one snapshot.4EvaluateNine checks; the first failure is the reason.5AppendChain and flush the event.6AnswerGranted or not. Any non-200 is a deny.

    One unit of work, from entry to the point someone reads the result.