Access Control
The Access Control module (id access_control, v1.0.0) gives you a structured data layer for physical security: doors, readers, access controllers, cardholders, credentials, and access schedules. All CRUD operations work, all data persists correctly, and an HMAC-chained event log records every access event. What is absent is the hardware bridge - the adapters that would translate an API call into a signal on an actual door relay.
What works
Section titled “What works”Data model
Section titled “Data model”The following objects are fully CRUD-operational via the API:
| Object | What it represents |
|---|---|
| AccessController | A physical access-control panel or reader hub (vendor, model, host, site) |
| Door | A named door with default unlock time and associated readers |
| Cardholder | A person record holding one or more credentials |
| AccessCredential | A card number, PIN, or mobile credential assigned to a cardholder |
| AccessSchedule | A named schedule (weekday windows, time zones) attachable to credentials |
| AccessEvent | Immutable log entries: granted / denied / forced / held-open / unlocked / locked / alarm |
Credential encryption
Section titled “Credential encryption”- PIN values are hashed with Argon2id - they are not stored in plaintext or reversible.
- Card number and facility code are Fernet-encrypted at rest (column-level, applied in migration 013).
- API responses use dedicated response schemas (
AccessCredentialResponse,AccessCredentialListResponse) that strip the PIN hash and card ciphertext - these fields never leave the server.
Access event log and tamper evidence
Section titled “Access event log and tamper evidence”Access events are written with an HMAC hash chain. Each event’s hash covers its own fields plus the hash of the preceding event, so any tampering with historical records is detectable. You can validate the chain for a door over a time range:
GET /api/v1/access_control/access/events/chain/validate?door_id=<uuid>&start_time=<iso>&end_time=<iso>The event_retention_days setting is defined in the module schema but automatic pruning of access events is not available in this release. Use pg_dump or the pg-backup container for long-term retention management.
Fabric event bus
Section titled “Fabric event bus”Access events are emitted on the Fabric bus regardless of adapter state. You can wire them to other module operations in Connections today - no adapter is needed for events:
| Event | Trigger |
|---|---|
access.door.granted | A credential passed at a reader |
access.door.denied | A credential was rejected |
access.door.forced | Door opened without a valid credential |
access.door.held_open | Door held beyond its unlock window |
access.door.unlocked | Door unlocked (software command or credential) |
access.door.locked | Door locked |
access.door.alarm | An access-control alarm was raised |
Example connection: wire access.door.forced → cameras.snapshot → storage.store_blob to automatically capture and archive a still image whenever a door is forced. See Connections for how to author this in Fabric.
Celery task: auto-relock
Section titled “Celery task: auto-relock”A access_control.relock_door_after Celery task is registered. When unlock is called, the task is scheduled to flip the door’s DB state back to locked after the unlock window expires. The task updates DB-side state only because no hardware adapter ships today.
What does not work
Section titled “What does not work”Door lock and unlock
Section titled “Door lock and unlock”POST /api/v1/access_control/access/doors/{door_id}/lock → 501 DoorControlUnavailablePOST /api/v1/access_control/access/doors/{door_id}/unlock → 501 DoorControlUnavailableAccessControlService._get_door_adapter() inspects every registered adapter and returns None because none implement lock_door / unlock_door. The 501 response is intentional. Both endpoints return 501 without writing an AccessEvent row. DoorControlUnavailableError is raised immediately after the adapter check returns None, before the _store_event('remote_lock' / 'remote_unlock') call that follows the adapter invocation. An audit entry is written only after a successful hardware-adapter call, which today is never reached. Site-limited operators get 404 for doors outside their grant boundary. Rejected lock/unlock attempts do not create an AccessEvent row in this release.
Anti-passback
Section titled “Anti-passback”Anti-passback enforcement appeared in an earlier version of the module manifest. It was never implemented - no code tracks a last-access-zone per credential or enforces re-entry rules. The settings fields have been removed from the schema. Anti-passback does not appear in the settings panel.
Frontend UI
Section titled “Frontend UI”The current frontend page at /access is a placeholder. There is no working management UI. All management must go through the API directly.
API reference
Section titled “API reference”The module router mounts at /api/v1/access_control/access/. All endpoints enforce require_permissions(...). The service builds per-request with your organization ID and respects per-user site grants (accessible_site_ids for site-limited users) - requests for resources outside your grant return 403 with a generic message (no ID disclosure).
| Method | Path | Purpose | Permission |
|---|---|---|---|
GET | /doors | List doors (site_id, controller_id, door_status; limit 1-100) | access.view |
GET | /doors/stats | Aggregate door stats by site | access.view |
GET | /doors/{door_id} | Get one door | access.view |
POST | /doors | Create door (201) | access.manage_doors |
PATCH | /doors/{door_id} | Update door | access.manage_doors |
DELETE | /doors/{door_id} | Delete door (204) | access.manage_doors |
POST | /doors/{door_id}/lock | Lock door - returns 501 | access.door_control |
POST | /doors/{door_id}/unlock | Unlock door - returns 501 (query: duration 1-300 s) | access.door_control |
Credentials
Section titled “Credentials”| Method | Path | Purpose | Permission |
|---|---|---|---|
GET | /credentials | List (cardholder_id, credential_type, is_active); strips hash/ciphertext | access.view |
GET | /credentials/{credential_id} | Get one; strips hash/ciphertext | access.view |
POST | /credentials | Create (201); PIN Argon2id-hashed, card/facility Fernet-encrypted | access.manage_credentials |
PATCH | /credentials/{credential_id} | Update | access.manage_credentials |
POST | /credentials/{credential_id}/revoke | Revoke (sets is_active=false) | access.manage_credentials |
Cardholders
Section titled “Cardholders”| Method | Path | Purpose | Permission |
|---|---|---|---|
GET | /cardholders | List (site_id, is_active, search; limit/offset) | access.view |
GET | /cardholders/{cardholder_id} | Get one | access.view |
POST | /cardholders | Create (201) | access.manage_credentials |
PATCH | /cardholders/{cardholder_id} | Update | access.manage_credentials |
DELETE | /cardholders/{cardholder_id} | Delete (204) | access.manage_credentials |
Schedules
Section titled “Schedules”| Method | Path | Purpose | Permission |
|---|---|---|---|
GET | /schedules | List (site_id; limit/offset) | access.view |
GET | /schedules/{schedule_id} | Get one | access.view |
POST | /schedules | Create (201) | access.manage_schedules |
PATCH | /schedules/{schedule_id} | Update | access.manage_schedules |
DELETE | /schedules/{schedule_id} | Delete (204) | access.manage_schedules |
Controllers
Section titled “Controllers”| Method | Path | Purpose | Permission |
|---|---|---|---|
GET | /controllers | List (site_id; limit/offset) | access.view |
GET | /controllers/{controller_id} | Get one | access.view |
POST | /controllers | Create (201) | access.manage_doors |
PATCH | /controllers/{controller_id} | Update | access.manage_doors |
DELETE | /controllers/{controller_id} | Delete (204) | access.manage_doors |
Events
Section titled “Events”| Method | Path | Purpose | Permission |
|---|---|---|---|
GET | /events | Search events (door_id, cardholder_id, event_type, start_time, end_time; limit 1-1000) | access.view_events |
GET | /events/stats | Aggregate stats (site_id, time range) | access.view_events |
POST | /events/{event_id}/ack | Acknowledge event (records actor + timestamp) | access.view_events |
GET | /events/chain/validate | Validate HMAC hash chain for tamper detection | access.view_events |
Permissions
Section titled “Permissions”| Permission code | What it gates | Minimum role |
|---|---|---|
access.view | Read doors, readers, controllers | viewer |
access.view_events | Read access event log | viewer |
access.manage_doors | Create/update/delete doors, readers, controllers | site_admin |
access.manage_credentials | Issue, update, and revoke credentials; manage cardholders | site_admin |
access.manage_schedules | Create and edit access schedules | site_admin |
access.door_control | Call lock/unlock endpoints (currently always 501) | operator |
For the full role hierarchy see Roles and Permissions.
Module settings
Section titled “Module settings”Two settings are configurable per organization under Settings → Modules → Access Control:
| Setting | Default | Range | Description |
|---|---|---|---|
default_door_unlock_time | 5 s | 1-60 s | How long a door stays unlocked after a valid credential (hardware-side effect only when an adapter ships) |
event_retention_days | 90 | 30-365 | Retention period (days). Defined in schema; pruning is not available in this release |
Migration dependency
Section titled “Migration dependency”Column-level encryption for credentials (PIN Argon2id hash column, card number and facility code Fernet columns) is applied in migration 013. If you are running a fresh install this is applied automatically. If you are upgrading from a pre-013 instance, run alembic upgrade head before enabling the module.
Honesty summary
Section titled “Honesty summary”| Claim | Accurate status |
|---|---|
| Door CRUD, credential CRUD, cardholder CRUD, schedule CRUD | Works |
| Tamper-evident HMAC event log | Works |
| Fabric event emission | Works |
| Per-site tenant isolation and per-user site grants | Works |
| Credential encryption at rest | Works (requires migration 013) |
| Frontend management UI | API-only placeholder |
| Door lock / unlock | HTTP 501 - no adapter for any vendor |
| Anti-passback | Removed - was never implemented |
Next steps
Section titled “Next steps”- Fabric Connections - wire
access.door.forcedoraccess.door.deniedto downstream actions (camera snapshots, notifications, webhooks) today. - Roles and Permissions - understand the 7-tier hierarchy and how site grants interact with
access.manage_doorsandaccess.door_control. - Security Model - learn how FreeSDN enforces tenant isolation at the application layer and how credentials are protected at rest.
- Configuration Backup - access-control configuration (doors, cardholders, credentials, schedules) is NOT included in
.fsdnsnapshots. No backup contributor has been implemented for this module (AccessControlModulehas noget_backup_contributor()hook, andCoreBackupContributorcovers only sites, controllers, devices, users, and automation rules). Use your database backup (pg_dumpor thepg-backupcontainer) to preserve access control data.