Security Boundary Configuration for Public Records and FOIA Automation

Within Core Architecture & Compliance Mapping, security boundary configuration is the control layer that decides — deterministically, on every request — whether a payload is allowed to cross from a public-facing channel into the records management system, and back out again as a lawful disclosure. It sits between intake and the rest of the pipeline: requests arriving through Intake & Routing Workflows pass the boundary before they reach classification, redaction, or delivery. Records managers and compliance officers must treat this configuration not as a static firewall rule set but as a policy-driven workflow that compiles statutory exemptions, litigation holds, and disclosure timelines into executable enforcement, and writes every decision to an audit trail that survives administrative appeal and federal litigation.

Problem Framing & Statutory Requirement

A FOIA pipeline fails its statutory obligations the moment an exempt record reaches a public channel without an authorized, recorded decision. The boundary is where that failure is prevented. Three statutory pressures bear directly on its design:

  • Mandatory disclosure under a deadline. 5 U.S.C. § 552(a)(6)(A)(i) sets a 20-business-day federal response window, and state open records acts impose their own windows tracked in State Law Compliance Frameworks. The boundary must never become a silent denial: a request that is blocked must produce an audited, reviewable event, not a dropped connection that stops the statutory clock from being honored.
  • Exemptions that gate release. FOIA Exemption 5 (deliberative process) and Exemption 6 (personal privacy), plus jurisdiction-specific carve-outs, determine which classification tiers may leave the system. The boundary evaluates these against the request scope before any record is forwarded.
  • Litigation holds and retention state. A record under an active hold or pending destruction in Records Retention Scheduling must not be released by an automated path. The boundary cross-references retention state as part of its decision.

The controls below map to the NIST SP 800-53 access-control (AC) and audit (AU) families — specifically AC-3 access enforcement, AC-4 information flow enforcement, AC-6 least privilege, AU-9 protection of audit information, and AU-10 non-repudiation. Each enforcement point in this guide cites the control it satisfies so the configuration traces to both a NIST family and a statutory obligation.

Prerequisites & Environment Setup

This guide targets Python 3.11 or later (for datetime.UTC and mature asyncio). The reference middleware runs on FastAPI but the validation logic is framework-agnostic.

  • Runtime: Python 3.11+.
  • Third-party libraries: fastapi (request lifecycle and dependency injection), python-jose[cryptography] (JWT verification), and uvicorn for local serving. Standard-library logging, hashlib, json, os, and datetime cover audit logging and fingerprinting with no extra dependencies.
  • Identity provider: an OIDC-compliant IdP that issues asymmetrically signed (RS256) access tokens carrying a sub, a space-delimited scope claim, and a compliance_tier claim. Symmetric (HS256) signing is not acceptable for a public boundary because the verification key doubles as a signing key.
  • Secrets: the JWT verification key, allowed algorithm, and allowed scopes are loaded from a secrets manager via environment variables — never hardcoded and never committed.
  • Access controls in place: role definitions from Configuring role-based access for public records portals and a readable retention-state lookup so the boundary can resolve litigation holds at request time.

Set the boundary’s configuration before serving traffic:

bash
export JWT_VERIFICATION_KEY="$(cat /run/secrets/idp_public_key.pem)"
export JWT_ALGORITHM="RS256"
export ALLOWED_SCOPES="public_records:read,records:submit"

Architecture Overview

Every request follows the same path through the boundary: decode and verify the bearer token, evaluate its scope against the allowed set and the record’s retention state, write an immutable audit entry, and only then forward validated claims downstream. A rejection at any stage produces a recorded 401 or 403 rather than a silent drop, preserving the evidence an appeal or court will later demand.

Boundary middleware request sequence: token decode, scope check, audit log, then downstream routing A request crosses the boundary in seven ordered steps. The client sends a request carrying a Bearer token to the boundary middleware. The middleware asks the identity provider to decode and verify the JWT, and receives either verified claims or a signature error. The middleware then checks the token scope against the allowed set, writes an immutable audit entry to the audit log, and forwards the validated claims to the records system. The records system returns a response or a 403 Forbidden to the client. Solid arrows are requests; dashed arrows are returns. Client request Boundary middleware Identity provider Audit log Records system Client request Boundary middleware Identity provider Audit log Records system 1 · Request with Bearer token 2 · Decode & verify JWT middleware (return) --> 3 · Claims or signature error 4 · Check scope vs ALLOWED_SCOPES 5 · Write immutable audit entry 6 · Forward validated claims client (return) --> 7 · Response or 403 Forbidden

The boundary deliberately denies lateral movement between classification tiers. Namespace isolation, cryptographic payload signing, and mandatory scope validation at every service hop prevent an exempt record from being co-mingled with a publicly releasable dataset — the failure mode that turns a single misconfiguration into a mass disclosure.

