Implementing Role-Based Access for Registration APIs: Fixing 403s and PII Leaks from Over-Scoped Service Accounts

Symptom Statement Link to this section

Event operations teams report intermittent 403 Forbidden and 500 Internal Server Error responses during peak badge-print queue execution, and audit trails confirm that badge_printer service accounts are invoking attendee_export and field_override endpoints they should never touch — leaking PII into print buffers and corrupting layout rendering. Registration managers see schema drift when third-party CRM webhooks push unscoped payloads that skip validation entirely. This page is the RBAC policy detail behind the Security Boundary Configuration gate, which is itself one of the four stages owned by the Core Architecture & Event Taxonomy section; the boundary gate consults the role model defined here on every request. The failure pattern is deterministic rather than flaky: the registration API grants blanket administrative scope from a single JWT claim, so a service account that only needs to read four display fields inherits read/write access to every attendee record, layout template, and routing config. The observable tells are consistent — a 403 storm the moment a print worker is retried against an export route, PII fields appearing in rendered PDFs that should carry only a name and badge tier, and webhook rows mutating records that no operator authorized.

Root Cause Analysis Link to this section

Four independent misconfigurations produce this symptom set. An incident is usually one of them, occasionally two stacked, so triage means naming which before touching code.

  1. Blanket JWT scope from a monolithic auth gate. The API authorizes every downstream operation from one event_ops claim. Every authenticated caller — including the print worker — implicitly gets read/write on attendees, layouts, and webhooks because there is no per-role scope to consult.
  2. No method or endpoint scoping at the routing boundary. Even where roles exist, they are not mapped to HTTP verbs or path prefixes. A badge_printer token that is valid at all is valid everywhere, so GET /v1/attendees and DELETE /v1/layouts are reachable by a role that should see only /v1/badge/render.
  3. No field-level mask, so PII leaks into the print buffer. The render path receives the full attendee object — email, payment status, accessibility notes — when it needs only the fields that appear on the badge. Anything the projection does not strip can surface in a rendered PDF or a debug log.
  4. Unscoped webhook payloads bypass validation. A webhook_consumer credential accepted for ingestion is reused to mutate arbitrary fields, so an over-scoped or malformed CRM push writes straight through instead of being confined to a narrow ingest contract and quarantined on violation.

Symptom-to-Resolution Matrix Link to this section

Root Cause 1 — Blanket JWT scope from a monolithic auth gate Link to this section

Symptoms

  • Every authenticated role can call every endpoint; 403 only appears when a downstream data store, not the API, rejects the call.
  • Rotating one service account’s permissions has no effect because permissions are not read from the token.

Root cause. Authorization collapses to “is this JWT signed and unexpired?” There is no mapping from role to permitted verbs, paths, or fields, so least-privilege cannot be expressed. This is the gap the event taxonomy schema design assumes is already closed when it hands tiers and tracks to downstream stages.

Fix

  1. Define an immutable policy registry keyed by role, each entry pinning allowed methods, endpoint prefixes, and a field mask.
  2. Store it in memory for O(1) lookup so authorization adds no database round-trip to the hot path.
  3. Make every role explicit — there is no implicit “admin” fallthrough; an unlisted role is denied.
PYTHON
from typing import Dict, FrozenSet
from dataclasses import dataclass

@dataclass(frozen=True)
class PolicyScope:
    allowed_methods: FrozenSet[str]
    endpoint_prefixes: FrozenSet[str]
    field_mask: FrozenSet[str]

POLICY_REGISTRY: Dict[str, PolicyScope] = {
    "registration_admin": PolicyScope(
        allowed_methods=frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}),
        endpoint_prefixes=frozenset({"/v1/attendees", "/v1/layouts", "/v1/webhooks"}),
        field_mask=frozenset({"*"}),
    ),
    "badge_operator": PolicyScope(
        allowed_methods=frozenset({"GET", "POST"}),
        endpoint_prefixes=frozenset({"/v1/badge/render", "/v1/badge/queue"}),
        field_mask=frozenset({"first_name", "last_name", "company", "badge_type"}),
    ),
    "webhook_consumer": PolicyScope(
        allowed_methods=frozenset({"POST"}),
        endpoint_prefixes=frozenset({"/v1/webhooks/ingest"}),
        field_mask=frozenset({"email", "registration_status", "ticket_id"}),
    ),
    "audit_viewer": PolicyScope(
        allowed_methods=frozenset({"GET"}),
        endpoint_prefixes=frozenset({"/v1/audit/logs"}),
        field_mask=frozenset({"event_id", "timestamp", "action", "actor"}),
    ),
}

Root Cause 2 — No method or endpoint scoping at the routing boundary Link to this section

