Skip to content

gateway: add a closed profile, a public-route list, and an environment switch that closes a container - #641

Open
bburda wants to merge 4 commits into
mainfrom
feat/ship-hardened-defaults
Open

bburda wants to merge 4 commits into
mainfrom
feat/ship-hardened-defaults

Conversation

@bburda

@bburda bburda commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

The gateway gets a closed profile, not closed defaults. config/gateway_params.yaml stays open exactly as it is today (auth off, require_auth_for: "write", TLS off), so every existing launch, every existing configuration and the web UI keep working after an upgrade. config/gateway_params.secure.yaml carries the closed posture: auth on, require_auth_for: "all", TLS on, rate limiting, an explicit CORS origin list. It is selected with config_file:=, and gateway.launch.py warns about a missing signing secret only when the profile in force needs one.

Independently of the profile, the environment closes the gateway, and the node reads it itself when it loads its parameters, so the rule is the same on every entry path: the container entrypoint, ros2 run, and any launch file.

  • MEDKIT_JWT_SECRET turns auth on with require_auth_for: "all" and that secret, whatever a parameters file or a launch argument says.
  • MEDKIT_CLIENTS replaces the client list: comma-separated id:secret:role entries, surrounding whitespace trimmed, a duplicate id dropped with the first entry winning and a WARN. An empty value means no clients and warns. A value whose every entry is refused stops the gateway. A comma inside a secret breaks the entry. MEDKIT_CLIENTS without MEDKIT_JWT_SECRET is ignored with a WARN.
  • MEDKIT_AUTH_DISABLED=1 opens any gateway, a hardened source install included. Being able to set it on a gateway's environment is being able to turn its authentication off.
  • ros2 param get reports the posture the gateway enforces. While auth is on, every configured secret reads back as a sentinel naming where it came from (<from MEDKIT_JWT_SECRET>, <set at start>), and each client as <id>:<sentinel>:<role>. auth.* parameters are read at start; a param set, a param load entry or an atomic batch touching them is refused with a reason, and the other parameters in a param load still apply.
  • MEDKIT_JWT_SECRET together with auth.jwt_algorithm: RS256 stops the gateway. A refusal names the setting and where it came from, the environment only for the three values it can supply, never a value. A malformed file client entry is reported by position, not echoed. RS256 keys are read once at start, so a key rotation needs a restart.
  • auth.public_routes is validated whether or not auth is on; a blank entry such as [""] means no routes.

Tokens and revocation:

  • An access token is checked on its signature and the local client table. It survives a gateway restart and works on any gateway that shares the signing config, which is what forward_auth peers need. The role it grants comes from the receiving gateway's auth.clients: a client that is admin on the aggregator and viewer on a peer may only read on the peer, and a client absent from the peer's table is refused there.
  • Refresh tokens are per process and do not survive a restart.
  • POST /auth/revoke takes a refresh token and writes a revoked record keyed by its id, kept until the last access token minted on it expires; an access token presented there changes nothing (the answer stays 200). It works on a gateway that never issued the token, so revoking on a peer refuses the token on that peer. The record lives in memory, so a token revoked before a restart can return for at most token_expiry_seconds after it. Gateways that share a signing configuration must share token_expiry_seconds and refresh_token_expiry_seconds, or a revocation on the peer with the shorter value lapses while the issuer's tokens are live.
  • Refresh records are swept every five minutes and on every refresh, which bounds the store for refresh-only workloads.

Requests:

  • auth.public_routes, a list of exact "METHOD /path" entries that answer without a credential. It ships empty. When GET /api/v1/health is listed, an anonymous caller on it gets a reduced body: status, timestamp, an empty warnings, warning_schema_version and x-medkit-reduced: true; the entity-cache counts and linking warnings need a token. On a route that is not listed, /health keeps its sections, so an anonymous GET /health under require_auth_for: "write" answers as before.
  • The rate limiter answers before any token work. A caller without a credential on a protected route gets one shape of 401 (WWW-Authenticate plus the error document) whether it is the first request or the one that exhausted the anonymous burst, and no X-RateLimit-* headers. A caller over the limit that presents any Authorization header, a valid one included, gets a bare 429 with no Retry-After and no X-RateLimit-*, before the token is parsed; on the closed profile a client therefore paces itself from X-RateLimit-Reset on its last accepted answer. A plain OPTIONS is metered like any other request; only a real preflight is exempt.
  • server.tls.min_version is enforced, and server.tls.ca_file turns on mutual TLS: every client must then present a certificate signed by that CA.

