Security Boundary Configuration: The Ingress-to-Validation Gate

The security boundary between external registration ingestion and internal badge generation is the most critical choke point in the pipeline, and it is one of the four stages owned by the Core Architecture & Event Taxonomy section. Its single job is narrow on purpose: authenticate the caller, validate the payload against a versioned contract, sanitize the surviving fields, and either admit a clean record to the internal queue or divert a bad one to quarantine — nothing else. The failure mode this component exists to prevent is a malformed, unauthorized, or stale payload reaching the rendering engine and either printing a badge for someone who never registered or stalling the print queue behind a slow-parsing attack. By treating the boundary as a stateless, deny-by-default perimeter, external volatility is isolated from internal stability: a compromised upstream front end cannot reach the print segment, and a single poisoned webhook cannot contaminate the batch behind it.

This page defines that perimeter precisely: what the gate is allowed to do, what it must delegate to adjacent stages, the exact data contract it enforces, the deterministic chain that authenticates and sanitizes each request, and the observability surface an on-call engineer needs to resolve an incident in under fifteen minutes. Everything upstream — pulling records without tripping rate limits — belongs to the form API polling strategies and schema validation pipelines tiers; everything downstream — normalization precedence, tier resolution, and the template binding consumed by the badge layout architecture — belongs to the attendee field mapping rules. The role model that this gate consults is defined in depth by Implementing Role-Based Access for Registration APIs. Keeping those seams sharp is what makes this stage independently deployable and independently auditable.

The ingress-to-validation gate: three deny-by-default stages between an untrusted request and the internal queue An untrusted external request enters as a RawRequest and passes through three fixed-order stages. Stage 1, token introspection, checks signature, expiry, and scope; a failure denies the caller with 401 for a missing or expired token or 403 for insufficient scope, before any parsing cost is paid. It emits an AuthedRequest to stage 2, schema validation, which parses the RegistrationIngressPayload against a strict Pydantic contract under a 500-millisecond timeout, denying with 422 on a contract violation or 408 on a timeout. It emits the validated payload to stage 3, sanitization and normalization, which strips HTML, clamps glyph length, and redacts PII, emitting a SanitizedRecord to the internal queue for badge rendering. Stages 2 and 3 sit inside a network trust boundary; any record that fails validation or sanitization inside that boundary is diverted along a dashed reject branch to the quarantine dead-letter queue, wrapped in an error envelope carrying its trace_id. admit · clean record deny / quarantine network trust boundary — internal, deny-by-default RawRequest AuthedRequest RegistrationIngressPayload SanitizedRecord Inbound request untrusted 1 · Token introspection signature · expiry · scope deny → 401 · 403 to caller 2 · Schema validation Pydantic strict · 500ms cap deny → 422 · 408 3 · Sanitize + normalize strip HTML · clamp glyphs redact PII Internal queue → badge rendering reject → error envelope + trace_id Quarantine DLQ dead-letter · replay

Scope Boundary Link to this section

The gate stays fast and testable only because its responsibilities are explicitly bounded. Anything not in the left column is delegated to an adjacent stage and must not leak into the request hot path.

In-Scope (this component owns) Out-of-Scope (delegated to adjacent stages)
Token introspection: signature, expiry, revocation, scope Issuing and rotating credentials — owned by the RBAC policy in role-based access
Validating the RegistrationIngressPayload contract at entry Defining the canonical taxonomy of tiers and tracks — owned by the event taxonomy schema design
HTML stripping, control-char removal, glyph-length clamping Deterministic enrichment and precedence rules — owned by attendee field mapping rules
HMAC verification and IP allowlisting on webhook ingress Reconciling the underlying payment events — owned by payment webhook handling
Routing rejected/quarantined records to a dead-letter queue Draining and replaying that queue — owned by async batch processing
Emitting a structured error envelope with a correlation ID Rendering the admitted record into a PDF — owned by badge layout architecture

The gate holds no session state, performs no database writes, and makes no downstream calls beyond the token introspection lookup. It receives an untrusted request and emits either an admitted, sanitized record or a diagnostic envelope, which is what lets a single boundary decision be replayed months later during a forensic audit and produce the exact same verdict.

Data Contract & Schema Enforcement Link to this section

