Modeling Vault Access Events in Python
A frozen Python dataclass for vault and cabinet access events with dual-witness validation for Schedule II and SHA-256 chaining into the audit ledger.
A vault-access event looks trivial to model until you try to make it stand up in an audit. The naive version is a row with a user id, a door id, and a timestamp — and it fails the moment an inspector asks who witnessed a Schedule II opening, or whether the same badge that opened the safe also “witnessed” itself. A defensible model has to encode the roles, distinguish the actor from the witness, refuse a Schedule II open that carries only one identity, and bind all of it into a tamper-evident hash so the record cannot be quietly rewritten after the fact. This recipe builds that model as a frozen Python dataclass with validation and SHA-256 chaining, solving exactly the problem of turning a physical door event into a compliant ledger entry. It sits within Physical Storage & Security Requirements, which specifies the enclosure classes and dual-presence rules this event enforces.
The design target is narrow and concrete: one immutable event type, three validation invariants (a resolved actor, a distinct witness for Schedule II, a chained hash), and a verification block that proves each invariant holds. When those hold in one place, a badge tap at a vault door becomes a record an auditor can trust rather than a log line that has to be defended.
Problem framing
The parent topic establishes why a Schedule II enclosure requires two identities under 21 CFR § 1301.72(a) and why every opening must be recorded to demonstrate the effective controls of 21 CFR § 1301.71. What it does not do — and what this page supplies — is the exact Python type that carries those guarantees. The problem is deceptively specific: model an access event so that (a) it is immutable once created, (b) it cannot be constructed in a valid state for a Schedule II open without a distinct witness, and © its hash chains to the previous event so any post-hoc edit is detectable. Get those three right and the event is safe to feed an append-only ledger; get any one wrong and the record is either mutable, forgeable, or incomplete.
Prerequisites & environment
- Python 3.11+ — the recipe uses
dataclasses(frozen=True),enum.Enum, and union-type syntax (str | None). - Standard library only:
dataclasses,enum,hashlib,json,logging,datetime,secrets. Addpytestonly for the test block. - Regulatory context you should already hold:
21 CFR § 1301.72(physical-security controls, including the safe/vault standard for Schedule I–II),21 CFR § 1301.75(securely locked cabinet for Schedule II–V), and45 CFR § 164.312(b)(audit controls). The dual-presence obligation for the most sensitive enclosures is the invariant this model enforces in code. - A resolved actor identity is assumed to arrive from the identity system; this recipe does not authenticate credentials, it records the resolved identities. Downstream, access frequency feeds the detectors described in Diversion Alert Thresholds.
Implementation: a frozen access-event with dual-witness validation
The model separates three concerns: the immutable event (VaultAccessEvent), the invariant checks (_validate), and the chaining commit (AccessLedger.append). Validation runs inside __post_init__ so an event that violates a Schedule II invariant cannot exist as a valid object — the failure happens at construction, before anything reaches the ledger.
from __future__ import annotations
import hashlib
import json
import logging
import secrets
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from enum import Enum
logger = logging.getLogger("cs_ledger.vault")
class ZoneClass(str, Enum):
VAULT_CII = "VAULT_CII" # § 1301.72(a) — dual presence
CAGE_CII = "CAGE_CII" # § 1301.72(a) — dual presence
CABINET_CIII_V = "CABINET_CIII_V" # § 1301.75 — single identity
ADC_POCKET = "ADC_POCKET" # § 1301.75
class AccessAction(str, Enum):
UNLOCK = "UNLOCK"
OPEN = "OPEN"
CLOSE = "CLOSE"
OVERRIDE = "OVERRIDE"
_DUAL_PRESENCE_ZONES = {ZoneClass.VAULT_CII, ZoneClass.CAGE_CII}
_WITNESSED_ACTIONS = {AccessAction.OPEN, AccessAction.OVERRIDE}
class AccessValidationError(ValueError):
"""Raised when an access event violates a physical-security invariant."""
@dataclass(frozen=True)
class VaultAccessEvent:
"""Immutable physical-access record for a controlled-substance enclosure.
Frozen so a committed opening cannot be mutated in place; corrections are
new forward-chained events, satisfying 21 CFR § 1301.72 recordkeeping and
the 45 CFR § 164.312(b) integrity requirement. Schedule II invariants are
checked at construction so an invalid event cannot exist.
"""
event_id: str
enclosure_id: str
zone_class: ZoneClass
action: AccessAction
actor_id: str
actor_role: str
witness_id: str | None = None
witness_role: str | None = None
previous_hash: str = "0" * 64
timestamp: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
nonce: str = field(default_factory=lambda: secrets.token_hex(16))
def __post_init__(self) -> None:
self._validate()
def _validate(self) -> None:
if not self.actor_id:
raise AccessValidationError("actor_id is required (unresolved credential).")
needs_witness = (
self.zone_class in _DUAL_PRESENCE_ZONES
and self.action in _WITNESSED_ACTIONS
)
if needs_witness:
if not self.witness_id:
raise AccessValidationError(
f"{self.zone_class.value} {self.action.value} requires a witness."
)
if self.witness_id == self.actor_id:
raise AccessValidationError(
"Witness must be a distinct identity from the actor."
)
def compute_hash(self) -> str:
"""SHA-256 over the full payload, chained to the prior event."""
payload = json.dumps(asdict(self), sort_keys=True, default=str)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@dataclass
class AccessLedger:
"""Minimal append-only chain; a real deployment persists to WORM storage."""
_last_hash: str = "0" * 64
def append(self, **kwargs) -> tuple[VaultAccessEvent, str]:
event = VaultAccessEvent(previous_hash=self._last_hash, **kwargs)
record_hash = event.compute_hash()
self._last_hash = record_hash
# Structured, PHI-free audit line: identities, zone, and hash only.
logger.info(
"vault_access_committed",
extra={
"event_id": event.event_id,
"enclosure": event.enclosure_id,
"zone_class": event.zone_class.value,
"action": event.action.value,
"actor": event.actor_id,
"witness": event.witness_id or "",
"record_hash": record_hash,
},
)
return event, record_hash
The frozen dataclass is what makes the record safe for an append-only ledger: once constructed, the binding of actor, witness, zone, and timestamp cannot be edited, so the only way to “change” an opening is to append a new event that references it. Running the validation in __post_init__ means a Schedule II open with a missing or self-matching witness raises before the object exists — there is no window in which an invalid event is holdable.
Verification & testing
Two properties must be proven: that the Schedule II dual-witness invariant is enforced at construction, and that the hash actually chains so tampering is detectable. The block below covers the accept and reject paths plus the chain linkage.
import pytest
def test_schedule_ii_open_requires_distinct_witness():
# Missing witness on a vault open is rejected at construction.
with pytest.raises(AccessValidationError):
VaultAccessEvent(
event_id="e1", enclosure_id="VAULT-A", zone_class=ZoneClass.VAULT_CII,
action=AccessAction.OPEN, actor_id="rph-1", actor_role="pharmacist",
)
# A witness identical to the actor is rejected (self-witness / badge share).
with pytest.raises(AccessValidationError):
VaultAccessEvent(
event_id="e2", enclosure_id="VAULT-A", zone_class=ZoneClass.VAULT_CII,
action=AccessAction.OPEN, actor_id="rph-1", actor_role="pharmacist",
witness_id="rph-1", witness_role="pharmacist",
)
# A distinct witness is accepted.
ok = VaultAccessEvent(
event_id="e3", enclosure_id="VAULT-A", zone_class=ZoneClass.VAULT_CII,
action=AccessAction.OPEN, actor_id="rph-1", actor_role="pharmacist",
witness_id="tech-9", witness_role="technician",
)
assert ok.witness_id == "tech-9"
def test_ciii_v_cabinet_allows_single_identity():
ev = VaultAccessEvent(
event_id="e4", enclosure_id="CAB-3", zone_class=ZoneClass.CABINET_CIII_V,
action=AccessAction.OPEN, actor_id="tech-9", actor_role="technician",
)
assert ev.witness_id is None
def test_hash_chain_detects_tampering():
ledger = AccessLedger()
_, h1 = ledger.append(
event_id="e5", enclosure_id="VAULT-A", zone_class=ZoneClass.VAULT_CII,
action=AccessAction.OPEN, actor_id="rph-1", actor_role="pharmacist",
witness_id="tech-9", witness_role="technician",
)
ev2, h2 = ledger.append(
event_id="e6", enclosure_id="VAULT-A", zone_class=ZoneClass.CABINET_CIII_V,
action=AccessAction.CLOSE, actor_id="rph-1", actor_role="pharmacist",
)
# Second event chains to the first.
assert ev2.previous_hash == h1
# Recomputing the hash of an untouched event reproduces the stored digest.
assert ev2.compute_hash() == h2
The emitted audit line is the compliance artifact an inspector reviews. Capturing it confirms that identities and the chain hash are present and that no patient or clinical data leaks into the physical-security record:
INFO cs_ledger.vault vault_access_committed \
event_id=e5 enclosure=VAULT-A zone_class=VAULT_CII action=OPEN \
actor=rph-1 witness=tech-9 record_hash=b7d1...c4a9
Recomputing compute_hash() for any stored event and comparing it to the persisted digest is the inspection check: a mismatch proves the row was altered after commitment, which is exactly the tamper signal a 45 CFR § 164.312(b) audit is meant to surface.
Gotchas & compliance pitfalls
- Badge sharing defeats identity, not the chain. The model records the identity a credential resolves to, so two people sharing one badge will log as one actor and can even satisfy the witness check falsely if a second shared badge is used. The chain still proves the record is untampered; catching credential abuse requires a second factor for Schedule II zones plus access-pattern monitoring. Do not treat a passing dual-witness check as proof two humans were present.
- Clock skew corrupts ordering and hashes. The event timestamp comes from the door controller; a controller with a drifting clock will emit events that appear out of order and whose timestamps disagree with the ledger service. Stamp events in UTC, discipline controller clocks to NTP, and treat a timestamp far outside the expected window as an anomaly rather than committing it blindly — the timestamp is inside the hash, so a wrong clock produces a permanent wrong record.
- Tailgating is invisible to the model. A door that opens once but admits two people records a single access; the second person leaves no trace. The event model cannot see this, so it must not be presented as proof of who was physically inside. Pair it with anti-passback hardware or interior sensors where the diversion risk justifies it.
witness_id == actor_idis a real attack, not an edge case. Systems that only check “is a witness present” without checking distinctness let one identity witness itself. The distinctness check must live in the model, not in a UI validator a determined user can skip.- Overrides need the same ceremony as opens. An emergency key or manual override is exactly when controls are most likely to lapse, so
OVERRIDEis treated as a witnessed action for Schedule II zones. An override that bypasses the witness requirement should be a quarantined anomaly, not a convenience path.
Frequently Asked Questions
Why validate in __post_init__ instead of a separate function?
Because it makes an invalid Schedule II event unconstructable. If validation lives in a separate function that callers must remember to invoke, a code path that forgets it can create and even persist an event that violates the dual-witness rule. Running the check in __post_init__ means the type itself guarantees the invariant — there is no valid VaultAccessEvent for a witnessless Schedule II open — which is a stronger guarantee than a convention that a reviewer has to police.
Should the witness sign the event, or is recording the identity enough?
Recording the resolved witness identity satisfies the recordkeeping expectation, but a higher-assurance deployment binds a second-factor confirmation (a second badge tap or biometric) from the witness at the moment of access, so the witness identity is authenticated rather than asserted. The dataclass carries witness_id and witness_role; whether that identity was independently authenticated is a property of the identity system feeding it, and for Schedule II zones it should be.
How does this event relate to the transactional dispensing record?
They are separate events in the same chain. A vault open is a physical-custody event; a dispense is a disposition event. Correlating them — an ADC-pocket access immediately before a dispense or a waste — is how physical and transactional custody are reconciled, but each is committed independently so that the absence of one relative to the other is itself detectable rather than hidden.
Can this run on an offline door controller?
Yes, with care. The controller buffers constructed events locally and replays them on reconnect. Because validation runs at construction, a Schedule II event that lacks a witness never buffers in the first place; and because the hash chains on append, the central ledger re-establishes ordering on replay. An event that was accepted locally but fails a central check (for example, an actor id that no longer resolves) is quarantined rather than committed.
Related
- Physical Storage & Security Requirements — parent topic: enclosure classes, dual-presence rules, and the full access workflow
- Controlled Substance Storage & Handling Compliance — the append-only ledger and dual-control architecture this event feeds
- Diversion Alert Thresholds — how access frequency and off-hours openings become diversion signals