Configuring Role-Based Access for Public Records Portals

Within Security Boundary Configuration, configuring role-based access control (RBAC) is the task of translating statutory disclosure mandates into a permission decision your code can make deterministically on every request. This page walks the exact configuration: how to resolve a requestor’s identity claims, evaluate them against a policy graph that puts statutory carve-outs ahead of convenience, and enforce the result all the way down to the database row.

Scenario & Compliance Stakes

A county opens a self-service portal so the public can submit and track records requests. Behind it, the same system is used by intake processors, redaction specialists, and legal reviewers, and an overnight batch job exports approved releases. The hard problem is that these populations share infrastructure but have radically different lawful reach: a public requestor may see unclassified and already-redacted material, while a juvenile record, an active-investigation file, or a sealed court order must never leave the system through an automated path — regardless of who asks.

Access control here cannot be a static ACL matrix. A row in a group table cannot express “this record is under a litigation hold,” “this requester is a co-party in the matter,” or “this classification tier requires a recorded legal-review decision before release.” Get the configuration wrong and the failure is statutory, not cosmetic: releasing an exempt record under FOIA Exemption 6 (personal privacy) or Exemption 7 (law-enforcement records) is an unlawful disclosure, and a silent denial that drops a legitimate request stops the agency from honoring the 20-business-day response window in 5 U.S.C. § 552(a)(6)(A)(i). Role definitions therefore trace to a statutory authority or an administrative exemption — derived from the FOIA Request Taxonomy Design that classifies records by sensitivity and cross-referenced against the State Law Compliance Frameworks that encode jurisdiction-specific carve-outs.

The access decision the configuration must make on every request follows a strict precedence: deny-overrides first, then retention state, then role permission, then a redaction threshold.

Attribute-based disclosure decision: deny-overrides first, then retention, then role allow, then redaction threshold A request carrying identity claims is resolved into an attribute-based context, then evaluated in strict statutory precedence. First, if the record matches a deny-override tier the disposition is DENY. Otherwise, if the record is pending destruction or under a legal hold the disposition is DENY. Otherwise, if no held role permits the classification tier the disposition is DENY. Otherwise, if the PII threshold is exceeded the disposition is PARTIAL_REDACT, and if not it is ALLOW. Every disposition — DENY, PARTIAL_REDACT, or ALLOW — is written to the append-only audit trail. yes yes no no no yes no yes Request with identity claims Resolve attribute-based policy context Matches deny-override? Pending destruction or legal hold? Role permits classification tier? PII threshold exceeded? DENY PARTIAL_REDACT ALLOW Log disposition to audit trail

Prerequisites

  • Runtime: Python 3.11 or later (for datetime.UTC and structured logging config).
  • Web framework: FastAPI 0.110+ or Django 4.2+. The evaluation logic is framework-agnostic; the example injects it through FastAPI’s dependency chain.
  • Token verification: PyJWT[crypto] >= 2.8 for RS256/SAML-assertion verification. Symmetric (HS256) signing is unacceptable on a public boundary because the verification key doubles as a signing key.
  • Data layer: PostgreSQL 14+ with row-level security (RLS) available, or an equivalent record-scoping mechanism in your document store.
  • Policy engine (optional but recommended): Casbin or Open Policy Agent for declarative rule evaluation; the example ships a self-contained policy graph so it runs without them.
  • Access controls already in place: a readable retention-state lookup so the boundary can resolve litigation holds at request time (owned by Records Retention Scheduling), and an OIDC/SAML identity provider that issues asymmetrically signed tokens carrying sub, roles, and agency claims.
  • Secrets: the JWT verification key and allowed algorithms loaded from a secrets manager via environment variables — never hardcoded, never committed.

Implementation

The decision runs in authentication middleware before routing to the records-retrieval layer. The middleware parses the JWT/SAML assertion into a normalized PolicyContext, strips extraneous claims, then evaluates that context against a pre-compiled policy graph in which explicit denials win over any allow.

python
import os
import json
import logging
from typing import Dict, List
from functools import lru_cache

import jwt
from fastapi import Request, HTTPException
from pydantic import BaseModel