The container image:

  • The config is three layers, carried by the entrypoint and applied in this order: the image's own file (server.host 0.0.0.0, a 2 s refresh interval), the mount point /etc/ros2_medkit/params.yaml, then the command's arguments. Later wins, so a mounted file sets server.host and refresh_interval_ms again, and docker run <image> --ros-args -p server.port:=9090 keeps server.host at 0.0.0.0. The Dockerfile CMD is empty.
  • The image no longer allows the localhost:3000 and localhost:5173 CORS origins it used to. The compose example in docker.rst mounts a params file naming http://localhost:3000.
  • The secure profile ships in the image at its documented path and binds 8443; TLS still needs the certificate files mounted, and the secret comes from the environment or from a second --params-file placed after the secure one.
  • The publish workflow checks the image's posture on the built digest before naming it: open with no environment, closed with MEDKIT_JWT_SECRET, closed with MEDKIT_JWT_SECRET over a mounted file that says auth off. Every push to main also publishes :main-<sha7> next to :latest; release tags keep :sha-<sha7> for the multi-arch manifest list.

MEDKIT_TLS_CERT_FILE and MEDKIT_TLS_KEY_FILE are gone from the launch file. The environment contract is the three variables above.

test_openapi_contract waits for a discovered service before comparing operation items, which is what the humble run was racing.


Issue

Refs #259 (item 4, the /health information leak).


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Breaking changes

  • Image users passing arguments: the config layers moved from the Dockerfile CMD into the entrypoint and the CMD is empty. Arguments no longer replace the image's values.
  • The image drops its localhost:3000 and localhost:5173 CORS origins. A browser client needs cors.allowed_origins in a mounted file.
  • Access tokens survive a restart; a revoked token can return for at most token_expiry_seconds after one.
  • The role a token grants comes from the receiving gateway's client table, and a client absent from that table is refused, so peers under forward_auth need the client in their own auth.clients.
  • An exhausted caller presenting a credential gets a bare 429 before the token is checked, and a plain OPTIONS is metered.
  • auth.public_routes is validated with auth off; a malformed entry that used to pass unnoticed now stops the gateway.
  • MEDKIT_AUTH_DISABLED=1 opens any gateway.
  • MEDKIT_TLS_CERT_FILE and MEDKIT_TLS_KEY_FILE no longer exist.

Testing

  • test_open_default_profile pins the compatibility promise: the packaged default file answers an anonymous GET /areas and a full anonymous /health; with the file mutated to auth on it fails on all three assertions.
  • test_secure_profile and test_closed_by_default launch the secure profile. The second asks the running gateway for its route table and calls every route without a credential and with a fake token: a token whose signature has one byte changed, a correctly signed token naming an unknown client, and a refresh token presented as an access token.
  • test_env_auth_contract drives the environment rule against the node: precedence over a file and a launch argument, the empty and the all-refused client list, the RS256 refusal, the parameter read-back and the refused param set. After shutdown it scans both gateways' output for the secret values.
  • test_forward_auth_across_gateways runs two gateways under forward_auth: a token issued on one works on the other, is refused there after POST /auth/revoke on the peer, and grants the peer's role.
  • test_rate_limiting and test_auth_policy_contract pin the single 401 shape, the bare 429, and that X-RateLimit-* reaches a credentialed caller and not an anonymous one. A unit test composes the middleware and the limiter the way the server does and asserts the verifier is not reached on the exhausted-header path.
  • test_tls_protocol_floor drives openssl s_client against three real gateways: floors at 1.2 and 1.3, plus one requiring a client certificate.
  • scripts/smoke_image_auth_posture.sh against a locally built jazzy image reads the gateway's startup line back from the container log: open, closed, closed over a mounted file, a mounted value applied, and -p server.port keeping server.host, eight cases green. The docker.rst closed-profile example was run against the same image.
  • Gateway unit suite: 2948 tests, 0 failures. Integration package on jazzy: 1339 tests, 0 failures. Sphinx with warnings as errors clean.

Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