Symptoms

  • A print worker retried against an export route succeeds (or returns 500 mid-way) instead of a clean 403.
  • attendee_export and field_override appear in audit logs with a badge_printer actor.

Root cause. Nothing intercepts the request before route resolution to test the caller’s role against the verb and path. Authorization is all-or-nothing at authentication time.

Fix

  1. Deploy a middleware layer that runs before route resolution, extracts the bearer token, and verifies its signature.
  2. Cross-reference the role claim against the registry, then test request.method and request.url.path against that role’s scope.
  3. Terminate a scope miss immediately with a structured 403, and inject the resolved field mask into request.state so downstream handlers cannot re-widen it.
PYTHON
if method not in policy.allowed_methods:
    return JSONResponse(status_code=403, content={"error": "method_not_permitted"})
if not any(path.startswith(p) for p in policy.endpoint_prefixes):
    return JSONResponse(status_code=403, content={"error": "endpoint_not_permitted"})

Root Cause 3 — No field-level mask, so PII leaks into the print buffer Link to this section

Symptoms

  • Rendered PDFs or render-stage logs contain email, registration_status, or internal notes.
  • The render service receives the full attendee object when it needs four display fields.

Root cause. The response projection is not constrained by role. A field the role should never see is not stripped before it reaches business logic or the badge layout architecture renderer.

Fix

  1. Apply a Pydantic v2 validator that reads the injected mask from request state and drops every field outside it before the model reaches handler code.
  2. Treat "*" as the only escape hatch, reserved for registration_admin.
  3. Mark internal-only fields exclude=True so they never serialize even if admitted.
PYTHON
from pydantic import BaseModel, model_validator, Field
from typing import Any, Dict, Optional

class AttendeePayload(BaseModel):
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    company: Optional[str] = None
    badge_type: Optional[str] = None
    email: Optional[str] = None
    registration_status: Optional[str] = None
    ticket_id: Optional[str] = None
    internal_notes: Optional[str] = Field(default=None, exclude=True)

    @model_validator(mode="before")
    @classmethod
    def apply_field_mask(cls, values: Dict[str, Any], info) -> Dict[str, Any]:
        request = info.context.get("request") if info.context else None
        if not request or not hasattr(request.state, "rbac_context"):
            return values
        mask = request.state.rbac_context["field_mask"]
        if "*" in mask:
            return values
        return {k: v for k, v in values.items() if k in mask}

Root Cause 4 — Unscoped webhook payloads bypass validation Link to this section

Symptoms

  • CRM webhook rows mutate fields no operator authorized; schema drift appears downstream.
  • A webhook_consumer token writes outside /v1/webhooks/ingest.

Root cause. The webhook credential is trusted for arbitrary mutation instead of being pinned to a single ingest prefix and a narrow field mask, so an over-scoped push writes straight through. Enrichment beyond that ingest contract belongs to the attendee field mapping rules, not the boundary.

Fix

  1. Confine webhook_consumer to POST /v1/webhooks/ingest and a three-field mask in the registry (already shown above).
  2. Route payloads that fail the ingest contract to the dead-letter queue rather than failing silently, so the print pipeline is never corrupted and the record can be replayed.
  3. Keep signature and allowlist checks aligned with payment webhook handling, which crosses the same perimeter.

Minimal Working Implementation Link to this section

The middleware below is the self-contained fix: it loads the RS256 verification key once at startup, verifies each token, resolves the role against the registry, enforces method and endpoint scope, and injects the field mask for downstream masking. The load-bearing detail that trips most first deployments is that jwt.decode needs the public key content, not a file path — pass the key object, not the filename.

RBAC middleware request lifecycle Five sequential gates run before route resolution — Bearer-token presence, RS256 verify, role lookup, method check, endpoint-prefix check. The first two deny with 401 and their error keys, the next three deny with 403 and their error keys, and a request that clears all five gets its field_mask injected into request.state and is forwarded to the handler as a masked 200. Inboundrequest Bearer tokenpresent? RS256 signaturevalid? role inPOLICY_REGISTRY? method inallowed_methods? path matchesprefix? inject field_maskthen call_next pass → next gate, before route resolution 401 Unauthorizedmissing_bearer_token 401 Unauthorizedinvalid_or_expired_token 403 Forbiddenunregistered_role 403 Forbiddenmethod_not_permitted 403 Forbiddenendpoint_not_permitted 200 OKmasked payload → handler request advances terminates with status code + error key
PYTHON
import os
import time
import jwt
from typing import Dict
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse

def _load_rsa_public_key() -> str:
    """Load RSA public key CONTENT once at startup. Fail fast if missing."""
    with open(os.environ["JWT_PUBLIC_KEY_PATH"], "r") as f:
        return f.read()