# 1. Verification material loaded from the secrets manager (never hardcoded).
#    NIST SP 800-53 AC-3 (access enforcement) requires the boundary itself be tamper-resistant.
JWT_VERIFICATION_KEY = os.environ["JWT_VERIFICATION_KEY"]
JWT_ALGORITHMS = os.getenv("JWT_ALGORITHMS", "RS256").split(",")

# 2. Structured JSON audit logger. AU-10 (non-repudiation): every decision is recorded.
audit = logging.getLogger("rbac.audit")


class PolicyContext(BaseModel):
    user_id: str
    agency_affiliation: List[str]
    clearance_level: str
    is_public_requestor: bool
    dual_role_override: bool = False


@lru_cache(maxsize=1)
def compile_policy_rules() -> Dict:
    """Pre-compile the policy graph once. Unbounded recompilation under a request
    surge introduces latency that breaks the statutory response window."""
    return {
        "public": {"read": {"unclassified", "redacted_public"}},
        "intake_processor": {"read": {"unclassified", "pending_review"}},
        "redaction_specialist": {"read": {"all"}},
        "legal_reviewer": {"read": {"all"}},
        # 3. deny_overrides are evaluated BEFORE any allow rule. 5 U.S.C. § 552(b)(6)/(7)
        #    and state carve-outs gate these tiers absolutely.
        "deny_overrides": {"juvenile_records", "active_investigation", "sealed_court_orders"},
    }


def resolve_access_context(request: Request) -> PolicyContext:
    """Parse the bearer token into a normalized, minimal permission context."""
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing authentication token")

    token = auth_header.split(" ", 1)[1]
    try:
        payload = jwt.decode(
            token,
            JWT_VERIFICATION_KEY,
            algorithms=JWT_ALGORITHMS,
            options={"require": ["sub", "roles", "agency"]},  # reject under-specified tokens
        )
    except jwt.PyJWTError as exc:
        raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") from exc

    # 4. Only allowlisted claims populate the context; untrusted claims are dropped.
    roles = payload.get("roles", [])
    is_public = "public_requestor" in roles
    clearance = "high" if any(r in {"legal_reviewer", "redaction_specialist"} for r in roles) else "standard"
    return PolicyContext(
        user_id=payload["sub"],
        agency_affiliation=payload.get("agency", []),
        clearance_level=clearance,
        is_public_requestor=is_public,
        dual_role_override=len(roles) > 1,
    )


def evaluate_disclosure(ctx: PolicyContext, record: Dict, roles: List[str]) -> str:
    """Return ALLOW, DENY, or PARTIAL_REDACT for one record, in statutory precedence."""
    rules = compile_policy_rules()
    classification = record["classification"]
    decision = "DENY"
    matched_rule = "default_deny"

    # 5. Deny-overrides win first — no role can release a carved-out tier.
    if classification in rules["deny_overrides"]:
        matched_rule = "deny_override"
    # 6. Retention state second: a held or expiring record never auto-releases.
    elif record["retention_state"] in {"legal_hold", "pending_destruction"}:
        matched_rule = "retention_block"
    else:
        # 7. Role permission: does any held role permit this classification tier?
        permitted = set()
        for role in roles:
            read_scopes = rules.get(role, {}).get("read", set())
            permitted |= {"all"} & read_scopes or read_scopes
        if "all" in permitted or classification in permitted:
            # 8. Redaction threshold last: PII over the limit is released partially.
            if record.get("pii_entity_count", 0) > 0 and not ctx.clearance_level == "high":
                decision, matched_rule = "PARTIAL_REDACT", "pii_threshold"
            else:
                decision, matched_rule = "ALLOW", f"role_allow:{','.join(roles)}"

    # 9. AU-10: emit one append-only audit line per decision with the evaluation path.
    audit.info(json.dumps({
        "user_id": ctx.user_id,
        "record_id": record["id"],
        "classification": classification,
        "retention_state": record["retention_state"],
        "matched_rule": matched_rule,
        "disposition": decision,
    }))
    return decision

The configuration deliberately puts deny_overrides and retention_block ahead of role evaluation so that a user holding multiple roles — for example a municipal clerk who is also a county FOIA liaison — can never combine them into access the statute forbids. Declarative engines like Casbin or Open Policy Agent give you the same precedence with richer rule syntax, but their Python bindings must cache policy compilations aggressively; recompiling the graph per request is what turns a high-volume request surge into a timeout cascade in downstream retrieval workers.