Copilot AI lite review requested due to automatic review settings August 26, 2026 18:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the default gateway posture to be “closed by default” (TLS + auth enabled, and auth required for reads as well as writes), adds a narrowly-scoped public health exemption, and introduces an integration acceptance test that sweeps the live route registry to ensure unauthenticated access is refused across the surface area.

Changes:

  • Switch shipped defaults to auth.enabled: true, auth.require_auth_for: "all", and server.tls.enabled: true.
  • Update AllAuthRequirementPolicy to exempt only GET /api/v1/health (plus /api/v1/auth/*) and add unit tests pinning the exemption boundary.
  • Add/adjust integration tests: a full route-sweep “closed by default” acceptance test and a more precise recovery poll in test_peer_recovery.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/ros2_medkit_integration_tests/test/features/test_peer_recovery.test.py Tightens polling to wait for actual “data” readiness rather than HTTP 200 alone.
src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py New integration acceptance that sweeps all registered routes for unauthenticated refusal + authenticated pass-through.
src/ros2_medkit_gateway/test/test_auth_manager.cpp Adds regression tests pinning the GET /api/v1/health exemption and /api/v1/auth/* public endpoints under the “all” policy.
src/ros2_medkit_gateway/launch/gateway.launch.py Adds launch args for enabling/disabling auth and injecting JWT secret + clients for quickstart usability.
src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp Implements the health/auth exemptions for the “all” auth-requirement policy and updates its description string.
src/ros2_medkit_gateway/design/hardening.rst Updates hardening guidance to reflect the new shipped-default locked posture and its implications.
src/ros2_medkit_gateway/config/gateway_params.yaml Changes shipped default params to enable TLS + auth and require auth for all operations.
README.md Updates example curl commands to include Authorization header.
docs/tutorials/triggers-use-cases.rst Updates curl examples to include Authorization header.
docs/tutorials/snapshots.rst Updates curl examples to include Authorization header.
docs/tutorials/scripts.rst Updates curl examples to include Authorization header.
docs/tutorials/openapi.rst Updates curl examples to include Authorization header.
docs/tutorials/migration-to-manifest.rst Updates curl examples to include Authorization header.
docs/tutorials/manifest-discovery.rst Updates curl examples to include Authorization header.
docs/tutorials/locking.rst Updates curl examples to include Authorization header.
docs/tutorials/linux-introspection.rst Updates curl examples to include Authorization header.
docs/tutorials/heuristic-apps.rst Updates curl examples to include Authorization header.
docs/tutorials/graph-provider.rst Updates curl examples to include Authorization header.
docs/tutorials/fault-correlation.rst Updates curl examples to include Authorization header.
docs/tutorials/docker.rst Updates curl examples to include Authorization header (non-health endpoint).
docs/tutorials/demos/demo-turtlebot3.rst Updates curl examples to include Authorization header.
docs/tutorials/demos/demo-sensor.rst Updates curl examples to include Authorization header.
docs/tutorials/beacon-discovery.rst Updates curl examples to include Authorization header.
docs/index.rst Adds a “ships closed” note and updates quick reference curls to include Authorization header.
docs/getting_started.rst Updates quickstart launch invocation and subsequent curl examples for the closed-by-default posture.
docs/config/discovery-options.rst Updates curl examples to include Authorization header.
docs/api/rest.rst Updates curl examples to include Authorization header.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 835 to +841
def served():
answer = self._aggregate_read_of_peer_topic()
return answer if answer.status_code == 200 else None
if answer.status_code != 200:
return None
if answer.json().get('x-medkit', {}).get('status') != 'data':
return None
return answer
Comment on lines +114 to +119
declare_clients_arg = DeclareLaunchArgument(
'auth_clients', default_value='',
description=(
'Comma-separated "client_id:client_secret:role" triples '
'(roles: viewer, operator, configurator, admin). Needed to obtain '
'a token from /auth/token.'))
Comment on lines +151 to +156
jwt_secret = LaunchConfiguration('jwt_secret').perform(context)
clients = LaunchConfiguration('auth_clients').perform(context)
if jwt_secret:
param_overrides['auth.jwt_secret'] = jwt_secret
if clients:
param_overrides['auth.clients'] = [c for c in clients.split(',') if c]
Comment on lines 97 to 99
std::string description() const override {
return "AllAuth: Authentication required for all endpoints except /auth/*";
return "AllAuth: Authentication required for all endpoints except /auth/* and GET /health";
}
Comment thread docs/index.rst Outdated
Comment on lines +57 to +59
The gateway ships closed: every route below needs a credential, and
``GET /api/v1/health`` is the only one that does not. See
:doc:`getting_started` for how to obtain ``$TOKEN``.
Comment thread docs/getting_started.rst Outdated
Comment on lines +43 to +45
ros2 launch ros2_medkit_gateway gateway.launch.py \
jwt_secret:=change-me-to-at-least-32-characters-long \
auth_clients:=demo:demo-secret:admin
@bburda bburda changed the title Ship closed by default: auth on, reads included, and a route sweep that proves it Ship closed by default: auth on, reads included, and a test that walks every route Aug 26, 2026
@bburda
bburda force-pushed the feat/ship-hardened-defaults branch from b69681b to 0771718 Compare August 26, 2026 18:17
@bburda
bburda marked this pull request as draft August 26, 2026 20:10
@bburda bburda self-assigned this Aug 27, 2026
@bburda
bburda force-pushed the feat/ship-hardened-defaults branch from 8d5989c to 9bc6bcb Compare August 27, 2026 20:28
@bburda
bburda force-pushed the feat/ship-hardened-defaults branch 2 times, most recently from 975193c to e94fb3e Compare September 12, 2026 17:44
@bburda bburda changed the title Ship closed by default: auth on, reads included, and a test that walks every route gateway: add a closed profile, a public-route list, and an environment switch that closes a container Sep 13, 2026
@bburda
bburda force-pushed the feat/ship-hardened-defaults branch from e94fb3e to 7c947d9 Compare September 13, 2026 12:05
@bburda
bburda marked this pull request as ready for review September 13, 2026 13:59
Comment thread src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp Outdated
Comment thread src/ros2_medkit_gateway/launch/gateway.launch.py
Comment thread docker/entrypoint.sh
Comment thread src/ros2_medkit_gateway/src/http/rest_server.cpp
Comment thread src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp Outdated
Comment thread Dockerfile Outdated
Comment thread docs/tutorials/docker.rst
Comment thread docker/entrypoint.sh Outdated
Comment thread src/ros2_medkit_gateway/launch/gateway.launch.py Outdated
Comment thread src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py Outdated
…route list

Profiles. config/gateway_params.yaml stays open: auth off,
require_auth_for "write", TLS off. Every existing launch, every existing
config file and the web UI send no credential. The packages are in
rosdistro, so closing that file would close all of them on an apt
upgrade. config/gateway_params.secure.yaml is the closed profile: auth
on, require_auth_for "all", TLS on, rate limiting on and an explicit
CORS origin list. A deployment selects it with config_file:=, which
gateway.launch.py and bringup.launch.py both take.

Launch arguments. gateway.launch.py also takes jwt_secret, auth_clients,
auth_enabled, tls_enabled, cert_file and key_file. When auth or TLS is
on and the matching value is missing, it prints what to pass. It reads
the active profile from the config_file. When it cannot read that file
as a mapping of node names, it says so and leaves the file's values
alone. package.xml adds python3-yaml for this.

Environment. The node reads MEDKIT_JWT_SECRET, MEDKIT_CLIENTS and
MEDKIT_AUTH_DISABLED when it loads its parameters. So the container
entrypoint, `ros2 run` and every launch file apply one rule:
- MEDKIT_JWT_SECRET closes the gateway with require_auth_for "all" and
  that secret.
- MEDKIT_CLIENTS replaces auth.clients. Entries are trimmed. A duplicate
  id keeps its first entry. An empty value means no clients. A list
  whose every entry is refused stops the gateway.
- MEDKIT_AUTH_DISABLED=1 opens any gateway.
- MEDKIT_JWT_SECRET together with RS256 stops the gateway.
A refusal names the setting and where it came from, never its value. A
malformed client entry is reported by its position and id. The launch
file does not read MEDKIT_TLS_CERT_FILE or MEDKIT_TLS_KEY_FILE.

Secrets. auth.jwt_secret, auth.clients and
aggregation.peer_auth_header are taken from the merged parameter
overrides and handed to their consumers. They are declared with a
sentinel and ignore_override in every auth state, so no parameter
service or parameter event carries a secret. auth.* and the peer header
are read at start, and a runtime set is refused.

Public routes. auth.public_routes is a new setting. It lists routes that
are answered with no credential. Each entry is "METHOD /path" and is
matched exactly. The list layers over require_auth_for and can only
remove a requirement, so it is the whole public surface. No shipped
profile sets it. It is validated whether or not auth is on. A malformed
entry stops the gateway. A blank entry, which is any whitespace, is
skipped.

/health. On a route listed in auth.public_routes, a caller with no
credential gets only status, timestamp, an empty warnings list and
warning_schema_version, marked x-medkit-reduced. The full body is not
safe to publish there. linking.warnings holds entity names and ROS node
FQNs, for example "App 'engine_ecu' cannot bind to '/nav/controller'".
x-medkit-entity-cache holds the counts of apps, areas and components.
The check runs before any section is built, so a section added later is
private by default.

Request order. Authentication ran after the CORS preflight and the rate
limiter, and both return Handled. With CORS on, an anonymous OPTIONS on
a protected route got 204. An exhausted limiter answered 429 on a
gateway that requires a credential for every route. The pre-routing
handler now works in this order:
- Only a real preflight, an OPTIONS with Access-Control-Request-Method,
  is exempt. A plain OPTIONS is metered and authenticated like any
  other request.
- The limiter meters every request before authentication.
- A caller over the limit who sends any Authorization header gets a
  bare 429 before any token work.
- A caller with no credential gets the middleware's standard 401, with
  WWW-Authenticate and an error document. The refusal carries the CORS
  headers, so a browser can read the 401.
- The X-RateLimit-* headers go on only after the caller is accepted or
  the route needs no credential. On an anonymous 401 they would disclose
  the limiter state.

Tokens. An access token is accepted on its signature and the local
client table. It survives a restart and works on a peer that shares the
signing config. The receiving gateway's auth.clients decides its role.
POST /auth/revoke writes a revoked record keyed by the refresh token's
id. It does this also on a gateway that never issued the token, and for
a refresh token past its expiry. An access token presented there changes
nothing. A foreign record is kept at most for this gateway's own refresh
lifetime. Refresh records are swept every five minutes and on every
refresh. The client secret comparison runs in constant time. RS256 keys
are read once at start.

TLS. server.tls.min_version is enforced on the server's own SSL context.
Setting ca_file turns on mutual TLS and requires a client certificate.
generate_dev_certs.sh is executable. It no longer prints a ca_file,
which would make the gateway demand a client certificate.
…ironment

The image carries three params files. The entrypoint applies them left
to right, and each can override the one before it:
- /etc/ros2_medkit/base.yaml is config/gateway_params.yaml, the file the
  package ships. The image and a source install share one posture.
- /etc/ros2_medkit/container.yaml holds the two values a container needs
  on top of it: bind every interface, and refresh every 2 s.
- /etc/ros2_medkit/params.yaml is the mount point. The image's own copy
  repeats container.yaml. A mounted file replaces that copy and wins
  wherever it sets a key, server.host and refresh_interval_ms included.
The caller's arguments come after the three files, so a caller's -p
wins over them. The Dockerfile CMD is empty. So `docker run <image>
--ros-args -p server.port:=9090` keeps server.host at 0.0.0.0.
docker/gateway_docker_params.yaml is removed. CORS names no origin.

The entrypoint exports MEDKIT_JWT_SECRET, MEDKIT_CLIENTS and
MEDKIT_AUTH_DISABLED and does not interpret them. The gateway node
applies the environment rule itself. The rule therefore holds for the
entrypoint's gateway, for `docker run <image> ros2 launch ...` and for
any command started from a shell in the container. Setting
MEDKIT_JWT_SECRET runs the container closed. MEDKIT_AUTH_DISABLED=1
forces authentication off. The Dockerfile shows how to point
--params-file at the closed profile inside the image. That profile
enables TLS, so it also needs a certificate and a key.

scripts/smoke_image_auth_posture.sh checks the image with two
instruments. For posture it reads the status code of an anonymous GET
/areas at the container's bridge address: 200 with no environment, 401
with the secret, and 401 with the secret over a params file that turns
auth off. The third case shows that the environment beats the file. For
layering it reads the gateway's startup line from the container log: a
mounted file's value applies, and -p server.port keeps server.host. The
script copies a params file into the container with docker cp. A bind
mount and a published port resolve in the daemon's namespace, which
need not be the caller's.

The per-push workflow runs the smoke script against the digest it is
about to tag. It then creates :latest and :main-<sha7> from that
inspected digest in one invocation. :latest moves on every merge, so
:main-<sha7> is the immutable reference to pin. The multi-arch workflow
owns the semver tags and sha-<sha7>, which name its manifest list. The
per-push workflow owns :latest and :main-<sha7>, which name its
linux/amd64 image. No tag name is written by both, so a release tag cut
on a commit that is also on main cannot leave one name pointing at two
images. A comment in the multi-arch workflow records this split.
…miter

test_closed_by_default runs a gateway on the secure profile. It asks
the gateway for its route table and calls every route with no
credential, then again with a fake token. It asserts that the table is
large, because a sweep over three routes proves almost nothing. A test
that read config values would keep passing after a route is registered
outside the policy. The test sets rate limiting and the docs routes to
values the sweep needs. At the profile's allowance most of the sweep
would answer 429, and with /docs off it would cover less.

The limiter cases pin the refusal itself: the challenge header, the
error document and the single 401 shape. A caller over the limit gets a
bare 429 with a valid bearer token and on a plain OPTIONS. The limiter
headers reach credentialed callers only.

test_secure_profile boots from config/gateway_params.secure.yaml and
test_open_default_profile boots from config/gateway_params.yaml, so a
flip in either direction fails.

test_env_closes_the_gateway drives gateway.launch.py with
MEDKIT_JWT_SECRET in the environment and no launch arguments. The
container image uses this path.

test_env_auth_contract drives the environment rule against the node:
- precedence over a params file, proven through a value only the file
  sets
- an empty client list, an all-refused client list and the RS256
  refusal
- the parameter read-back, and the refused param set, param load and
  atomic batch
- the sentinels in every auth state
- a scan of every gateway's output for the secrets after shutdown
The params file for `ros2 param load` is keyed on the absolute node
name, /gateway_env_closed, because the loader matches keys against that
name. Gateways in the refusal cases are killed as a process group.

test_forward_auth_across_gateways runs two gateways under forward_auth.
A token issued on one works on the other. It is refused there after a
revoke on the peer, and it grants the peer's role. CMake registers it
as a two-gateway test.

test_tls_protocol_floor drives openssl s_client against a real gateway
for the protocol floor and for client-certificate verification. An
HTTP client can observe neither.

test_openapi_contract now waits for calibration's operation before it
compares built items. A node is listed in the ROS graph before its
service endpoints propagate. A discovery sweep can then build the App
with an empty service list, and the cache-derived operation items in
/docs come from that list.

The health-readiness helper accepts 401 and 403. It waits for a process
that listens and speaks HTTP, and a refusal proves both. package.xml
adds python3-yaml as a test dependency.
…age layers

The configuration reference, the hardening design doc, the
authentication tutorial and the HTTPS tutorial name the profile each
value belongs to. hardening.rst adds what the secure profile costs to
run, and says the secret parameters carry a placeholder, so ros2 param
get and /parameter_events do not show them. The gateway refuses to start
with auth on and no signing secret. Under require_auth_for "all", only
/api/v1/auth/* is exempt. Health also needs a credential. A probe
accepts 401, or the operator names the route in auth.public_routes.

server.rst, rest.rst, aggregation.rst, authentication.rst, docker.rst
and hardening.rst describe:
- the environment rule and its notices
- what survives a restart: access tokens do, refresh tokens do not
- the revocation denylist, and the shared-expiry rule under
  forward_auth
- the limiter's answer for each kind of caller
- the five keys of the reduced health body

The configuration reference, the HTTPS tutorial and the gateway README
no longer describe ca_file as reserved. It turns on mutual TLS and
requires a client certificate from every caller. Bearer-token clients
without a certificate can then no longer connect.

The docker tutorial describes an image that answers without a
credential and how to close it. It covers the precedence between the
environment and a params file, the image's three config layers and the
order they apply in, the closed-profile run command, the CORS file of
the compose example, and the main-<sha7> tag published next to :latest
on every push to main.

The two new subsections under Authentication in server.rst use the
underline character that the file already uses for level 3. docutils
fixes heading depths by the first character it sees at each depth, so a
new character would be an inconsistent title level.
@bburda
bburda force-pushed the feat/ship-hardened-defaults branch from 0e72d75 to e11ec25 Compare September 18, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants