Implementing RBAC for LIMS Result Release in Python
Problem Statement
Who is allowed to release a clinical result is not a UI concern — it is a regulated access-control boundary, and getting it wrong lets an unqualified user push an unverified value onto a patient chart or amend a released result without a trace. A LIMS must enforce that only a qualified user may verify a result, only an authorized user may release it to the ordering provider, and only a narrow role may amend an already-released result, with every one of those decisions — allow and deny — recorded. This page implements that as deny-by-default role-based access control in Python: a Pydantic v2 role and permission model, an enforcement decorator that wraps each release-path action, and an audit record for every access decision so an inspector can prove who could do what, and who actually did. The default answer to “may this user perform this action” is always no until a role explicitly grants it.
Prerequisites
Before this becomes the gate on the release path, confirm:
- Runtime: Python 3.11+,
pydantic>=2.6for the role/permission/subject models,pytest>=8for the enforcement fixtures. - Authenticated identity: authentication is already solved upstream — this stage receives a verified
Subject(a signed-in analyst or pathologist), not a raw credential; RBAC is authorization, not authentication. - Audit sink: an append-only, tamper-evident store as built in Implementing HIPAA-Compliant Audit Trails in LIMS, because an access decision that is not recorded did not, for compliance purposes, happen.
- Release state machine: the result lifecycle (unverified → verified → released → amended) is owned by the CLIA/CAP data boundaries phase machine; RBAC guards the transitions, it does not define them.
Step-by-Step Implementation
Step 1: Model permissions, roles, and the subject
Model permissions as an enum so a typo cannot invent a permission, roles as a named set of permissions, and the subject as the authenticated user carrying its granted roles. Keeping permissions closed (an enum, not free strings) is what makes deny-by-default enforceable — an action can only require a permission that exists.
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
class Permission(str, Enum):
result_verify = "result:verify"
result_release = "result:release"
result_amend = "result:amend"
result_view = "result:view"
class Role(BaseModel):
name: str
permissions: frozenset[Permission]
class Subject(BaseModel):
user_id: str
display_name: str
roles: tuple[Role, ...] = Field(default_factory=tuple)
def granted(self) -> frozenset[Permission]:
acc: frozenset[Permission] = frozenset()
for role in self.roles:
acc |= role.permissions
return acc
def can(self, permission: Permission) -> bool:
return permission in self.granted()
Step 2: Define the role catalog with least privilege
Define roles so each grants the narrowest set that its clinical function requires. A technologist may verify but not release; a pathologist or lab director may release and amend; a phlebotomist may only view. Separation of the verify and release permissions enforces a two-person boundary where the laboratory requires one.
TECHNOLOGIST = Role(
name="technologist",
permissions=frozenset({Permission.result_view, Permission.result_verify}),
)
PATHOLOGIST = Role(
name="pathologist",
permissions=frozenset(
{Permission.result_view, Permission.result_verify,
Permission.result_release, Permission.result_amend}
),
)
PHLEBOTOMIST = Role(
name="phlebotomist",
permissions=frozenset({Permission.result_view}),
)
Step 3: Enforce deny-by-default with a decorator
Wrap each release-path action in a decorator that checks the required permission before the action runs and raises AccessDenied otherwise. The decorator emits an audit record on both branches, so a denial is as visible as an allow. Deny-by-default means the check is mandatory: an action with no @requires is a bug the review must catch, not an implicit grant.
import datetime as dt
import functools
from typing import Awaitable, Callable, TypeVar
R = TypeVar("R")
class AccessDenied(Exception):
def __init__(self, subject_id: str, permission: Permission) -> None:
super().__init__(f"{subject_id} lacks {permission.value}")
self.subject_id = subject_id
self.permission = permission
class AccessDecision(BaseModel):
subject_id: str
permission: Permission
resource_id: str
allowed: bool
decided_at: dt.datetime = Field(
default_factory=lambda: dt.datetime.now(dt.timezone.utc)
)
class AuditSink:
async def record(self, decision: AccessDecision) -> None:
... # append-only, tamper-evident write
def requires(permission: Permission, sink: AuditSink) -> Callable[
[Callable[..., Awaitable[R]]], Callable[..., Awaitable[R]]
]:
def decorator(fn: Callable[..., Awaitable[R]]) -> Callable[..., Awaitable[R]]:
@functools.wraps(fn)
async def wrapper(subject: Subject, resource_id: str, *args, **kwargs) -> R:
allowed = subject.can(permission)
await sink.record(AccessDecision(
subject_id=subject.user_id, permission=permission,
resource_id=resource_id, allowed=allowed,
))
if not allowed:
raise AccessDenied(subject.user_id, permission)
return await fn(subject, resource_id, *args, **kwargs)
return wrapper
return decorator
Step 4: Guard the release-path actions
Apply the decorator to each action. The permission a UI shows must be the permission the server enforces — never trust a hidden button; enforce at the action. Here release_result requires result:release, so a technologist reaching the endpoint is denied and the denial is recorded.
sink = AuditSink()
@requires(Permission.result_verify, sink)
async def verify_result(subject: Subject, resource_id: str) -> str:
return f"{resource_id} verified by {subject.user_id}"
@requires(Permission.result_release, sink)
async def release_result(subject: Subject, resource_id: str) -> str:
return f"{resource_id} released by {subject.user_id}"
@requires(Permission.result_amend, sink)
async def amend_result(subject: Subject, resource_id: str, reason: str) -> str:
return f"{resource_id} amended by {subject.user_id}: {reason}"
Roles and Permissions
The catalog below is the access contract an inspector reviews. Each cell states whether the role holds the permission; the grid makes the two-person verify/release boundary explicit.
| Role | result:view | result:verify | result:release | result:amend |
|---|---|---|---|---|
| phlebotomist | ✓ | — | — | — |
| technologist | ✓ | ✓ | — | — |
| pathologist | ✓ | ✓ | ✓ | ✓ |
| lab director | ✓ | ✓ | ✓ | ✓ |
Verification & Testing
Prove deny-by-default at the boundary that matters: a technologist may verify but must be denied release, and the denial must be audited. Grant the union of roles correctly so a multi-role user is not over- or under-privileged.
import pytest
class RecordingSink(AuditSink):
def __init__(self) -> None:
self.decisions: list[AccessDecision] = []
async def record(self, decision: AccessDecision) -> None:
self.decisions.append(decision)
@pytest.mark.asyncio
async def test_technologist_denied_release_and_audited():
rec = RecordingSink()
@requires(Permission.result_release, rec)
async def release(subject: Subject, resource_id: str) -> str:
return "released"
tech = Subject(user_id="u-tech-01", display_name="T. Ng", roles=(TECHNOLOGIST,))
with pytest.raises(AccessDenied):
await release(tech, "A26-0007712")
assert rec.decisions[-1].allowed is False
assert rec.decisions[-1].permission is Permission.result_release
def test_role_union_is_least_privilege():
tech = Subject(user_id="u1", display_name="x", roles=(TECHNOLOGIST,))
path = Subject(user_id="u2", display_name="y", roles=(PATHOLOGIST,))
assert tech.can(Permission.result_verify) and not tech.can(Permission.result_release)
assert path.can(Permission.result_release) and path.can(Permission.result_amend)
Expected: both tests pass. test_technologist_denied_release_and_audited confirms the denial path raises and records an AccessDecision with allowed=False; test_role_union_is_least_privilege confirms permissions accumulate as the union of granted roles without leaking release to a technologist. A user with no roles must be denied every action — a third test asserting Subject(...).granted() is empty pins the deny-by-default default.
Compliance Note
21 CFR Part 11 §11.10(d) requires that system access be limited to authorized individuals, and §11.10(g) requires authority checks to ensure that only authorized individuals can use the system, electronically sign a record, or alter a record — which is exactly the verify/release/amend boundary this RBAC enforces. HIPAA §164.312(a)(1) requires technical access controls that grant access only to those who have been granted rights, and deny-by-default is the concrete implementation of that standard. Recording every AccessDecision, allow and deny, to the tamper-evident sink from HIPAA-compliant audit trails satisfies both the Part 11 attributability expectation and the HIPAA §164.312(b) audit-controls requirement, and keeping verify and release as separate permissions supports a two-person control where the laboratory’s procedures require independent verification before release.
Troubleshooting
A user with no roles can still perform an action.
Root cause: the action was not wrapped with @requires, so no check ran and the action defaulted to allow. Fix: make enforcement mandatory — every release-path action carries a @requires, and a review (or a test that scans the router) fails the build if any action lacks one. Deny-by-default only holds if the check is never optional.
Denied attempts leave no trace, so we can't investigate misuse.
Root cause: the audit write happens inside the success path, after the permission check passes, so denials are never recorded. Fix: record the AccessDecision before branching on allowed, as the decorator does, so an attempted release by an unauthorized user is captured with allowed=False for later review.
A multi-role user has more access than any single role grants.
Root cause: that is expected — permissions are the union of granted roles — but it becomes a problem when a role is over-broad. Fix: keep each role least-privilege and audit role assignments; a user who is both technologist and pathologist correctly gains release, so control the assignment of the pathologist role, not the union logic.
The UI hides the release button but users still release via the API.
Root cause: enforcement lived in the UI, not the action, so a direct API call bypassed it. Fix: enforce at the server action with @requires; the hidden button is a usability affordance, not a control. The permission the UI shows must be the permission the server checks.
Related
- Security & Access Controls — the subsystem this release-path RBAC belongs to.
- Implementing HIPAA-Compliant Audit Trails in LIMS — the tamper-evident sink that records every access decision this decorator emits.
- CLIA/CAP Data Boundaries — the result lifecycle whose verify, release, and amend transitions this RBAC guards.
- LIMS Architecture & Regulatory Compliance Foundations — the reference this access-control boundary sits within.
- Mapping HL7 v2 OBX Segments to LIMS Result Fields — produces the result whose status this RBAC controls the release of.
Part of: Security & Access Controls.