Skip to content

Postern — Public Unauthenticated Proxy Edge

Status & scope

Status: Proposed (2026-06-01). No code yet. Revised 2026-06-03 — (a) public addressing changed from a dedicated host to a /public-api/ path prefix on the shared platform host; (b) config surface dropped the POSTERN_ prefix and reuses platform-common AxonisSettings names (SSO_TOKEN_URL, PLATFORM_API_URL, *_VERIFY, OTEL_*), with a single server/config.py reader, the allowlist authored as a list, and OTEL via the shared axonis.observability.instrument(). Revised 2026-06-05 (v1.1) — drops the absolute read-only rule for an enumerated POST allowlist: specific POST entries may be allowlisted (query-by-body reads such as POST /dataspace/query, and POST /api/v1/tickets/public consumed by component.beacon.ticketing's public submission), gated by the same default-deny allowlist, rate limit, audit, and minted limited token as reads. This sanctions the direction the code already took (commit f85decf, "POST-read allowlist") and resolves dev-plan gap postern-proxy-01's resolution gate in favor of POST. All other mutating methods (PUT/PATCH/DELETE) remain opaque-404 and may never be allowlisted; allowlisted POSTs are never cached and carry stricter abuse controls (Invariant 12).

Package: postern (name provisional — single-word, platform-style; a postern is a fortification's secondary gate, which is what a dedicated public URI is. Rename is cheap at this stage.)

Depends on: platform.axonis-core (auth, redis, gateway client), platform.service-contract, platform.service-configuration, platform.ingress-routing (the public path prefix), platform.observability

Milestone: P2 (Hebronsoft public-website integration)

Port: 8012 (proposed — confirm against platform.ingress-routing routing table)

Purpose

Postern is the platform's single hardened front door for unauthenticated public API traffic. It accepts anonymous requests (no caller credential) under a dedicated public path prefix (/public-api/) on the shared platform host, validates them against a default-deny allowlist (read-mostly — see Invariant 2) and a rate limit, injects a limited-permission Keycloak service token acquired server-side, strips the public prefix, and forwards the request to the existing platform REST layer.

Every other REST entry point requires a validated Bearer token (platform.service-contract §Authentication). Postern is the only anonymous path, so it isolates that risk behind one small, audited boundary — the deliberate inverse of Oracle's REST gateway (platform.ingress-routing §"Oracle REST Gateway"): Oracle validates an inbound token and applies guardrails; Postern serves callers who have no token and grants a limited one.

Why a new service (not a flag on Oracle)

  1. Blast radius. Compromising Postern exposes only the limited service-token scope — never Oracle's OIDC callback flow, token exchange, or a user session.
  2. No anonymous branch in the auth path. Folding "no auth required here" into OracleAuthMiddleware would put an anonymous conditional inside the platform's most security-sensitive code. Postern has no such branch — it never validates an inbound token because there is none to validate.
  3. Precedent. platform.service-contract already documents Conduit as "itself a proxy." Postern is the same shape: a thin proxy whose command layer is the forwarding client.

The routing contract — one host, two path namespaces

Hebronsoft codes against the same host as authenticated integrations, distinguished by the path prefix (see platform.ingress-routing §"The Postern Public Path Prefix"):

Traffic URL Auth Backend
Public / anonymous https://<domain>/public-api/<backend-path> none (Postern injects a service token) Postern → strips /public-api → platform REST
Authenticated https://<domain>/<backend-path> (existing PLATFORM_API_URL) caller Bearer token (platform.service-contract) direct to owning service

The /public-api/ prefix is the website developer's "unique entry point." It routes exclusively to Postern; everything outside the prefix continues to route to the owning authenticated service. The public namespace is a mirror: /public-api/dataspace/query reaches the same backend handler as the authenticated /dataspace/query, but only after Postern's allowlist + rate limit + minted token.

Scope

Postern owns: - The /public-api/ Ingress path-prefix rule on the shared platform host (no dedicated hostname; the prefix is the public surface). TLS terminates at the shared ingress as for every other path. - The public path prefix strip: Postern removes the configured public_path_prefix (/public-api) from the inbound path before allowlist-matching and forwarding. A request that does not begin with the prefix is rejected (404) and never forwarded — defense in depth, since the Ingress should only ever route prefixed traffic here. - The default-deny request allowlist: an explicit (method, path-pattern) table expressed in backend-path terms (the path after the prefix is stripped — e.g. GET /dataspace/query, not GET /public-api/dataspace/query). A request that matches no rule is rejected (404, opaque — see Invariant 1) and never forwarded. Read-mostlyGET, HEAD, and explicitly enumerated POST entries may be allowlisted; PUT/PATCH/DELETE may never be, and any unlisted method/path remains opaque-404. - Per-source rate limiting: a direct Redis INCR/EXPIRE counter keyed on the caller source, over a fixed window; over-limit → 429. (It uses the Redis connection from axonis.redis, but not the axonis.redis.Client CRUD API — that client namespaces keys by session_id from the auth context, and anonymous callers have none, so it cannot count per source. See "Reused primitives" below.) - Service-token acquisition: a self-contained Keycloak client-credentials request to the realm token endpoint (SSO_TOKEN_URL) using the limited-scope client, with the token cached and refreshed before expiry under an asyncio.Lock (collapses concurrent refreshes within a worker; across uvicorn workers / HPA replicas each process refreshes once — bounded by replica count, kept low via a generous token TTL, not a true single-flight). The token POST verifies TLS (SSO_VERIFY from AxonisSettings), never verify=False. axonis.auth has no client-credentials grant helper; Postern mints its own, mirroring the pattern in axonis/gateway/oracle.py. It never reuses the platform SSO_CLIENT_ID — only PUBLIC_SSO_CLIENT_ID. - Forwarding via a raw HTTP relay (the injected token as Bearer) to the internal platform REST entry (PLATFORM_API_URL / Traefik ClusterIP) using the prefix-stripped path, so normal object routing (platform.ingress-routing) applies downstream. The relay must surface the upstream status and body faithfully (Invariant 5), so it does not use axonis.gateway.client.ServiceClient.get() — that helper calls raise_for_status() + .json() and would swallow upstream 4xx/5xx and non-JSON bodies. - An append-only audit of every request (source, method, path, allow/deny verdict, upstream status) to ES index postern.

Postern does NOT: - Validate an inbound caller token — there is none. (This is the one place in the platform that intentionally does not call requires_auth.) - Define or create the limited Keycloak client/role → platform team (SSO is read-only; hard boundary). Postern only consumes its credentials. - Proxy arbitrary writes → only explicitly enumerated POST entries are forwarded; PUT/PATCH/DELETE and any unlisted POST are rejected (404, opaque — never forwarded, so the write never reaches the backend). The upstream owner of an allowlisted POST (e.g. Beacon for POST /api/v1/tickets/public) performs all content validation and authorization — Postern relays the body faithfully and stays a dumb relay. - Own domain objects → emits an empty objects list in /service-info (like Oracle). - Expose an MCP/agent interface → documented exception to the platform.service-contract dual-interface rule: Postern is a public REST edge, not a domain service. REST-only, no /agentspace mount. This exception is intentional and must not be replicated by domain services.

The service token is a hard boundary

The limited-permission service account / client lives in Keycloak. Postern reads PUBLIC_SSO_CLIENT_ID and PUBLIC_SSO_CLIENT_SECRET from K8s secrets and performs a self-contained client-credentials grant against the realm token endpoint (SSO_TOKEN_URL). The Keycloak role granted to that client must be scoped to exactly the read capabilities the allowlist exposes — nothing more. Creating that client and role is the platform team's responsibility; until it exists Postern fails closed (503). Postern never embeds, logs, or forwards the secret.

Request lifecycle

anonymous request on https://<domain>/public-api/<backend-path>
  → derive source identity (trusted-proxy XFF depth; never raw leftmost XFF)   (Invariant 9)
  → strip every inbound Authorization header, case-insensitively               (Invariant 4)
  → strip the configured public path prefix (/public-api); a path not under
    the prefix → 404 (never forwarded)                                         (Invariant 6)
  → normalize the stripped backend path (percent-decode, collapse //, resolve ..) THEN
    match (method, backend-path) against the allowlist  (unlisted path OR disallowed method → 404, never forwarded)  (Inv 1,2)
  → rate-limit check per source  (over → 429)  — BEFORE token mint, so a flood
    cannot amplify into Keycloak token requests                                (Invariant 5)
  → on an allowlisted GET/HEAD, return a fresh in-memory cache hit (still audited),
    skipping the token mint + forward; an allowlisted POST is NEVER cached and
    always proceeds to mint + forward (writes also pass the stricter write
    abuse controls — body-size cap, write rate limit)                          (Inv 10,12)
  → acquire/refresh limited service token  (unavailable → 503, fail closed; never forward
    without a minted token)                                                    (Invariant 5)
  → raw-HTTP forward with injected Bearer to PLATFORM_API_URL, using the prefix-stripped
    backend path (routes to the owning authenticated service, never back to Postern)
  → relay upstream status + body to the caller (faithful; upstream 4xx/5xx passed through)
  → audit the request (allow AND deny) to ES index `postern` off the response path (the ES
    write does not block the caller); an audit-write failure is logged at ERROR and never
    silently fails open                                                        (Invariant 7)

The per-source rate-limit counter is INCR first, then EXPIRE <window> only when the INCR result is 1 (the first hit in a window) — so the count is exact (first request = 1, not 2) and a crash can never leave a TTL-less key that locks a source forever. The limit fires at rate_limit_per_min inclusive. (A single Lua step is the equivalent atomic form.)

Reused primitives (and why two are NOT reused as-is)

Postern reuses AxonisSettings (config) and the Redis connection from axonis.redis. It deliberately does not reuse two axonis-core helpers, because they are wrong for an anonymous edge — recorded here so a future maintainer does not "fix" Postern back onto them:

Need Not reused Why Postern does instead
Forward + relay ServiceClient.get() calls raise_for_status() + .json() — swallows upstream 4xx/5xx and non-JSON bodies raw HTTP relay returning the upstream status + body verbatim
Token mint axonis.auth grants no client-credentials grant exists; Authenticator would pull SSO_CLIENT_ID (the platform client, not Postern's limited one) self-contained client-credentials POST to SSO_TOKEN_URL with PUBLIC_SSO_CLIENT_ID
Rate limit axonis.redis.Client CRUD namespaces keys by session_id from auth context; anonymous callers have none → every request its own counter → limit never fires; returns NullClient when EDGE_NODE=True (silently no-ops) direct INCR/EXPIRE on a source-keyed key; EDGE_NODE=True is startup-fatal

In-memory response cache

Allowlisted GET/HEAD responses are public, anonymous, and read-only, so Postern caches them in-process to shed load off the upstream and absorb public-traffic bursts. The cache is a bounded per-worker LRU with a TTL — deliberately in-memory, never Redis. This is the inverse of the rate limiter: the limiter must be the shared Redis counter to be correct across workers and HPA replicas, whereas a cache is a per-process optimization where a cross-worker miss merely costs one upstream fetch. Each uvicorn worker / replica keeps its own cache; staleness is bounded by the TTL, not by cross-worker coherence (mirrors the per-worker service-token cache).

  • Keyed on (method, normalized-backend-path, query) — the normalized path the allowlist matched (never the raw path), so the cache can never serve a traversal the allowlist rejected.
  • Cacheable only when the request is allowlisted (GET/HEAD) and the upstream returned 2xx; non-2xx, non-allowlisted, and any non-GET/HEAD response is never cached.
  • A cache hit does not bypass the security path (Invariant 10): the request still derives source identity, strips inbound Authorization, matches the allowlist, consumes a rate-limit token, and is audited (Invariant 7). A hit only elides the service-token mint and the upstream forward — so caching can never lift the rate limit, the allowlist, or the audit.
  • Bounded by CACHE_MAX_MB per worker (LRU eviction on the byte budget) and CACHE_TTL_S (expiry); CACHE_MAX_MB = 0 disables the cache.
  • The cached bytes count against pod memory; the chart sizes the memory request and limit as base + CACHE_MAX_MB (per worker) so a full cache cannot OOM the pod.

Configuration (platform.service-configuration)

All config is read through a single Settings(AxonisSettings) subclass in server/config.py, exposed via @lru_cache get_settings(). config.py is the only env reader in the service — there is no os.getenv anywhere else in source (including observability.py), and no module-level singletons. Postern reuses the platform-common AxonisSettings env names for shared concerns (SSO, TLS, Elasticsearch, Redis, OpenTelemetry) and defines only the Postern-specific knobs — with no POSTERN_ prefix.

Inherited from AxonisSettings (platform-common — Postern consumes, does not redefine):

Field Env Postern's use
sso_token_url SSO_TOKEN_URL Realm token endpoint for the client-credentials grant (the limited client's secret is POSTed here). SSRF-guarded by a config.py validator (http(s) only; no loopback/link-local/RFC-1918) — a misconfigured endpoint would exfiltrate the secret. Invalid → startup-fatal.
sso_verify SSO_VERIFY TLS verification for the token POST. Never verify=False in code.
elastic_host / elastic_verify ELASTIC_HOST / ELASTIC_VERIFY Append-only audit sink (postern index); TLS per ELASTIC_VERIFY.
redis_host / redis_port / redis_db / redis_password / redis_verify REDIS_* / REDIS_VERIFY Per-source rate-limit counter connection; TLS per REDIS_VERIFY.
otel_enabled, otel_service_name, otel_exporter_otlp_endpoint, otel_exporter_otlp_protocol OTEL_ENABLED, OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_* OpenTelemetry via the shared axonis.observability.instrument() (platform.observability). Gating + exporter use these standard fields — Postern adds no OTEL config of its own.

Postern-defined (declared in server/config.py; unprefixed):

Field Env Purpose
platform_api_url PLATFORM_API_URL Platform REST entry Postern forwards to (Traefik ClusterIP) — the common platform-API var (replaces the former POSTERN_UPSTREAM_URL). Validated at startup: http(s) only, and the host must not resolve to a loopback / link-local / RFC-1918 range (127/8, 169.254/16, 10/8, 172.16/12, 192.168/16, ::1, fc00::/7 ULA, fe80::/10 link-local) — an SSRF guard, since an anonymous caller would otherwise relay through Postern's token to e.g. 169.254.169.254. Invalid → startup-fatal.
public_path_prefix PUBLIC_PATH_PREFIX The public path prefix Postern is reachable under and strips before matching/forwarding (default /public-api). Must be a non-empty absolute path starting with /; a trailing slash is normalized off. The Ingress rule (platform.ingress-routing) installs the matching Prefix path.
public_sso_client_id PUBLIC_SSO_CLIENT_ID Limited Keycloak client id — the separate, narrow client, never the platform SSO_CLIENT_ID. (secret)
public_sso_client_secret PUBLIC_SSO_CLIENT_SECRET The limited client's secret. (secret)
allowlist ALLOWLIST (method, backend-path) rules, default-deny, in backend-path terms (the path after the public prefix is stripped). Methods may be GET/HEAD/POST (Invariant 2); any other method in a rule is a parse error. Authored as a YAML list in values.yaml; the chart renders the list newline-joined into the ALLOWLIST env var, and config.py parses newline/comma-separated entries. A parse error is startup-fatal (never silently degrade to an empty allowlist that looks healthy but rejects everything).
rate_limit_per_min RATE_LIMIT_PER_MIN Per-source request ceiling, enforced by the Redis rate limiter.
trusted_proxy_depth TRUSTED_PROXY_DEPTH How many trailing X-Forwarded-For hops are trusted (the ingress chain). The source identity is the entry at that depth — never the raw leftmost (caller-controlled) XFF, never request.client.host (the ingress IP). Must be >= 1; 0 or unset is startup-fatal. Required for correct per-source rate limiting (Invariant 9).
cors_allow_origin CORS_ALLOW_ORIGIN Access-Control-Allow-Origin for the CORS preflight and the relayed response (default *).
cache_max_mb CACHE_MAX_MB Per-worker in-memory response-cache byte budget (MB) for allowlisted GET/HEAD. 0 disables the cache. Cached bytes are reflected in the pod memory request/limit (chart).
cache_ttl_s CACHE_TTL_S Max age (seconds) of a cached response before it is re-fetched from the upstream.

EDGE_NODE is checked at startup via axonis.env.is_edge_node() (the same function that makes axonis.redis swap in the no-op NullClient); EDGE_NODE=True would silently disable rate limiting, so it is startup-fatal (Invariant 9).

Package structure (platform.service-contract)

postern/
├── postern/                       # Domain package (shares repo name)
│   ├── allowlist.py               # default-deny (method, backend-path) matcher; normalizes path first
│   ├── ratelimit.py               # direct Redis INCR/EXPIRE per source (atomic: SET..NX EX + INCR)
│   ├── token.py                   # self-contained client-credentials grant + cache/refresh
│   ├── proxy.py                   # raw-HTTP relay; strips public prefix; header strip + inject; forwards normalized backend path
│   └── audit.py                   # append-only ES `postern` audit (allow AND deny)
├── server/                        # FastAPI app: /health, /service-info, catch-all proxy route under the public prefix
│   ├── config.py                  # Settings(AxonisSettings) — the SINGLE env reader (no os.getenv elsewhere)
│   └── observability.py           # OTEL via axonis.observability.instrument() (platform.observability); no os.getenv
├── charts/postern/                # Bitnami-pattern Helm chart (port 8012)
└── pyproject.toml                 # packages = ["postern", "server"]

No server/mcp/ (documented dual-interface exception above).

/service-info

Postern emits the full platform.service-contract registration contract, with the MCP fields zeroed (it has no agent surface): name: "postern", version, health_path: "/health", api_path: settings.public_path_prefix, mcp_path: null (explicit — platform.service-contract mandates the key be present; null signals "no MCP mount" to Oracle's registry normalizer, distinct from the /agentspace default), tools_count: 0, resources_count: 0, capabilities: ["public-proxy"], objects: []. The client secret never appears here (Invariant 8). /health and /service-info are pod-direct, not exposed via the public Ingress.

Integration

  • Inbound (public → Postern): no auth middleware; the catch-all route under the /public-api prefix runs the request lifecycle above. This is the only platform route without requires_auth, and it is safe precisely because it forwards only allowlisted reads under a limited token.
  • Postern → Keycloak: self-contained client-credentials request to the realm token endpoint (SSO_TOKEN_URL) for the limited service token (Postern mints it; axonis.auth has no client-credentials helper). On a Keycloak 4xx the error body (which can echo the client id) is logged internally and never surfaced in Postern's 503 response.
  • Postern → platform REST: raw HTTP relay to PLATFORM_API_URL with the minted token as Bearer, using the prefix-stripped, normalized backend path that the allowlist matched (never the raw inbound path — else a traversal that the allowlist normalized away could reach the upstream). Because the forwarded path lacks the prefix so it routes to the owning authenticated service, never loops. The Host header is rewritten to the PLATFORM_API_URL host — forwarding the inbound Host: public-api.<domain> would re-match Postern's own catch-all Ingress and loop. The inbound X-Forwarded-For is not passed through (it is consumed for source identity only); Postern sets the upstream XFF itself so a caller cannot pollute upstream logging/auth-context. The query string is forwarded verbatim — the allowlist matches on path only, so for allowlisted endpoints the Keycloak role is the floor that constrains query-driven behavior (accepted; documented). Upstream status + body relayed faithfully (not ServiceClient.get()); topology-leaking response headers (Server, X-Powered-By, X-Internal-*) are stripped before relay. The relay client does not follow redirects — a 3xx Location from the upstream is relayed verbatim to the caller, never chased (else a Location: http://169.254.169.254/… would bypass the startup SSRF guard at runtime).
  • Postern → Redis: direct INCR/EXPIRE per-source counters (keys prefixed postern:rl: for isolation/observability on the shared Redis) over the Redis connection (TLS per REDIS_VERIFY) — not the session-namespaced axonis.redis.Client CRUD API. A Redis error on the rate-limit check fails closed (503), never fail-open — on an anonymous surface a Redis hiccup must not lift the limit.
  • Observability: OTEL via the shared axonis.observability.instrument() (platform.observability), gated by OTEL_ENABLED — instruments the app plus its Redis and httpx clients. No hand-rolled instrumentation and no os.getenv (the standard OTEL_* settings come through config.py).
  • Oracle: Postern does not self-register for MCP tool routing (no MCP surface). It still exposes /service-info (empty objects, capabilities: ["public-proxy"]) for discovery.

Deployment & observability

Bitnami Helm pattern at charts/postern/, port 8012 (confirm in platform.ingress-routing). The chart installs the /public-api path-prefix Ingress (priority 100, host-less, path /public-api Prefix → Postern). Chart values.yaml config keys are unprefixed / platform-common (no POSTERN_ prefix), and ALLOWLIST is authored as a list, rendered newline-joined into the env var. OTEL via the shared axonis.observability.instrument() (platform.observability), gated by OTEL_ENABLED; TLS verification for SSO / Elasticsearch / Redis via the inherited SSO_VERIFY / ELASTIC_VERIFY / REDIS_VERIFY. HPA on request rate. Pod memory request+limit are sized for the in-memory response cache (base + CACHE_MAX_MB). ES index postern for the append-only request audit. Observability note: because every allowlist denial is an opaque 404 (Invariant 1), a denial is indistinguishable from a relayed upstream 404 at the HTTP layer — alerting must not key on the raw status. The distinction lives only in the postern audit index: a denial is verdict ∈ {denied, method_not_allowed} with upstream_status: null, whereas a forwarded request that the upstream 404s is verdict: allow with upstream_status: 404. The denial query is status=404 AND verdict != allow, not status=404.

Invariants

  1. Default-deny, opaque. A request matching no allowlist rule — an unlisted path or a disallowed method on a listed path — is rejected with 404 and never forwarded. The code is deliberately 404, never 403/405: on the platform's only unauthenticated public surface a uniform "Not Found" reveals nothing about whether a path exists or which methods it permits, so an anonymous prober cannot map the allowlist by reading the status. A mutating request outside the enumerated write allowlist is one such denial — it returns 404 and never reaches the backend (cortex).
  2. Allowlisted methods only. Only GET, HEAD, and explicitly enumerated POST entries may be allowlisted; PUT/PATCH/DELETE may never be, and any unlisted method/path → 404 (the opaque default-deny of Invariant 1 — never forwarded, never reaches the backend). Allowlisted POSTs are never cached (Invariant 10) and carry stricter abuse controls (Invariant 12). OPTIONS is handled locally as a CORS preflight (never forwarded, never a service token minted) — the target audience is a browser, so Postern answers preflight with Access-Control-Allow-Origin (configurable, default *), Access-Control-Allow-Methods: GET, HEAD, OPTIONS (plus POST only when a POST rule is configured), and the requested headers; without this the browser never issues the real GET.
  3. Defense in depth. The allowlist is the ceiling on reachable endpoints; the limited Keycloak role is the floor enforced by every backend. Neither alone is the sole control.
  4. No caller credential passthrough. Postern strips any inbound Authorization header and injects only its own minted service token. A caller can never smuggle a token through Postern.
  5. Fail closed. No service token → 503; not allowlisted (unlisted path or disallowed method) → 404; over rate limit → 429; a Redis error on the rate-limit check → 503 (never fail-open). Postern never forwards a request without a successfully minted limited token.
  6. Path-prefix isolation. The /public-api/ prefix is the platform's only unauthenticated surface and routes exclusively to Postern. Postern serves only requests under its prefix (anything else → 404), strips the prefix, and forwards the remainder onto the authenticated routes; no authenticated object route is installed under /public-api, and Postern installs no Ingress rule outside its prefix. The forwarded (stripped) path re-enters at the normal authenticated routes — it can never resolve back to Postern.
  7. Full audit. Every request — allowed and denied — is recorded append-only to the postern ES index.
  8. Secret hygiene. The client secret is never logged, echoed in /service-info, or forwarded upstream. Neither the secret nor the minted access token ever appears in logs, OTEL span attributes, or the audit record.
  9. Trustworthy source identity. The per-source rate-limit key is derived from X-Forwarded-For honoring a configured trusted-proxy depth — never the raw client-controllable leftmost XFF entry, and never request.client.host alone (which is the ingress IP behind K8s). The allowlist matches the normalized backend path (public prefix stripped, percent-decoded, // collapsed, .. resolved), so traversal cannot reach a non-allowlisted endpoint. EDGE_NODE=True (which would null the Redis client and disable rate limiting) is startup-fatal for Postern.
  10. In-memory cache, never authoritative. The response cache is per-worker in-memory (never Redis); a hit still passes the allowlist + rate-limit + audit and only elides the token mint + upstream forward. Only allowlisted GET/HEAD 2xx responses are cached, keyed on the normalized backend path; allowlisted writes (POST) are never cached. The cache is bounded by CACHE_MAX_MB and CACHE_TTL_S.
  11. Single config reader. All environment variables are read through server/config.py (Settings(AxonisSettings)); no os.getenv appears elsewhere in source. Shared concerns reuse the platform-common env names (SSO_TOKEN_URL, SSO_VERIFY, ELASTIC_*, REDIS_*, OTEL_*, PLATFORM_API_URL); only Postern-specific knobs are bespoke, and none carries a POSTERN_ prefix.
  12. POST abuse controls. Allowlisted POST endpoints enforce a request body-size cap and a stricter per-source rate limit than reads (separate counter, postern:rl:w: key prefix); over-limit → 429, oversized → 413, both before token mint and forward. The minted limited token's Keycloak role remains the floor on what an allowlisted POST can do downstream (Invariant 3), and the upstream owner of the endpoint performs all content validation — Postern relays the body faithfully and never inspects or rewrites it.

Rollout

  1. Platform team creates the limited Keycloak client + read-only role scoped to the allowlist.
  2. Stand up Postern with the allowlist for Hebronsoft's required read endpoints (in backend-path terms, authored as a list), rate limiting, and the /public-api path-prefix Ingress; forward to the platform REST entry via PLATFORM_API_URL.
  3. Validate end to end: anonymous GET https:///public-api/ returns data; a non-allowlisted path returns 404; a mutating method outside the write allowlist returns 404 (and never reaches the backend); an allowlisted POST forwards with the minted token and is never cached; token-outage returns 503.
  4. Hand Hebronsoft the public base path (https:///public-api); authenticated integrations stay on the existing host unchanged.

Do not allowlist any mutating method other than explicitly enumerated POST entries (never PUT/PATCH/DELETE), and do not fold the anonymous path into Oracle's authenticated middleware.


Depends on: platform.axonis-core, platform.ingress-routing, platform.observability, platform.service-configuration, platform.service-contract

Required by: component.beacon.ticketing