RSA_PUBLIC_KEY = _load_rsa_public_key()

class RBACMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, rsa_public_key: str, policy_registry: Dict[str, PolicyScope]):
        super().__init__(app)
        self.rsa_public_key = rsa_public_key
        self.policy_registry = policy_registry

    async def dispatch(self, request: Request, call_next):
        if os.environ.get("RBAC_BYPASS") == "1":
            return await call_next(request)  # legacy monolithic gate — rollback only

        auth = request.headers.get("Authorization", "")
        if not auth.startswith("Bearer "):
            return JSONResponse(status_code=401, content={"error": "missing_bearer_token"})

        try:
            # RS256 requires the key CONTENT, not a path
            payload = jwt.decode(
                auth.split(" ", 1)[1],
                self.rsa_public_key,
                algorithms=["RS256"],
                options={"verify_exp": True},
            )
        except jwt.InvalidTokenError:
            return JSONResponse(status_code=401, content={"error": "invalid_or_expired_token"})

        role = payload.get("role")
        policy = self.policy_registry.get(role)
        if policy is None:
            return JSONResponse(status_code=403, content={"error": "unregistered_role"})

        if request.method not in policy.allowed_methods:
            return JSONResponse(status_code=403, content={"error": "method_not_permitted"})
        if not any(request.url.path.startswith(p) for p in policy.endpoint_prefixes):
            return JSONResponse(status_code=403, content={"error": "endpoint_not_permitted"})

        request.state.rbac_context = {
            "role": role,
            "field_mask": policy.field_mask,
            "audit_id": payload.get("jti", f"req_{int(time.time() * 1000)}"),
        }
        return await call_next(request)


app = FastAPI()
app.add_middleware(RBACMiddleware, rsa_public_key=RSA_PUBLIC_KEY, policy_registry=POLICY_REGISTRY)

# Verification: a badge_operator token must be 403'd on an export route.
# curl -s -o /dev/null -w "%{http_code}\n" \
#   -H "Authorization: Bearer $BADGE_OP_JWT" localhost:8000/v1/attendees
# expected: 403

Memory & Performance Constraints Link to this section

The middleware sits on every request, so its cost is paid per call. The constraints below are the ones that bite during a registration surge.

Component Constraint Mitigation
Token decoding jwt.decode runs RSA signature math per request Pre-load the public key once at startup; cache decoded claims with functools.lru_cache(maxsize=1024) keyed by a token hash when identical tokens recur across concurrent calls
Policy lookup Registry membership tests are hot-path frozenset and dict.get are O(1); avoid per-request regex compilation — precompile prefix matchers only if dynamic routing is required
Response serialization Full attendee objects inflate render-stage payloads Serialize with orjson on high-throughput routes and let the field mask shrink the projection before it is dumped
Connection pool Badge-print queues exhaust ephemeral ports under load Configure httpx.AsyncClient with explicit limits so peak throughput cannot starve connections

Incident Triage & Rollback Link to this section

When a 403 storm or a PII leak lands, work these steps in order — target MTTR is under fifteen minutes.

  1. Classify the failure. Confirm whether it is auth (401), scope (403), or a downstream fault (500): curl -s localhost:9090/metrics | grep -E 'rbac_(401|403)_total'. A 403 spike on export routes with a badge_printer actor confirms over-scoped retries against a now-scoped registry.
  2. Check for a rotated key. A blanket 401 right after a deploy points at a stale JWT_PUBLIC_KEY_PATH; restore the prior public key from the vault and let the decode cache expire.
  3. Contain without a full outage. Set RBAC_BYPASS=1 to fall back to the legacy monolithic gate while you diagnose — this reverts to pre-RBAC behavior instead of denying all traffic.
  4. Backward-compat shim (only if tokens lack role). Temporarily map the legacy event_ops claim to registration_admin and log every shim activation for audit reconciliation; remove it the moment tokens carry real roles.
  5. Drain quarantined webhooks. Route ingest-contract failures to /v1/webhooks/dlq with exponential backoff and hand replay to the async batch processing drain job.

Rollback procedure. Toggle RBAC_BYPASS=1 (env or ConfigMap), or roll POLICY_REGISTRY back to its prior version tag and hot-reload without a process restart. Post-rollback validation: confirm legacy behavior is restored with curl -I -H "Authorization: Bearer <legacy_token>" localhost:8000/v1/badge/queue and expect HTTP/1.1 200 OK, then re-enable RBAC and re-run the badge_operator denial check from the implementation above (expected 403).

For authoritative RBAC patterns, cross-reference the NIST SP 800-53 access-control guidelines and align token validation with the FastAPI security documentation.


Up: Security Boundary Configuration — the ingress-to-validation gate this RBAC policy belongs to.