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.6 for the role/permission/subject models, pytest>=8 for 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.

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

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

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

python
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}"
Deny-by-default RBAC enforcement flow for a LIMS result-release action An authenticated subject requests a release-path action such as release_result on a result. The request enters the requires decorator, which resolves the subject's granted permissions from the union of its roles and checks whether the required permission is present. The decorator writes an AccessDecision to the append-only audit sink on both branches. If the permission is granted the wrapped action runs and returns; if it is not, the decorator raises AccessDenied and the action never runs. The diagram stresses that both the allow and the deny are recorded and that the default is deny. Authenticated subject release_result(id) @requires check granted = ∪ role permissions permission ∈ granted? default = deny Audit sink append-only · both branches AccessDecision granted Action runs result released not granted raise AccessDenied action never runs Every decision — allow and deny — is recorded before the action proceeds or is refused.

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.

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

Part of: Security & Access Controls.