Skip to content

Configuring RBAC for FSMA 204 Trace Data: Segregating Auditors, Logistics, and Admins

The production failure this guide fixes is quiet until an audit exposes it: a logistics operator whose account was provisioned for shipment updates can also read — and sometimes trigger — recall workflows over the same Key Data Elements, because both surfaces sit behind one coarse “authenticated employee” check. The lateral path usually runs through the ERP. An operator has a valid ERP session, the trace-data service trusts any ERP-issued token, and nothing between that token and the recall-scoping endpoint asks whether this role is allowed to reach this action. The result is a KDE store where the ability to read a traceability lot code, mutate a Critical Tracking Event, and initiate a recall are all reachable from the same credential, which is exactly the concentration of authority an FSMA 204 records system is supposed to prevent.

This is the access-control half of the boundary defined by the parent Security Boundaries for Trace Data. That guide draws the network and storage perimeter; this one governs what an already-authenticated principal is permitted to do once inside it. The goal is narrow and testable: an auditor can read and export but never mutate, a logistics operator can append receiving and shipping events but never reach recall orchestration, and only an admin can manage roles — with every decision made by one authorization layer rather than scattered if user.is_staff checks that drift apart over time.

Root Cause: Authentication Is Not Authorization

The defect is a category error that hides in almost every early traceability service. The system authenticates well — it confirms the caller holds a valid token — and then treats that proof of identity as if it were proof of permission. Once “logged in” is the only gate, every endpoint the router exposes is reachable by every account, and the Critical Tracking Events that drive a recall become as accessible as a read-only dashboard.

Three forces make this the default failure:

  1. Token trust is transitive. The trace service trusts the ERP’s identity provider, so any ERP user inherits a session the trace service honors. Provisioning someone for warehouse work silently grants them a trace-data session too, and no one revisits what that session can reach.
  2. Permissions are checked at the wrong layer. Authorization gets pushed into UI routing — a button is hidden, a menu item removed — while the underlying API endpoint stays open. Hiding the recall button in the operator’s view does nothing to stop a scripted POST to the recall endpoint.
  3. Roles are implicit, not modeled. Without a declared role-to-permission map, “who can do what” lives in tribal knowledge and scattered conditionals. There is no single place to audit, so an FDA inspector’s question — show me who could have altered this lot’s KDEs — has no authoritative answer.

Under 21 CFR Part 1, Subpart S, the records that establish traceability must be maintained and produced on demand, which presumes they are protected from uncontrolled alteration. A trace store where any authenticated role can mutate a KDE cannot demonstrate that its records are trustworthy. The engineering rule that follows: model roles and permissions explicitly, enforce them at the action boundary on the server, and default to deny.

Figure — Role-to-action authorization boundary:

Role-based authorization boundary over KDE trace-data actions Three principals — auditor, logistics operator, and admin — each carry a role claim into a single authorization guard. The guard maps the role to a permission set and checks it against the requested action. Auditors reach read and export, operators reach event append, and only admins reach recall orchestration and role management. Any role-to-action pair not granted is denied by default and logged. Auditor role=auditor Logistics op role=logistics Admin role=admin Authorization guard role → permission set default deny · logged Read / export KDEs Append CTE events Recall orchestration Manage roles (admin)

The Permission Model This Guard Enforces

Before the code, the actions the guard arbitrates and the regulatory obligation each one touches. Actions — not endpoints or tables — are the unit of authorization, because the same KDE row can be read, appended to, or used to scope a recall, and those are three different privileges.

Action auditor logistics admin Regulatory Source
kde:read yes yes yes 21 CFR 1.1455 (records available for inspection)
kde:export yes no yes 21 CFR 1.1455 (produce within 24 hours)
cte:append no yes yes 21 CFR 1.1340 (record CTE data)
recall:scope no no yes 21 CFR 1.1455 (traceability information on request)
role:manage no no yes 21 CFR 1.1455 (integrity of maintained records)

The pattern that keeps this auditable: an auditor reads and exports but can never write, a logistics operator writes events but can never reach recall scoping, and the two admin-only actions — recall orchestration and role management — are the concentrated authority that everything else is deliberately kept away from.

Minimal Reproducible Example

The vulnerable service checks only that a token is present, then serves every action. A logistics token reaches recall scoping with nothing standing in the way:

from dataclasses import dataclass


@dataclass
class Principal:
    user_id: str
    role: str  # carried from the ERP token, but never actually checked