Step-by-Step Implementation

1. Configure a SIEM-ready audit logger

Boundary decisions are compliance evidence, so logging is built first and treated as a hard dependency, not an afterthought. The formatter emits structured JSON keyed for SIEM ingestion; the handler is attached once at module load so every enforcement path shares one append-only stream.

python
import logging
import hashlib
import json
import os
from datetime import datetime, timezone
from typing import Dict
from fastapi import Request, HTTPException, Depends, status
from jose import jwt, JWTError, ExpiredSignatureError

# Structured JSON audit logger configured for SIEM ingestion.
# AU-9: audit records are emitted as immutable, parseable events.
class JSONLogFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        base = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        # Promote structured extras (user_id, request_id, etc.) into the event.
        for key in ("user_id", "request_id", "path", "compliance_tier",
                    "requested_scopes", "error", "ip"):
            if hasattr(record, key):
                base[key] = getattr(record, key)
        return json.dumps(base)

audit_logger = logging.getLogger("security_boundary_audit")
audit_logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(JSONLogFormatter())
audit_logger.addHandler(handler)

# Configuration loaded from a secrets manager (never hardcoded).
JWT_SECRET = os.getenv("JWT_VERIFICATION_KEY")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "RS256")
ALLOWED_SCOPES = os.getenv("ALLOWED_SCOPES", "public_records:read,records:submit").split(",")

Expected output, one line per decision, ready for forwarding:

json
{"timestamp": "2026-06-27T14:02:11.913402+00:00", "level": "INFO", "logger": "security_boundary_audit", "message": "Boundary validation passed", "request_id": "9f2c…", "user_id": "req-4471", "path": "/records/search", "compliance_tier": "public"}

2. Validate the token and enforce statutory scoping

The validator is the boundary’s core. It rejects malformed authorization headers, distinguishes an expired token from an invalid signature (so a routine re-auth is not logged as an attack), and then enforces that the caller’s scope intersects the allowed set. Every rejection is recorded before the exception is raised.

python
def validate_boundary_token(request: Request) -> Dict:
    """
    Intercept incoming requests, verify the cryptographic token, and enforce
    statutory scoping before any downstream routing.
    AC-3 (access enforcement) and AC-4 (information flow enforcement).
    """
    auth_header = request.headers.get("Authorization")
    if not auth_header or not auth_header.startswith("Bearer "):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing or malformed authorization token",
        )

    token = auth_header.split(" ", 1)[1]
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
    except ExpiredSignatureError:
        # Expected, recoverable: client re-authenticates. Not a security event.
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
    except JWTError as e:
        audit_logger.warning(
            "JWT validation failed",
            extra={"error": str(e), "ip": request.client.host},
        )
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token signature")

    # AC-6 least privilege: the caller's scope must intersect the allowed set.
    user_scopes = payload.get("scope", "").split(" ")
    if not any(s in ALLOWED_SCOPES for s in user_scopes):
        audit_logger.error(
            "Scope boundary violation",
            extra={"user_id": payload.get("sub"), "requested_scopes": user_scopes},
        )
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Insufficient clearance for requested record classification tier",
        )

    # AU-10 non-repudiation: bind the decision to method, path, and subject.
    request_fingerprint = hashlib.sha256(
        f"{request.method}:{request.url.path}:{payload.get('sub')}".encode()
    ).hexdigest()

    audit_logger.info(
        "Boundary validation passed",
        extra={
            "request_id": request_fingerprint,
            "user_id": payload.get("sub"),
            "path": request.url.path,
            "compliance_tier": payload.get("compliance_tier", "public"),
        },
    )
    return payload

3. Attach validated claims for downstream routing

The boundary forwards claims to the rest of the pipeline as request state, so that classification, redaction, and Department Routing Logic read an already-verified identity rather than re-parsing the raw token. This keeps the trust boundary singular and auditable.

python
def boundary_middleware(request: Request, token: Dict = Depends(validate_boundary_token)):
    """
    Attach validated claims to request state for downstream redaction and
    routing. Aligns with NIST SP 800-53 AC-3 / AC-4.
    """
    request.state.claims = token
    request.state.audit_hash = hashlib.sha256(
        f"{request.url.path}:{token.get('sub')}:{datetime.now(timezone.utc).isoformat()}".encode()
    ).hexdigest()
    return request

Records reaching this point have crossed exactly one trust boundary with a recorded decision; scanned attachments can now proceed to OCR Processing Pipelines and bulk submissions to Async Queue Management without re-entering the public trust zone.

Validation & Verification

Assert the boundary’s behavior with deterministic tests rather than manual probing. The contract is simple: a valid in-scope token returns claims; every other case raises a recorded HTTPException with the correct status.