API-level checks alone are not enough. Enforce the same context at the data layer with PostgreSQL row-level security so that even a compromised application layer or a background worker cannot read past the policy. Set the active context on the session and let the database filter:

sql
-- Bind the policy context to the session; RLS filters every query, ORM call, or batch export.
ALTER TABLE records ENABLE ROW LEVEL SECURITY;
CREATE POLICY records_disclosure ON records
  USING (
    classification NOT IN ('juvenile_records', 'active_investigation', 'sealed_court_orders')
    AND retention_state NOT IN ('legal_hold', 'pending_destruction')
    AND classification = ANY (current_setting('app.permitted_tiers')::text[])
  );

In Python batch jobs — bulk exports or overnight redaction runs — never assume a service account inherits public read privileges. Open the connection under a least-privilege role, set app.permitted_tiers from the initiating requestor’s resolved context, and wrap every cursor in a context manager so the worker cannot widen its own access mid-run.

Expected Output & Verification

Each call to evaluate_disclosure emits exactly one structured audit line, and the disposition matches the decision tree above. Feeding a public requestor a sealed record yields a deny that names the rule:

json
{"user_id": "usr_3391", "record_id": "rec_88120", "classification": "sealed_court_orders",
 "retention_state": "active", "matched_rule": "deny_override", "disposition": "DENY"}

Assert the precedence holds — a deny-override beats a role that would otherwise allow, and a held record is blocked even for a legal reviewer:

python
ctx = PolicyContext(user_id="u1", agency_affiliation=["county"],
                    clearance_level="high", is_public_requestor=False)

held = {"id": "r1", "classification": "unclassified",
        "retention_state": "legal_hold", "pii_entity_count": 0}
assert evaluate_disclosure(ctx, held, roles=["legal_reviewer"]) == "DENY"

sealed = {"id": "r2", "classification": "sealed_court_orders",
          "retention_state": "active", "pii_entity_count": 0}
assert evaluate_disclosure(ctx, sealed, roles=["legal_reviewer"]) == "DENY"

For data-layer verification, run the export query under the service role with app.permitted_tiers set to {unclassified} and confirm RLS returns zero sealed or held rows even when the SQL explicitly selects them. If the database returns a restricted row, the boundary has a gap that an API check would have hidden.

Common Pitfalls

  • Dual-role precedence inverted. Joining roles with OR so any role can grant access lets a user with two affiliations reach material neither role should release alone. The fix is structural: evaluate deny_overrides and retention state before any role allow, as the policy graph above does — denial must win regardless of role count.
  • Gateway check, ungated database. Teams enforce the role at the API gateway but let ORM calls, raw cursors, and Celery/RQ workers connect with elevated credentials that bypass it. Apply RLS at the database and pass the initiating user_id and permitted tiers explicitly into every task; never run worker queues on a superuser connection.
  • Blanket masking instead of statutory redaction. A redaction engine that masks everything over a generic PII count produces over-redaction that is itself appealable, while one with no threshold leaks. Map detected entities to specific jurisdictional exemption codes before the policy decision, and route PARTIAL_REDACT dispositions through a recorded review rather than auto-publishing.

FAQ

Why model access with attribute claims instead of static groups?

Because the lawful answer depends on variables a group row cannot hold: the record’s retention state, whether it is under a litigation hold, the requester’s affiliation to the matter, and the record’s classification tier. Static group membership cannot adapt to those contextual facts, so it either over-grants (an unlawful disclosure) or over-denies (a missed statutory deadline). Resolving attribute-based claims at request time lets the same role behave differently as the record’s state changes.

Should deny rules always beat allow rules?

Yes. FOIA exemptions and state carve-outs — juvenile records, active investigations, sealed orders — are absolute gates, not preferences. Evaluating deny_overrides and retention state before any role allow guarantees that no combination of roles, and no convenience rule, can release a carved-out tier. This deny-overrides ordering is what makes the configuration defensible under administrative appeal.

Do I still need row-level security if the API already checks the role?

Yes. An API check protects only the path that runs through the API. Direct ORM queries, ad-hoc cursors, and background workers frequently bypass it, and that is exactly where audits find leaks. Row-level security moves enforcement into the database engine so a compromised or careless application layer still cannot read past the policy context bound to the session.

← Back to all public records automation topics