def handle_request(principal: Principal, action: str) -> str:
    # The only gate: "is someone logged in?" Identity is mistaken for permission.
    if not principal.user_id:
        raise PermissionError("authentication required")
    return f"{action} executed for {principal.user_id}"


operator = Principal(user_id="op-4417", role="logistics")

# A logistics operator reaches recall orchestration with no obstacle.
print(handle_request(operator, "recall:scope"))
# -> "recall:scope executed for op-4417"   <-- lateral reach into recall workflows

Nothing raises. The role field rides along on the principal but no code path consults it, so the operator’s ERP-derived session reaches recall:scope exactly as easily as a read. Hiding the recall button in that user’s UI would not have changed this line’s output — the server never asked the question.

Fix: An Explicit Role Model and a Deny-by-Default Guard

The fix has three parts: a declared role-to-permission map that is the single source of truth, a guard that resolves a principal’s role to its permission set and refuses anything not explicitly granted, and a decorator that pins the required permission to each action handler so the check cannot be forgotten. This mirrors the enforcement posture the boundary guide applies everywhere — decide once, at the action edge, and default to deny.

from __future__ import annotations

import logging
from dataclasses import dataclass
from enum import Enum

logger = logging.getLogger("fsma204_rbac")
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")


class Permission(str, Enum):
    KDE_READ = "kde:read"
    KDE_EXPORT = "kde:export"
    CTE_APPEND = "cte:append"
    RECALL_SCOPE = "recall:scope"
    ROLE_MANAGE = "role:manage"


class Role(str, Enum):
    AUDITOR = "auditor"
    LOGISTICS = "logistics"
    ADMIN = "admin"


# The single source of truth. Every grant is explicit; anything absent is denied.
ROLE_PERMISSIONS: dict[Role, frozenset[Permission]] = {
    Role.AUDITOR: frozenset({Permission.KDE_READ, Permission.KDE_EXPORT}),
    Role.LOGISTICS: frozenset({Permission.KDE_READ, Permission.CTE_APPEND}),
    Role.ADMIN: frozenset(Permission),  # every permission, including role management
}


@dataclass(frozen=True)
class Principal:
    user_id: str
    role: Role


class AuthorizationError(PermissionError):
    """Raised when an authenticated principal lacks the required permission."""


def authorize(principal: Principal, required: Permission) -> None:
    """Deny-by-default check at the action boundary. Never trust the caller's UI."""
    granted = ROLE_PERMISSIONS.get(principal.role, frozenset())
    if required not in granted:
        # Log the denial with full context so an inspector can reconstruct attempts.
        logger.warning(
            "DENY user=%s role=%s action=%s reason=permission_not_granted",
            principal.user_id, principal.role.value, required.value,
        )
        raise AuthorizationError(
            f"role '{principal.role.value}' may not perform '{required.value}'"
        )
    logger.info(
        "ALLOW user=%s role=%s action=%s",
        principal.user_id, principal.role.value, required.value,
    )

The decorator binds a permission to each handler so authorization is structural — a handler literally cannot be defined without declaring what it requires:

from functools import wraps
from typing import Callable, TypeVar

R = TypeVar("R")


def requires(permission: Permission) -> Callable[[Callable[..., R]], Callable[..., R]]:
    """Guard a handler with a required permission. The principal is the first argument."""
    def decorator(func: Callable[..., R]) -> Callable[..., R]:
        @wraps(func)
        def wrapper(principal: Principal, *args: object, **kwargs: object) -> R:
            authorize(principal, permission)  # raises AuthorizationError on denial
            return func(principal, *args, **kwargs)
        return wrapper
    return decorator


@requires(Permission.RECALL_SCOPE)
def scope_recall(principal: Principal, lot_code: str) -> dict[str, str]:
    """Admin-only: initiate recall scoping for a traceability lot code."""
    return {"status": "recall_scoped", "lot_code": lot_code, "by": principal.user_id}


@requires(Permission.CTE_APPEND)
def append_receiving_event(principal: Principal, lot_code: str) -> dict[str, str]:
    """Logistics: append a receiving CTE for a lot."""
    return {"status": "event_appended", "lot_code": lot_code, "by": principal.user_id}

Because the ERP token still supplies the role, the boundary does not depend on the ERP being untrusted — it depends on the trace service refusing to act on a role that lacks the permission, regardless of where the token came from. That is what closes the lateral path: the operator’s valid session now reaches append_receiving_event and stops hard at scope_recall.

Verification Steps

Prove both directions — that the allowed action succeeds and the denied one raises — against the same operator credential that walked straight through the vulnerable service:

auditor = Principal(user_id="aud-9001", role=Role.AUDITOR)
operator = Principal(user_id="op-4417", role=Role.LOGISTICS)
admin = Principal(user_id="adm-0001", role=Role.ADMIN)

# ALLOW: the logistics operator can append a receiving event.
assert append_receiving_event(operator, "0071234500017")["status"] == "event_appended"

# DENY: the same operator can no longer reach recall orchestration.
try:
    scope_recall(operator, "0071234500017")
    raise AssertionError("operator should not reach recall:scope")
except AuthorizationError as e:
    assert "may not perform 'recall:scope'" in str(e)

# DENY: an auditor may read/export but never mutate a CTE.
try:
    append_receiving_event(auditor, "0071234500017")
    raise AssertionError("auditor should not append events")
except AuthorizationError:
    pass

# ALLOW: only the admin reaches recall scoping.
assert scope_recall(admin, "0071234500017")["status"] == "recall_scoped"
print("RBAC boundary verified: lateral path from logistics to recall is closed.")

The four assertions are what an inspector actually cares about: the boundary is enforced positively (the operator keeps the access their job needs) and negatively (the operator, and the read-only auditor, are stopped at exactly the actions they must not reach). The DENY log line — with user, role, and action — is the audit trail proving every refused attempt was recorded, not silently swallowed. Route those denial events into your error-handling workflows so a burst of denials from one account surfaces as a possible privilege-probing signal rather than log noise.

  • Role changes mid-session. An operator promoted to admin, or demoted, keeps their old permissions until the token refreshes if the role is cached in the session. Resolve the role from the authoritative store per request, or keep token lifetimes short, so a revocation takes effect immediately — a stale grant is the same lateral hole in slow motion.
  • Object-level scoping, not just action-level. cte:append lets an operator write events, but should a Region-A operator write events for a Region-B facility? Action permissions are necessary but not sufficient; pair them with row-level checks tied to the facility identifier, keeping location data segregated as the parent Security Boundaries for Trace Data requires.
  • Service accounts and batch jobs. The nightly supplier ingestion job needs cte:append but must never hold recall:scope or role:manage. Give automation its own least-privilege role rather than reusing an admin credential, and monitor its permission use through data-quality monitoring so an over-scoped job stands out.

Frequently Asked Questions

Why enforce permissions on the server when the UI already hides the recall button?

Because hiding a control in the interface changes nothing about the API behind it. A logistics operator can open developer tools, read the network calls the app makes, and replay a recall request directly — the hidden button never mattered. Authorization has to be decided on the server at the action boundary, where every request passes regardless of which client sent it. Treat the UI as a convenience that reflects permissions, never as the thing that grants them.

Should roles be checked or should individual permissions be checked?

Check permissions, and derive them from roles. If your handlers ask “is this user an admin?” then adding a fourth role, or splitting one role in two, means hunting down every conditional and editing it. If handlers instead require a permission like recall:scope, and roles are just named bundles of permissions, then reshaping the role model is a single edit to the role-to-permission map. The decorator in this guide pins a permission, not a role, for exactly this reason.

How does default-deny actually help during an FDA inspection?

An inspector can ask you to demonstrate who could have altered a lot’s records. With an explicit role-to-permission map and deny-by-default, the answer is a table you can produce: only these roles hold cte:append, only admins hold recall:scope, and every denied attempt is logged. With scattered conditionals and implicit allow, there is no authoritative answer, and the inability to bound who could have changed a KDE undermines the trustworthiness the records are supposed to carry.

The ERP issues the tokens — doesn't that mean the ERP controls access?

The ERP controls identity, not authorization for the trace service. The token proves who the caller is and what role the ERP assigned them, but the trace-data service decides independently what that role is permitted to do with KDEs. Keeping that decision inside the trace service is what stops an ERP account from inheriting recall powers just because it happens to be a valid login. Trust the token for identity, never for permission.

Where should the role-to-permission map live so it stays auditable?

In one declared structure, version-controlled and reviewed like any other compliance artifact, rather than spread across handler code. A single map is greppable, diffable, and can be exported for an audit as the definitive statement of who can do what. When a permission grant changes, the change shows up as one reviewable diff with a clear author and date, which is precisely the provenance an inspector expects for a control over traceability records.

Up: Security Boundaries for Trace Data — this RBAC layer enforces, per action, the perimeter the parent guide defines.

For the authoritative regulatory text, reference the FDA Food Traceability Final Rule.