python
import pytest
from fastapi import HTTPException

class _Req:
    """Minimal Request stand-in for unit tests."""
    def __init__(self, headers, path="/records/search", method="GET", host="127.0.0.1"):
        self.headers = headers
        self.url = type("U", (), {"path": path})()
        self.method = method
        self.client = type("C", (), {"host": host})()

def test_missing_header_is_401():
    with pytest.raises(HTTPException) as exc:
        validate_boundary_token(_Req({}))
    assert exc.value.status_code == 401

def test_out_of_scope_is_403(monkeypatch, valid_token_with_scope):
    # valid_token_with_scope issues "records:admin", which is not in ALLOWED_SCOPES
    req = _Req({"Authorization": f"Bearer {valid_token_with_scope}"})
    with pytest.raises(HTTPException) as exc:
        validate_boundary_token(req)
    assert exc.value.status_code == 403

Three verification habits keep the boundary honest in production:

  • Log assertion. Pipe a known-bad request through a test harness and assert that exactly one Scope boundary violation event was emitted — proving no rejection is silent.
  • Idempotency check. The audit fingerprint for an identical method, path, and subject is stable; replaying a request must produce the same request_id, so duplicate submissions are recognizable rather than double-counted.
  • Determinism. Run the validator against a fixed corpus of tokens (expired, wrong-signature, out-of-scope, valid) and assert the status code for each on every build; any drift means a control regressed.

Troubleshooting & Edge Cases

  1. Token expiration and clock skew. A correct token fails verification when the IdP and boundary clocks drift past the nbf/exp tolerance — common after a container host loses NTP sync. Diagnosis: compare the token’s exp to the boundary’s datetime.now(timezone.utc). Fix: restore NTP synchronization; introduce a small leeway in the decoder only after compliance sign-off, because widening the window weakens the access guarantee.
  2. Algorithm confusion / alg: none downgrade. An attacker resubmits a token with the algorithm switched to none or to a symmetric algorithm. Diagnosis: the JWTError audit line shows an unexpected algorithm. Fix: always pass an explicit algorithms=[JWT_ALGORITHM] allowlist (as above) so the decoder never honors the token’s self-declared algorithm.
  3. Duplicate submissions through retried channels. A requester or an upstream retry resubmits the same request, producing two boundary crossings. Diagnosis: identical request_id fingerprints appear back-to-back in the audit stream. Fix: treat the fingerprint as an idempotency key downstream so the statutory clock starts once; the second crossing is recorded but does not create a second request lifecycle.
  4. Litigation-hold conflict. A request targets a record that is releasable by classification but frozen by an active hold in Records Retention Scheduling. Diagnosis: the scope check passes but the retention lookup returns suspended. Fix: make the retention-state check a hard stop in the validator — a hold overrides scope, and the denial is logged with the hold reference so legal can audit it.
  5. Asynchronous audit loss under load. During a high-throughput surge, buffered or async log handlers drop boundary events on crash, leaving gaps that look like unauthorized access. Diagnosis: gaps between consecutive request_id sequences with no matching decision. Fix: write audit entries synchronously to a write-once store or SIEM forwarder before the side effect executes, so a crash can never erase the evidence of a decision.

Compliance Verification Checklist

Frequently Asked Questions

Why is a denied request logged as an event instead of just dropped?

A dropped connection looks identical to a network failure and provides no evidence that the agency acted within its statutory window. Because FOIA and state open records acts require a defensible response to every request, the boundary records each 401 or 403 as a structured audit event with the subject, path, and reason. On administrative appeal or in litigation, that record proves the request was evaluated and shows exactly which control denied it.

Should the boundary verify scope or full record-level permissions?

The boundary enforces coarse statutory scope — whether the caller may touch a classification tier at all — and rejects anything outside the allowed set early and cheaply. Fine-grained, record-level decisions belong to the role model in Configuring role-based access for public records portals, which evaluates requester attributes, record sensitivity, and retention state. Keeping scope at the boundary and permissions in the role engine preserves a single, auditable trust transition.

How does the boundary handle a record under a litigation hold?

A hold is a hard stop that overrides scope. Even when a request’s token carries sufficient scope for the classification tier, the validator consults the retention-state lookup; if the record is suspended by a hold or pending destruction, the boundary denies the release and logs the decision with the hold reference. This prevents an automated path from disclosing a frozen record and keeps the action traceable for legal review.

Can I relax JWT expiry tolerance to stop intermittent 401s?

Only with compliance sign-off, and only by a small leeway. Intermittent expiry failures almost always indicate clock drift between the identity provider and the boundary host; the correct fix is restoring NTP synchronization. Widening the acceptance window enlarges the period in which a revoked or stale token still works, which weakens the access guarantee the boundary exists to provide, so the change must be deliberate and recorded.