Implementing Role-Based Access Control for Digital Archives: Resolving Workflow Bottlenecks in Preservation Security

Archival digitization pipelines stall in a very specific way when access control is bolted on after the fact: an ingest worker holding a valid but over-scoped credential silently mutates an archival information package (AIP) that should have been immutable, and the corruption is only discovered weeks later during a fixity sweep. This page addresses that failure class directly — the permission-model bugs that break high-throughput preservation systems — and sits under the Digital Preservation Security Policies cluster, which defines which controls must exist. Here the concern is narrower and more operational: how to encode role-based access control (RBAC) as a deterministic, testable policy matrix so that least-privilege boundaries hold across the full lifecycle — from bitstream validation to public dissemination — without introducing the token-refresh latency that breaks automated processing chains.

Problem Statement: Where RBAC Breaks in Preservation Pipelines

The symptom is rarely an outright security breach. It is a bottleneck or a silent authorization failure that only the audit trail reveals:

  • An ingest queue drains to zero throughput because a token expires mid-transfer and the worker retries against a now-403 endpoint indefinitely.
  • A preservation planner triggers a batch normalization run and, through inherited group membership, gains curator-level write access to the master repository — overwriting provenance.
  • A denial returns a bare 403 Forbidden with no machine-readable reason, so the orchestration layer cannot decide whether to retry, quarantine, or dead-letter the package.

Each of these is a scoping bug, not a firewall bug. RBAC in this environment must map to the functional entities of the OAIS-Compliant Digital Preservation Architecture — Ingest, Archival Storage, Data Management, Access, and Administration — because those entities are the true privilege boundaries. An Ingest role needs write-only access to staging directories and transient processing queues; an Archival Storage role needs read/write strictly scoped to fixity-verified packages; Data Management and Access roles operate only on derived metadata and dissemination copies, never on master preservation objects.

Root-Cause Analysis of Permission Drift and Silent Failures

Four concrete root causes account for the overwhelming majority of RBAC incidents in preservation systems:

  1. Implicit role inheritance from the identity provider. Enterprise directories synchronize group attributes asynchronously; a role added to a parent group propagates a superset of permissions before the deny rules catch up. The fix is an explicit default-deny with deny rules that override any inherited allow, evaluated at request time rather than at provisioning time.
  2. Session lifetime vs. processing-window mismatch. Identity-provider access tokens are typically minted with 5–15 minute lifetimes, while a large TIFF batch transfer or a migration action can run for tens of minutes. When the token expires mid-operation the storage backend returns 401/403 and a naive retry loop hammers the endpoint. This is a clock problem, not a permission problem.
  3. Non-deterministic denial payloads. A generic 403 carries no policy_reference, so downstream Python automation cannot distinguish “wrong role” (permanent, dead-letter it) from “token expired” (transient, refresh and retry). The two demand opposite handling.
  4. Scope re-evaluation on every hop. When each microservice re-decodes the token against its own copy of the policy, any drift between copies produces inconsistent grants. The policy matrix must be a single version-pinned artifact — the same discipline the Digital Preservation Security Policies cluster applies to its PreservationPolicy object.

The token-refresh race in cause (2) is the subtlest, because it presents as an intermittent authorization failure that disappears on manual retry. The state machine below shows the safe path: validate the token’s remaining lifetime before dispatching a long-running preservation action, and refresh proactively rather than reacting to a mid-flight 401.

Proactive token-refresh state machine for long-running preservation actions Idle leads to Validate scope. A role mismatch drops to a terminal Dead-letter state (non-retryable HTTP 403); a permitted role reaches the Token fresh? decision. A fresh token dispatches the action directly, while an expiring token is refreshed proactively before dispatch, avoiding the mid-flight 401 loop. Dispatch action commits the AIP and returns to Idle on success, or falls to Mid-flight 401 which routes back through Proactive refresh to resume. role ✓ fresh expiring refreshed success · commit AIP → Idle 401 mid-flight resume role mismatch 403 · non-retryable Idle Validate scope Token fresh? Dispatch action Proactive refresh Mid-flight 401 Dead-letter terminal · no retry