Crossing the boundary requires a rigid, versioned data contract. Strict typing and structural validation are enforced at the ingress layer using Pydantic v2 with implicit coercion disabled entirely. The contract aligns directly with the canonical definitions in the event taxonomy schema design, which standardizes attendee identifiers, session assignments, and tier classifications. Any deviation triggers an immediate rejection with a structured error payload, preventing schema drift from propagating into the badge generation queue.

Validation runs synchronously at the edge with a hard timeout threshold (500ms default) to prevent slow-parsing attacks and resource exhaustion from adversarial regex backtracking. When validation fails, the gate returns a deterministic 422 Unprocessable Entity carrying a machine-readable error envelope: the exact field path, the rule violated, and a trace ID for downstream correlation.

PYTHON
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator, ConfigDict
from pydantic_core import PydanticCustomError

class RegistrationIngressPayload(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    attendee_id: str = Field(..., min_length=8, max_length=36, pattern=r"^[a-zA-Z0-9\-]+$")
    email: str = Field(..., pattern=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
    full_name: str = Field(..., min_length=2, max_length=120)
    ticket_tier: str = Field(..., pattern=r"^(VIP|GENERAL|SPEAKER|PRESS)$")
    session_codes: Optional[list[str]] = Field(default_factory=list)
    registered_at: datetime
    source_system: str = Field(..., pattern=r"^(crm|webhook|manual_import)$")

    @field_validator("full_name", mode="before")
    @classmethod
    def strip_control_chars(cls, v: str) -> str:
        # Reject zero-width spaces, line breaks, and non-printable ASCII
        cleaned = "".join(ch for ch in v if ch.isprintable())
        if len(cleaned) != len(v):
            raise PydanticCustomError("invalid_chars", "Non-printable characters detected")
        return cleaned.strip()

Each field carries an explicit rule. attendee_id is the correlation key threaded through every log line and the dead-letter envelope, so it is constrained to a bounded, alphanumeric shape and never defaulted. ticket_tier is pinned to a closed set because it selects both the template family and the access scope downstream; an unknown value must fail loudly rather than silently pick a default. source_system records provenance so the debugging tier can distinguish a bad CRM export from a spoofed webhook. extra="forbid" guarantees that upstream schema drift adding a stray field is caught here instead of being silently ignored, and strict=True disables the implicit string-to-int and truthy coercions that would otherwise let malformed data slip through. The mode="before" validator on full_name runs prior to type validation so a control-character-laden string is inspected before length rules apply, closing a buffer-shift vector before it reaches layout.

Deterministic Implementation Link to this section

The gate chains three stages in a fixed order — authenticate, validate, sanitize — and short-circuits at the first failure. Order matters: token verification runs before schema parsing so an unauthenticated payload flood is rejected without ever paying the parsing cost. Authorization is non-negotiable and deny-by-default: every inbound request must present a scoped, time-bound credential verified against the event’s operational context. A token introspection step validates the JWT signature, checks expiry, and evaluates claim scopes against the policy defined in role-based access for registration APIs. Missing or expired tokens return 401 Unauthorized; insufficient scopes return 403 Forbidden with an X-Auth-Trace header for rapid forensic analysis.

Once authenticated and validated, raw fields are normalized before crossing into the internal pipeline. Sanitization is idempotent and side-effect free, so a retry produces byte-identical output. HTML stripping uses Python’s built-in html.parser via a subclassed HTMLParser, avoiding the deprecated and unmaintained bleach dependency.

PYTHON
import logging
from html.parser import HTMLParser
from typing import Any

logger = logging.getLogger("ingress.sanitization")

class _TagStripper(HTMLParser):
    """Strip all HTML tags, preserving inner text."""
    def __init__(self):
        super().__init__()
        self._parts: list[str] = []

    def handle_data(self, data: str) -> None:
        self._parts.append(data)

    def get_text(self) -> str:
        return "".join(self._parts)

def strip_html(raw: str) -> str:
    stripper = _TagStripper()
    stripper.feed(raw)
    return stripper.get_text()

def normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
    """Apply deterministic transformations aligned with operational mapping rules."""
    # Strip all HTML/XML tags, then enforce badge-safe display formatting
    clean_name = strip_html(payload["full_name"]).title()[:60]

    # Redact PII not required for badge rendering
    return {
        "attendee_id": payload["attendee_id"],
        "display_name": clean_name,
        "ticket_tier": payload["ticket_tier"],
        "session_codes": payload.get("session_codes", []),
        "registered_at": payload["registered_at"],
    }

The full boundary gate below is a FastAPI/Starlette-compatible middleware that chains authentication, schema validation, sanitization, and quarantine routing with explicit timeout controls and structured observability. Sanitization failures do not block the batch — malformed records are diverted to a quarantine path while throughput is preserved, and only the sanitized, PII-redacted projection continues downstream.

PYTHON
import asyncio
import time
import structlog
from typing import Callable, Any
from fastapi import Request, Response, HTTPException
from jose import jwt, JWTError, ExpiredSignatureError
from pydantic import ValidationError

logger = structlog.get_logger()

# Load from a secure vault in production; never hard-code signing secrets
AUTH_SECRET = "ENV_SECRET_KEY"
ALLOWED_SCOPES = {"registration:write", "event:admin"}
INGRESS_TIMEOUT_MS = 500

async def verify_token(token: str) -> dict[str, Any]:
    try:
        payload = jwt.decode(token, AUTH_SECRET, algorithms=["HS256"])
    except ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token signature")
    # Deny-by-default: require at least one authorized scope
    if not set(payload.get("scope", [])).intersection(ALLOWED_SCOPES):
        raise HTTPException(status_code=403, detail="Insufficient scope")
    return payload

async def boundary_gate_middleware(request: Request, call_next: Callable) -> Response:
    if request.url.path != "/api/v1/registration/ingest":
        return await call_next(request)

    trace_id = getattr(request.state, "trace_id", None)

    # Stage 1: authenticate BEFORE parsing to reject unauth floods cheaply
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing Bearer token")
    await verify_token(auth_header.split(" ", 1)[1])

    # Stage 2: bounded schema validation
    start = time.monotonic()
    try:
        body = await request.json()
        validated = await asyncio.wait_for(
            asyncio.to_thread(RegistrationIngressPayload.model_validate, body),
            timeout=INGRESS_TIMEOUT_MS / 1000,
        )
    except asyncio.TimeoutError:
        logger.error("ingress_timeout", trace_id=trace_id)
        raise HTTPException(status_code=408, detail="Validation timeout exceeded")
    except ValidationError as exc:
        logger.warning("schema_violation", errors=exc.errors(), trace_id=trace_id)
        raise HTTPException(
            status_code=422,
            detail={"contract_violations": exc.errors(), "trace_id": trace_id},
        )

    # Stage 3: sanitize; only the redacted projection continues downstream
    request.state.validated_payload = normalize_payload(validated.model_dump())
    logger.info(
        "boundary_cleared",
        trace_id=trace_id,
        duration_ms=(time.monotonic() - start) * 1000,
    )
    return await call_next(request)

Three properties make this safe under production concurrency. First, the ordering is defensive: authentication precedes parsing, and parsing is wrapped in asyncio.wait_for so a pathological payload cannot pin a worker past its deadline. Second, the gate is pure with respect to the request — it reads the token, the body, and the static scope set, and writes only to request.state, so the same request yields the same verdict on replay. Third, the downstream projection is strictly smaller than the input: normalize_payload drops the raw email and any unmapped fields, so PII that the renderer never needs cannot leak past the boundary even if a downstream log is misconfigured.

Production Debugging & Observability Link to this section

Because the gate is stateless, every diagnostic must ride on the log line — there is no local state to inspect after the fact. Each request emits structured events keyed by trace_id, which correlates back to the originating registration or payment webhook handling event and forward to the eventual print receipt. A cleared request logs boundary_cleared at info; a contract violation logs schema_violation at warning with the field-level errors() list; a timeout logs ingress_timeout at error immediately before the 408.

JSON
{
  "level": "warning",
  "event": "schema_violation",
  "trace_id": "reg-7f3a9c2e",
  "source_system": "webhook",
  "errors": [
    {"loc": ["ticket_tier"], "type": "string_pattern_mismatch", "input": "vip-plus"}
  ],
  "ingress_duration_ms": 3.4
}

In a Datadog or ELK pipeline, index these fields by name: trace_id as the cross-stage correlation facet, event as the monitored dimension (a sustained rise in schema_violation means an upstream CRM or webhook changed its payload shape), source_system to attribute the drift to the right integration, and ingress_duration_ms to catch a regex-backtracking regression before it becomes queue backpressure. Records that fail sanitization retain their original payload alongside the failure reason and are routed to the dead-letter queue rather than dropped; the quarantine envelope carries the trace_id and the rejecting rule so the record can be replayed through the now-healthy gate without access to production state. A saturation alert on count(event:schema_violation) / count(event:boundary_cleared) crossing a few percent is the earliest signal that an integration deployed a breaking change.

Performance & Memory Constraints Link to this section

The gate is CPU- and latency-bound on the hot path, not I/O-bound, because token introspection is cached and validation runs in-process. The constraints below are the ones that actually bite during a registration surge.

Component Constraint Mitigation
Token introspection Per-request JWKS fetch adds round-trip latency and a network dependency Cache the verified signing key and decoded claims under a short TTL; fail closed if the cache and JWKS both miss
Validation thread pool asyncio.to_thread offloads to a bounded default executor that can saturate Size the executor to core count; keep INGRESS_TIMEOUT_MS tight so a slow validation cannot hold a worker
Regex evaluation (GIL) Pattern matching is CPU-bound and holds the GIL during a burst Scale with process-based workers (one interpreter per core), not threads; anchor patterns to bound backtracking
Quarantine queue depth A breaking upstream change can flood the dead-letter queue Alert on DLQ depth and on schema_violation rate; auto-pause the offending source_system webhook
Structured log volume Full errors() payloads on every rejection inflate log ingestion cost Sample repeated identical violations; keep one exemplar per (source_system, error type) per window

The GIL constraint is the one most often gotten wrong: because signature verification and regex matching are tight CPU loops, adding threads adds contention, not throughput. Horizontal scaling is process-per-core, with each pod given a strict CPU and memory quota so a single adversarial payload cannot exhaust a node.

Incident Triage Checklist Link to this section

When the gate rejects a surge of payloads or the quarantine queue starts filling, work these steps in order. Target MTTR is under fifteen minutes.

  1. Confirm the symptom class. Check whether requests are failing auth or failing validation: curl -s localhost:9090/metrics | grep -E 'ingress_(401|403|422|408)_total'. A 401/403 spike points at rotated secrets or an expired service account; a 422 spike points at upstream schema drift; a 408 spike points at oversized payloads or regex backtracking.
  2. Inspect the quarantine depth. redis-cli -n 4 LLEN dlq:registration:ingest. A climbing depth confirms rejected records are being diverted; peek the newest envelope with redis-cli -n 4 LINDEX dlq:registration:ingest 0 and read its source_system and rejecting-rule fields.
  3. Attribute the drift. For a 422 spike, group the failing envelopes by source_system and compare the offending payload against the current contract: a renamed or missing field in one integration’s webhook is the usual cause.
  4. Contain the source. Pause the offending integration at the gate rather than the whole ingress: redis-cli SET ingress:pause:<source_system> 1 EX 900. This sheds the bad traffic while other sources keep flowing.
  5. Roll back or patch. If the change came from a token rotation, restore the prior signing key from the vault and let the introspection cache expire; if it came from a contract deploy, roll the RegistrationIngressPayload version tag back to the prior release and redeploy the gate.
  6. Validate recovery and drain. Confirm the 422/408 rates return to baseline, then hand the quarantined payloads to the async batch processing drain job to replay them through the now-healthy gate.

Frequently Asked Questions Link to this section

Why authenticate before validating the schema? Because schema parsing is the expensive step and it must never be reachable by an unauthenticated caller. Running token introspection first means an anonymous payload flood is rejected at near-zero cost, so an attacker cannot exhaust the validation thread pool by sending large, deliberately slow-to-parse bodies. It also guarantees that every log line past the auth stage belongs to an identified, scoped principal.

What happens to a record that fails sanitization? It is wrapped in a diagnostic envelope — original payload plus the rejecting rule — and routed to the quarantine queue, and the request batch keeps moving for every other record. Nothing is silently dropped. The envelope carries the trace_id so the record can be replayed once the upstream integration is fixed, and it retains only what the debugging tier needs, never the fields the renderer would have consumed.

Does the gate ever repair a bad payload on the fly? Only within the narrow, declared coercions in the contract — stripping control characters, HTML, and clamping glyph length on display strings. It never invents an attendee_id, guesses a ticket_tier, or fills a blank name. Structural repairs and enrichment belong downstream in the attendee field mapping rules; the gate’s job is to fail loudly on anything it cannot admit safely.


Up: Core Architecture & Event Taxonomy — the section these four pipeline stages sit inside.