Skip to content

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.


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

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.forcedcameras.snapshotstorage.store_blob to automatically capture and archive a still image whenever a door is forced. See Connections for how to author this in Fabric.

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.


POST /api/v1/access_control/access/doors/{door_id}/lock → 501 DoorControlUnavailable
POST /api/v1/access_control/access/doors/{door_id}/unlock → 501 DoorControlUnavailable

AccessControlService._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 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.

The current frontend page at /access is a placeholder. There is no working management UI. All management must go through the API directly.


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

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.


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

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 to bring the schema current (the module itself is coming soon and cannot be enabled yet).


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

  • Fabric Connections - wire access.door.forced or access.door.denied to downstream actions (camera snapshots, notifications, webhooks) today.
  • Roles and Permissions - understand the 5-tier hierarchy and how site grants interact with access.manage_doors and access.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 .fsdn snapshots. No backup contributor has been implemented for this module (AccessControlModule has no get_backup_contributor() hook, and CoreBackupContributor covers only sites, controllers, devices, users, and automation rules). Use your database backup (pg_dump or the pg-backup container) to preserve access control data.

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.