The next diagram maps each OAIS-aligned role to its permitted actions and target resources, with a default-deny decision node that rejects any unlisted role/action combination.

Role-to-action-scope policy matrix with default-deny off-diagonal A five-by-five matrix of OAIS-aligned roles against action scopes. Explicit allows sit only on the diagonal — each role is granted exactly one scope (Ingest to Staging/SIP, Archival Storage to AIP write/lock, Data manager to metadata/embargo/dissemination build, Preservation planner to migration/audit read, Public access to dissemination read/download). All other cells are default-deny (HTTP 403), so no role crosses into a second functional entity. role ↓ scope → Staging &SIP intake AIP write& lock Metadata,embargo,dissem. build Migration& audit read Disseminationread /download Ingest operatorsubmit_sip · upload Archival Storagewrite_aip · lock Data managermetadata · embargo Preserv. plannerread_aip · migration Public accessread · download ALLOW ALLOW ALLOW ALLOW ALLOW explicit allow in the policy matrix — default-deny → HTTP 403 diagonal shape = no role spans two OAIS functional entities

Roles are scoped to specific actions and resources; any request outside the policy matrix falls through to default deny.

Step-by-Step Resolution: Deterministic Scope Enforcement in Python

The resolution is middleware that intercepts every preservation API call, validates the JWT against a centralized policy matrix, and — critically — distinguishes a permanent role mismatch from a transient token expiry so the caller can react correctly. The matrix is the single source of truth; it is version-pinned and loaded once, mirroring how the parent cluster pins its policy object.

python
"""rbac_middleware.py — deterministic RBAC enforcement for preservation APIs."""
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from http import HTTPStatus
from typing import Any

import jwt
from fastapi import HTTPException, Request

logger = logging.getLogger("preservation.rbac")

# Policy matrix aligned with OAIS functional entities. Version-pinned and
# loaded once at startup so every service evaluates the identical grants.
PRESERVATION_POLICY_MATRIX: dict[str, frozenset[str]] = {
    "ingest_operator": frozenset({"create_staging", "upload_bitstream", "submit_sip"}),
    "archival_storage_admin": frozenset({"verify_fixity", "write_aip", "lock_package"}),
    "data_manager": frozenset({"update_descriptive_metadata", "generate_dissemination", "apply_embargo"}),
    "preservation_planner": frozenset({"read_aip", "trigger_migration", "audit_log_access"}),
    "public_access_user": frozenset({"read_dissemination", "download_derivative"}),
}


@dataclass
class RBACMiddleware:
    """Validate bearer tokens and enforce role/action scope with typed denials."""

    # RS256: PEM-encoded RSA public key. HS256: the shared secret string.
    public_key: str
    algorithm: str = "RS256"
    audience: str = "digital-archive-api"

    def _decode(self, request: Request) -> dict[str, Any]:
        auth = request.headers.get("Authorization", "")
        if not auth.startswith("Bearer "):
            raise HTTPException(
                status_code=HTTPStatus.UNAUTHORIZED,
                detail={"error": "missing_bearer_token", "retryable": False,
                        "message": "Valid JWT required for preservation API."},
            )
        token = auth.split(" ", 1)[1]
        try:
            return jwt.decode(
                token, self.public_key,
                algorithms=[self.algorithm], audience=self.audience,
            )
        except jwt.ExpiredSignatureError:
            # Transient: the orchestrator should refresh and retry, not dead-letter.
            logger.info("token_expired", extra={"path": request.url.path})
            raise HTTPException(
                status_code=HTTPStatus.UNAUTHORIZED,
                detail={"error": "token_expired", "retryable": True,
                        "message": "Session expired mid-pipeline. Re-authenticate to resume."},
            )
        except jwt.InvalidTokenError as exc:
            raise HTTPException(
                status_code=HTTPStatus.FORBIDDEN,
                detail={"error": "invalid_token", "retryable": False,
                        "message": f"Token validation failed: {exc}"},
            )

    def enforce_scope(self, request: Request, required_action: str) -> None:
        """Default-deny: raise unless an assigned role explicitly permits the action."""
        payload = self._decode(request)
        roles: list[str] = payload.get("roles", [])
        permitted = any(
            required_action in PRESERVATION_POLICY_MATRIX.get(role, frozenset())
            for role in roles
        )
        if permitted:
            logger.info("rbac_granted", extra={"action": required_action, "roles": roles})
            return

        # Permanent denial: machine-readable so callers dead-letter, not retry.
        violation = {
            "error": "policy_violation",
            "retryable": False,
            "requested_action": required_action,
            "assigned_roles": roles,
            "policy_reference": "OAIS-RBAC-SECTION-4.2",
            "message": "Action denied by preservation security policy.",
        }
        logger.warning("rbac_denied", extra=violation)
        raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=violation)

