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)
- Blast radius. Compromising Postern exposes only the limited service-token scope — never Oracle's OIDC callback flow, token exchange, or a user session.
- No anonymous branch in the auth path. Folding "no auth required here" into
OracleAuthMiddlewarewould 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. - 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-mostly —
GET, 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 returned2xx; non-2xx, non-allowlisted, and any non-GET/HEADresponse 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_MBper worker (LRU eviction on the byte budget) andCACHE_TTL_S(expiry);CACHE_MAX_MB = 0disables 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.authhas 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_URLwith 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. TheHostheader is rewritten to thePLATFORM_API_URLhost — forwarding the inboundHost: public-api.<domain>would re-match Postern's own catch-all Ingress and loop. The inboundX-Forwarded-Foris 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 (notServiceClient.get()); topology-leaking response headers (Server,X-Powered-By,X-Internal-*) are stripped before relay. The relay client does not follow redirects — a 3xxLocationfrom the upstream is relayed verbatim to the caller, never chased (else aLocation: http://169.254.169.254/…would bypass the startup SSRF guard at runtime). - Postern → Redis: direct
INCR/EXPIREper-source counters (keys prefixedpostern:rl:for isolation/observability on the shared Redis) over the Redis connection (TLS perREDIS_VERIFY) — not the session-namespacedaxonis.redis.ClientCRUD 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 byOTEL_ENABLED— instruments the app plus its Redis and httpx clients. No hand-rolled instrumentation and noos.getenv(the standardOTEL_*settings come throughconfig.py). - Oracle: Postern does not self-register for MCP tool routing (no MCP surface). It still
exposes
/service-info(emptyobjects,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
- 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, never403/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). - Allowlisted methods only. Only
GET,HEAD, and explicitly enumeratedPOSTentries may be allowlisted;PUT/PATCH/DELETEmay 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).OPTIONSis handled locally as a CORS preflight (never forwarded, never a service token minted) — the target audience is a browser, so Postern answers preflight withAccess-Control-Allow-Origin(configurable, default*),Access-Control-Allow-Methods: GET, HEAD, OPTIONS(plusPOSTonly when a POST rule is configured), and the requested headers; without this the browser never issues the realGET. - 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.
- No caller credential passthrough. Postern strips any inbound
Authorizationheader and injects only its own minted service token. A caller can never smuggle a token through Postern. - 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.
- 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. - Full audit. Every request — allowed and denied — is recorded append-only to the
posternES index. - 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. - Trustworthy source identity. The per-source rate-limit key is derived from
X-Forwarded-Forhonoring a configured trusted-proxy depth — never the raw client-controllable leftmost XFF entry, and neverrequest.client.hostalone (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. - 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/HEAD2xxresponses are cached, keyed on the normalized backend path; allowlisted writes (POST) are never cached. The cache is bounded byCACHE_MAX_MBandCACHE_TTL_S. - Single config reader. All environment variables are read through
server/config.py(Settings(AxonisSettings)); noos.getenvappears 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 aPOSTERN_prefix. - POST abuse controls. Allowlisted
POSTendpoints 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
- Platform team creates the limited Keycloak client + read-only role scoped to the allowlist.
- 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. - 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. - 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