Conversation
Contributor
There was a problem hiding this comment.
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", andserver.tls.enabled: true. - Update
AllAuthRequirementPolicyto exempt onlyGET /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 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 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
force-pushed
the
feat/ship-hardened-defaults
branch
from
August 26, 2026 18:17
b69681b to
0771718
Compare
bburda
marked this pull request as draft
August 26, 2026 20:10
bburda
force-pushed
the
feat/ship-hardened-defaults
branch
from
August 27, 2026 20:28
8d5989c to
9bc6bcb
Compare
bburda
force-pushed
the
feat/ship-hardened-defaults
branch
2 times, most recently
from
September 12, 2026 17:44
975193c to
e94fb3e
Compare
bburda
force-pushed
the
feat/ship-hardened-defaults
branch
from
September 13, 2026 12:05
e94fb3e to
7c947d9
Compare
bburda
marked this pull request as ready for review
September 13, 2026 13:59
mfaferek93
reviewed
Sep 14, 2026
…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
force-pushed
the
feat/ship-hardened-defaults
branch
from
September 18, 2026 18:04
0e72d75 to
e11ec25
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull Request
Summary
The gateway gets a closed profile, not closed defaults.
config/gateway_params.yamlstays 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.yamlcarries the closed posture: auth on,require_auth_for: "all", TLS on, rate limiting, an explicit CORS origin list. It is selected withconfig_file:=, andgateway.launch.pywarns 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_SECRETturns auth on withrequire_auth_for: "all"and that secret, whatever a parameters file or a launch argument says.MEDKIT_CLIENTSreplaces the client list: comma-separatedid:secret:roleentries, 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_CLIENTSwithoutMEDKIT_JWT_SECRETis ignored with a WARN.MEDKIT_AUTH_DISABLED=1opens 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 getreports 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; aparam set, aparam loadentry or an atomic batch touching them is refused with a reason, and the other parameters in aparam loadstill apply.MEDKIT_JWT_SECRETtogether withauth.jwt_algorithm: RS256stops 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_routesis validated whether or not auth is on; a blank entry such as[""]means no routes.Tokens and revocation:
forward_authpeers need. The role it grants comes from the receiving gateway'sauth.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.POST /auth/revoketakes 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 mosttoken_expiry_secondsafter it. Gateways that share a signing configuration must sharetoken_expiry_secondsandrefresh_token_expiry_seconds, or a revocation on the peer with the shorter value lapses while the issuer's tokens are live.Requests:
auth.public_routes, a list of exact"METHOD /path"entries that answer without a credential. It ships empty. WhenGET /api/v1/healthis listed, an anonymous caller on it gets a reduced body:status,timestamp, an emptywarnings,warning_schema_versionandx-medkit-reduced: true; the entity-cache counts and linking warnings need a token. On a route that is not listed,/healthkeeps its sections, so an anonymousGET /healthunderrequire_auth_for: "write"answers as before.WWW-Authenticateplus the error document) whether it is the first request or the one that exhausted the anonymous burst, and noX-RateLimit-*headers. A caller over the limit that presents anyAuthorizationheader, a valid one included, gets a bare 429 with noRetry-Afterand noX-RateLimit-*, before the token is parsed; on the closed profile a client therefore paces itself fromX-RateLimit-Reseton its last accepted answer. A plainOPTIONSis metered like any other request; only a real preflight is exempt.server.tls.min_versionis enforced, andserver.tls.ca_fileturns on mutual TLS: every client must then present a certificate signed by that CA.The container image:
server.host0.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 setsserver.hostandrefresh_interval_msagain, anddocker run <image> --ros-args -p server.port:=9090keepsserver.hostat 0.0.0.0. The DockerfileCMDis empty.localhost:3000andlocalhost:5173CORS origins it used to. The compose example in docker.rst mounts a params file naminghttp://localhost:3000.--params-fileplaced after the secure one.MEDKIT_JWT_SECRET, closed withMEDKIT_JWT_SECRETover a mounted file that says auth off. Every push tomainalso publishes:main-<sha7>next to:latest; release tags keep:sha-<sha7>for the multi-arch manifest list.MEDKIT_TLS_CERT_FILEandMEDKIT_TLS_KEY_FILEare gone from the launch file. The environment contract is the three variables above.test_openapi_contractwaits for a discovered service before comparing operation items, which is what the humble run was racing.Issue
Refs #259 (item 4, the
/healthinformation leak).Type
Breaking changes
CMDinto the entrypoint and theCMDis empty. Arguments no longer replace the image's values.localhost:3000andlocalhost:5173CORS origins. A browser client needscors.allowed_originsin a mounted file.token_expiry_secondsafter one.forward_authneed the client in their ownauth.clients.OPTIONSis metered.auth.public_routesis validated with auth off; a malformed entry that used to pass unnoticed now stops the gateway.MEDKIT_AUTH_DISABLED=1opens any gateway.MEDKIT_TLS_CERT_FILEandMEDKIT_TLS_KEY_FILEno longer exist.Testing
test_open_default_profilepins the compatibility promise: the packaged default file answers an anonymousGET /areasand a full anonymous/health; with the file mutated to auth on it fails on all three assertions.test_secure_profileandtest_closed_by_defaultlaunch 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_contractdrives 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 refusedparam set. After shutdown it scans both gateways' output for the secret values.test_forward_auth_across_gatewaysruns two gateways underforward_auth: a token issued on one works on the other, is refused there afterPOST /auth/revokeon the peer, and grants the peer's role.test_rate_limitingandtest_auth_policy_contractpin the single 401 shape, the bare 429, and thatX-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_floordrivesopenssl s_clientagainst three real gateways: floors at 1.2 and 1.3, plus one requiring a client certificate.scripts/smoke_image_auth_posture.shagainst 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.portkeepingserver.host, eight cases green. The docker.rst closed-profile example was run against the same image.Checklist