The retryable flag is the load-bearing detail. A token_expired denial is marked retryable so the pipeline refreshes credentials and resumes; a policy_violation is marked non-retryable so the orchestration layer routes the package to a dead-letter queue rather than looping. This single boolean eliminates the indefinite-retry bottleneck described in the problem statement.

Validation and Verification

Confirming the fix requires asserting three properties: the matrix denies by default, denials carry the correct retry semantics, and every decision is auditable. A minimal test harness exercises each:

python
import pytest
from fastapi import HTTPException

from rbac_middleware import RBACMiddleware, PRESERVATION_POLICY_MATRIX


class _FakeRequest:
    def __init__(self, roles_token: str) -> None:
        self.headers = {"Authorization": f"Bearer {roles_token}"}
        self.url = type("U", (), {"path": "/aip/write"})()


def test_default_deny_for_unlisted_action(monkeypatch) -> None:
    mw = RBACMiddleware(public_key="secret", algorithm="HS256")
    monkeypatch.setattr(mw, "_decode", lambda req: {"roles": ["public_access_user"]})
    with pytest.raises(HTTPException) as exc:
        mw.enforce_scope(_FakeRequest("t"), "write_aip")
    assert exc.value.status_code == 403
    assert exc.value.detail["retryable"] is False
    assert exc.value.detail["policy_reference"] == "OAIS-RBAC-SECTION-4.2"


def test_no_role_grants_cross_entity_write(monkeypatch) -> None:
    mw = RBACMiddleware(public_key="secret", algorithm="HS256")
    # No single role may both write an AIP and disseminate — privilege separation.
    assert not (PRESERVATION_POLICY_MATRIX["archival_storage_admin"]
                & PRESERVATION_POLICY_MATRIX["public_access_user"])

Beyond unit tests, verify the running system by inspecting the audit trail: every enforcement decision must emit a preservation event so that a fixity check or a denial is provable after the fact. Bind each rbac_denied and rbac_granted record to a PREMIS event as defined in the PREMIS Metadata Mapping layer, and confirm the count of emitted events equals the count of enforcement decisions. A decision that ran but was not logged is, for accreditation purposes, a decision that never happened.

Edge Cases and Gotchas

Archival environments surface RBAC pitfalls that generic web-app guidance never mentions:

  • Break-glass writes over WORM storage. Long-term retention relies on write-once-read-many policies enforced at the storage layer described in the long-term storage architecture. Even an archival_storage_admin must not silently bypass a cryptographic seal; model break-glass as a distinct, time-bound role that emits a high-severity audit event and expires automatically, rather than as a permanent super-user.
  • Format-registry read scoping. Analysts validating PRONOM signatures during preservation format identification need read-only access to the registry consumed by format registry integration. Granting write access here risks an accidental registry overwrite that would silently reclassify every subsequently ingested object.
  • Denials that must not become retry storms. When a scoping failure occurs mid-batch, the package should hand off to the error handling and retry logic subsystem using the retryable flag, so a permanent policy_violation dead-letters immediately instead of re-queuing behind a still-valid ingest token.
  • Atomic policy propagation across federated nodes. A matrix change must reach every node in one version bump; a half-applied update exposes master bitstreams on the lagging node. Version-pin the matrix and reject any request whose token references an unknown policy version, exactly as upstream ingest rejects packages that fail batch validation schemas.