From c194292283e43fee08cc07808505fbdd7a248a24 Mon Sep 17 00:00:00 2001 From: bburda Date: Sun, 13 Sep 2026 13:54:49 +0200 Subject: [PATCH 1/4] gateway: add a closed profile, an auth environment rule and a public-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. --- src/ros2_medkit_gateway/CMakeLists.txt | 4 + .../config/gateway_params.secure.yaml | 21 +- .../config/gateway_params.yaml | 64 +- .../ros2_medkit_gateway/core/auth/auth.hpp | 1 + .../core/auth/auth_config.hpp | 10 + .../core/auth/auth_environment.hpp | 109 ++ .../core/auth/auth_manager.hpp | 55 +- .../core/auth/auth_middleware.hpp | 48 + .../core/auth/auth_requirement_policy.hpp | 111 ++- .../ros2_medkit_gateway/core/config.hpp | 3 +- .../core/http/rate_limiter.hpp | 9 + .../core/http/rest_server.hpp | 13 + .../ros2_medkit_gateway/dto/health.hpp | 21 +- .../ros2_medkit_gateway/gateway_node.hpp | 28 + .../ros2_medkit_gateway/http/typed_router.hpp | 10 + .../launch/bringup.launch.py | 58 +- .../launch/gateway.launch.py | 235 ++++- .../launch/gateway_https.launch.py | 14 +- src/ros2_medkit_gateway/package.xml | 1 + .../scripts/generate_dev_certs.sh | 6 +- .../src/core/auth/auth_config.cpp | 15 +- .../src/core/auth/auth_environment.cpp | 245 +++++ .../src/core/auth/auth_manager.cpp | 261 ++++- .../src/core/auth/auth_middleware.cpp | 7 + .../src/core/auth/auth_requirement_policy.cpp | 122 ++- src/ros2_medkit_gateway/src/core/config.cpp | 5 - .../src/core/http/rate_limiter.cpp | 11 + src/ros2_medkit_gateway/src/gateway_node.cpp | 317 +++++- .../src/http/handlers/health_handlers.cpp | 59 +- .../src/http/http_server.cpp | 76 +- .../src/http/rest_server.cpp | 101 +- .../test/test_auth_environment.cpp | 372 +++++++ .../test/test_auth_manager.cpp | 938 ++++++++++++++++++ 33 files changed, 3228 insertions(+), 122 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_environment.hpp mode change 100644 => 100755 src/ros2_medkit_gateway/scripts/generate_dev_certs.sh create mode 100644 src/ros2_medkit_gateway/src/core/auth/auth_environment.cpp create mode 100644 src/ros2_medkit_gateway/test/test_auth_environment.cpp diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 23b20ac4d..dad064300 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -777,6 +777,10 @@ if(BUILD_TESTING) medkit_add_gtest(test_auth_config test/test_auth_config.cpp) target_link_libraries(test_auth_config gateway_ros2) + medkit_add_gtest(test_auth_environment test/test_auth_environment.cpp) + target_link_libraries(test_auth_environment gateway_ros2) + medkit_test_needs_no_domain(test_auth_environment) + # Add data access manager tests medkit_add_gtest(test_data_access_manager test/test_data_access_manager.cpp) target_link_libraries(test_data_access_manager gateway_ros2) diff --git a/src/ros2_medkit_gateway/config/gateway_params.secure.yaml b/src/ros2_medkit_gateway/config/gateway_params.secure.yaml index f157ce2d9..442b29d94 100644 --- a/src/ros2_medkit_gateway/config/gateway_params.secure.yaml +++ b/src/ros2_medkit_gateway/config/gateway_params.secure.yaml @@ -1,10 +1,23 @@ # ROS 2 Medkit Gateway - secure field profile # # Hardened parameter preset for on-prem / plant-network (appliance) -# deployments. It turns ON every control that the default development config -# leaves OFF: JWT auth, TLS, restricted CORS, and rate limiting. Use this file -# instead of gateway_params.yaml for any deployment reachable from an -# untrusted network. +# deployments. This is the closed profile: it turns ON every control that +# gateway_params.yaml leaves OFF - JWT auth with require_auth_for "all", TLS, +# rate limiting - and replaces the default's empty CORS origin list with an +# explicit one. Point --params-file at this file, in place of +# gateway_params.yaml, for any +# deployment reachable from an untrusted network. +# +# gateway_params.yaml stays open so that an existing launch, an existing +# config and the web UI keep working unchanged. Closing a gateway is a +# deployment decision, and this file is how it is made: +# +# ros2 launch ros2_medkit_gateway gateway.launch.py \ +# config_file:=$(ros2 pkg prefix --share ros2_medkit_gateway)/config/gateway_params.secure.yaml \ +# jwt_secret:="$MEDKIT_JWT_SECRET" \ +# auth_clients:="operator:$OP_SECRET:operator" \ +# cert_file:=/etc/ros2_medkit/certs/server.pem \ +# key_file:=/etc/ros2_medkit/certs/server-key.pem # # ros2 run ros2_medkit_gateway gateway_node \ # --ros-args --params-file gateway_params.secure.yaml diff --git a/src/ros2_medkit_gateway/config/gateway_params.yaml b/src/ros2_medkit_gateway/config/gateway_params.yaml index d6a4dce4d..1c706d874 100644 --- a/src/ros2_medkit_gateway/config/gateway_params.yaml +++ b/src/ros2_medkit_gateway/config/gateway_params.yaml @@ -80,7 +80,10 @@ ros2_medkit_gateway: # TLS/HTTPS Configuration # Enables encrypted communication using OpenSSL tls: - # Enable/disable TLS (default: false for backward compatibility) + # Off in this profile. A gateway reachable from anything but loopback + # wants it on, and cert_file and key_file below then have to be filled + # in - the gateway refuses to start with TLS on and no certificate. + # config/gateway_params.secure.yaml is that profile. enabled: false # Path to PEM-encoded certificate file (required when TLS enabled) @@ -101,8 +104,11 @@ ros2_medkit_gateway: # Options: "1.2" (default, widely compatible), "1.3" (more secure) min_version: "1.2" - # TODO: Mutual TLS (client certificate verification) is not yet implemented - # See: https://github.com/selfpatch/ros2_medkit/issues/XXX + # Mutual TLS. Set ca_file above to the CA that signs your client + # certificates and the gateway REQUIRES one from every client: a + # caller with no certificate is rejected during the handshake, before + # any request is read. Leave ca_file empty for ordinary server-only + # TLS, which is what bearer-token clients expect. # Safety-backstop refresh interval in milliseconds. # @@ -360,11 +366,24 @@ ros2_medkit_gateway: # Authentication Configuration (REQ_INTEROP_086, REQ_INTEROP_087) # JWT-based authentication with Role-Based Access Control (RBAC) auth: - # Enable/disable authentication - # Default: false (disabled for local development) + # Off in this profile, which is what every existing launch, config and + # the web UI expect. An unauthenticated SOVD gateway does expose the + # entity tree, the fault history and every operation the plugins + # register, so a deployment reachable by anything other than the machine + # it runs on should not use this file: + # config/gateway_params.secure.yaml turns this on together with TLS and + # require_auth_for "all". + # + # With this true and jwt_secret empty the gateway REFUSES TO START + # (auth_config.cpp: "JWT secret is required when authentication is + # enabled"), so turning it on here without supplying a secret does not + # produce a half-protected gateway. enabled: false - # JWT signing secret (required when enabled) + # JWT signing secret. Required whenever auth.enabled is true - the + # gateway will not start without it. At least 32 characters for HS256. + # Inject it from a secret store or the deployment's own configuration; + # do not commit a real secret here. # For HS256: The shared secret string # For RS256: Path to the private key file (PEM format) jwt_secret: "" @@ -391,8 +410,41 @@ ros2_medkit_gateway: # - "write": Auth required for write operations (POST, PUT, DELETE) # - "all": Auth required for all operations # Default: "write" + # + # Note what "write" leaves open: every read, even with authentication + # switched on, and the reads are where the disclosure is - the entity + # tree names the machines, the fault history is the maintenance record. + # "all" closes them, leaving only /auth/* public because authentication + # cannot bootstrap through a door that demands the credential it hands + # out. config/gateway_params.secure.yaml uses "all". require_auth_for: "write" + # Routes answered with no credential at all, on top of whatever + # require_auth_for decides. Each entry is "METHOD /path", matched + # exactly: no wildcards, so this list is the whole public surface and a + # reviewer can read it as such. + # + # Empty as shipped. Add an entry when something that cannot hold a + # credential has to reach a route - a container supervisor or a load + # balancer probing health is the case this exists for: + # + # public_routes: ["GET /api/v1/health"] + # + # A liveness probe usually needs no entry: a 401 already proves the + # process is up and answering HTTP. Prefer teaching the probe to accept + # it over opening the route. When the route is opened, the body an + # anonymous caller gets is narrowed to liveness - no entity names, no + # counts - so the probe works and the disclosure does not follow. + # + # Every entry is logged at WARN on startup, once per route. + # + # Left absent, and not written as `public_routes: []`. An empty YAML + # sequence carries no type, so rclcpp cannot tell a string array from any + # other and the node dies at startup with "No parameter value set". The + # declared default is already empty, so absence and `[]` mean the same + # thing - one of them just starts. + # public_routes: ["GET /api/v1/health"] + # JWT issuer claim # Default: "ros2_medkit_gateway" issuer: "ros2_medkit_gateway" diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth.hpp index 0fb303859..d53836ccd 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth.hpp @@ -23,6 +23,7 @@ #pragma once #include "ros2_medkit_gateway/core/auth/auth_config.hpp" +#include "ros2_medkit_gateway/core/auth/auth_environment.hpp" #include "ros2_medkit_gateway/core/auth/auth_manager.hpp" #include "ros2_medkit_gateway/core/auth/auth_middleware.hpp" #include "ros2_medkit_gateway/core/auth/auth_models.hpp" diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_config.hpp index b77acb99a..ef4d74f50 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_config.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_config.hpp @@ -91,6 +91,15 @@ struct AuthConfig { // Pre-configured clients (for development/testing) std::vector clients; + /// Routes answered with no credential, each written "METHOD /path". + /// + /// Empty by default, so `require_auth_for` alone decides what a route needs. + /// An operator adds an entry when something that cannot + /// hold a credential has to reach a route - a container supervisor probing + /// `GET /api/v1/health` is the case this exists for. Matching is exact and + /// there are no wildcards, so the list reads as the whole public surface. + std::vector public_routes; + /// Permission entries for the routes the `RouteRegistry` does not hold. /// /// The gateway's own routes derive their entries from their registration @@ -119,6 +128,7 @@ class AuthConfigBuilder { AuthConfigBuilder & with_refresh_token_expiry(int seconds); AuthConfigBuilder & with_require_auth_for(AuthRequirement requirement); AuthConfigBuilder & with_issuer(const std::string & issuer); + AuthConfigBuilder & with_public_routes(const std::vector & public_routes); AuthConfigBuilder & add_client(const std::string & client_id, const std::string & client_secret, UserRole role); AuthConfig build(); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_environment.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_environment.hpp new file mode 100644 index 000000000..9e610b112 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_environment.hpp @@ -0,0 +1,109 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include + +namespace ros2_medkit_gateway { + +/// What three environment variables say about this gateway's authentication. +/// +/// `applies` false means the environment said nothing and every `auth.*` +/// parameter keeps the value the params file or the command line gave it. +struct AuthEnvironment { + /// The environment made a statement, so the fields below replace parameters. + bool applies{false}; + + /// The value `auth.enabled` takes. + bool enabled{false}; + + /// `auth.require_auth_for` is forced to "all". Never set without `enabled`: + /// closing a gateway and leaving every read open is the one outcome the + /// variable must not produce. + bool require_auth_for_all{false}; + + /// The value `auth.jwt_secret` takes. Empty unless `enabled`. + std::string jwt_secret; + + /// `MEDKIT_CLIENTS` was set, so `clients` replaces `auth.clients` even when + /// it is empty or every entry in it was refused. Leaving the file's + /// credentials standing under a secret they were not issued against would be + /// the surprise. + bool clients_given{false}; + + /// How many comma-separated entries carried anything at all. + /// + /// Separates "no credentials were asked for" (`MEDKIT_CLIENTS=`) from "every + /// credential asked for was refused" (`MEDKIT_CLIENTS=typo`). Both leave + /// `clients` empty; only the second is a list the operator wrote and the + /// gateway could not use. + std::size_t client_entries_seen{0}; + + /// The value `auth.clients` takes, one "id:secret:role" entry per element. + std::vector clients; + + /// Lines to log at WARN, in order: what the environment overrode, then every + /// client entry it refused. The operator is changing the gateway's posture + /// from outside its configuration, so the log has to say what happened. + std::vector notices; +}; + +/// Applies the environment rule to three already-read values. +/// +/// THE RULE, in one place: +/// - `MEDKIT_AUTH_DISABLED=1` turns authentication off and wins over +/// everything, the parameters and the other two variables included. +/// - otherwise a non-empty `MEDKIT_JWT_SECRET` turns authentication on, sets +/// `require_auth_for` to "all", and supplies the secret. "write" would +/// leave every read open, and the reads are the disclosure. +/// - otherwise the environment says nothing. +/// +/// `MEDKIT_CLIENTS` is read only in the second case, where a credential can be +/// exchanged for a token; set on its own it is ignored, with a notice saying +/// so. Entries are separated by commas and each is written +/// `id:secret:role`: the id is everything before the first colon, the role +/// everything after the last, and the secret is what lies between - so a +/// secret may contain colons, while an id and a role may not, and no field may +/// contain a comma. Roles are `viewer`, `operator`, `configurator`, `admin`. +/// Surrounding spaces and tabs are dropped from each entry, so `a:b:admin, +/// c:d:viewer` works; space inside a field is part of that field. An entry +/// that does not parse is refused and named by its position, and so is one +/// repeating an id an earlier entry claimed; an empty entry is a separator +/// artefact and is skipped in silence. +/// +/// Values are passed in so the rule can be exercised without touching the +/// process environment. +/// +/// @param auth_disabled Value of MEDKIT_AUTH_DISABLED, or nullopt if unset +/// @param jwt_secret Value of MEDKIT_JWT_SECRET, or nullopt if unset +/// @param clients Value of MEDKIT_CLIENTS, or nullopt if unset +/// @return What the environment decided, and what to log about it +AuthEnvironment resolve_auth_environment(const std::optional & auth_disabled, + const std::optional & jwt_secret, + const std::optional & clients); + +/// Reads the three variables from the process environment and applies the rule. +AuthEnvironment resolve_auth_environment_from_process(); + +/// The `auth.clients` list as the parameter services may show it: one +/// `::` per entry the gateway registers, in order. An +/// entry that does not parse or names an unknown role is omitted; it is +/// registered by nothing, and showing it would print whatever was put where +/// the secret goes. +std::vector redact_client_entries(const std::vector & entries, const std::string & sentinel); + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp index 578a927cb..7776fbad6 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp @@ -14,6 +14,7 @@ #pragma once +#include #include #include #include @@ -127,6 +128,20 @@ class AuthManager { */ bool requires_authentication(const std::string & method, const std::string & path) const; + /** + * @brief Whether an operator listed this route in `auth.public_routes` + * + * The question a handler asks before withholding part of its answer from an + * uncredentialed caller. Narrower than "no credential was required here": + * under `require_auth_for: "write"` every GET is answered anonymously + * because of the requirement level, and nobody decided that route by route. + * + * @param method HTTP method + * @param path Request path + * @return true if the route is exempted by name + */ + bool is_public_route(const std::string & method, const std::string & path) const; + /** * @brief The policy half of `requires_authentication` * @@ -186,6 +201,23 @@ class AuthManager { */ bool enable_client(const std::string & client_id); + /// How many times a token signature has been put through the verifier. + /// + /// The instrument for the claim that an over-limit caller does not pay for a + /// signature check: a status code alone cannot tell "the limiter answered + /// first" from "the verifier ran and its answer was discarded". Counted at + /// the top of validate_token, so it covers every caller of it. + size_t token_validation_count() const { + return token_validations_.load(std::memory_order_relaxed); + } + + /// How many refresh records are currently held. + /// + /// Public so a test can observe that the sweep actually runs. The count is + /// the thing the unbounded-growth claim is about, and asserting on it is the + /// only way to tell a sweep that works from one that is never called. + size_t refresh_token_count() const; + private: /** * @brief Generate a JWT token @@ -194,12 +226,19 @@ class AuthManager { */ std::string generate_jwt(const JwtClaims & claims) const; + /// Whether decode_jwt refuses a token past its `exp`. The signature and the + /// issuer are verified either way. + enum class Expiry { CHECK, IGNORE }; + /** * @brief Decode and verify a JWT token * @param token JWT token string + * @param expiry Whether a token past its `exp` is refused. revoke_refresh_token + * passes Expiry::IGNORE: the access tokens minted from an expired + * refresh token are exactly the ones still live. * @return JwtClaims if valid */ - tl::expected decode_jwt(const std::string & token) const; + tl::expected decode_jwt(const std::string & token, Expiry expiry = Expiry::CHECK) const; /** * @brief Generate a unique token ID @@ -230,6 +269,16 @@ class AuthManager { AuthConfig config_; + // RS256 key material, read from disk once in the constructor. Empty under + // HS256, where the secret is the configured string and there is no file. + // Const after construction, so the request threads read them without a lock. + std::string rs256_private_key_; + std::string rs256_public_key_; + + // Signature verifications performed, read through token_validation_count(). + // Relaxed ordering: it is a counter nothing synchronises on. + mutable std::atomic token_validations_{0}; + // RBAC entries check_authorization matches against, populated via // add_route_permissions() before the server starts listening. Empty until // then, and an empty set authorizes nothing - see add_route_permissions(). @@ -242,6 +291,10 @@ class AuthManager { mutable std::mutex clients_mutex_; std::unordered_map clients_; + /// Drop every expired record. The caller must already hold + /// refresh_tokens_mutex_; cleanup_expired_tokens() is the locking wrapper. + size_t cleanup_expired_locked(); + // Refresh token storage (thread-safe) mutable std::mutex refresh_tokens_mutex_; std::unordered_map refresh_tokens_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_middleware.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_middleware.hpp index b8d03c1db..3156a3034 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_middleware.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_middleware.hpp @@ -89,6 +89,54 @@ class AuthMiddleware { */ AuthMiddlewareResult process(const AuthRequest & request) const; + /** + * @brief Whether this route needs a credential at all + * + * The same question `process` asks first, exposed so a caller that has to + * decide something BEFORE running `process` - the rate limiter, choosing + * whether it may answer - reads the one policy object, so there is a single + * copy of the rule. + * + * @param request The HTTP request abstraction + * @return true if the route requires authentication + */ + bool requires_authentication(const AuthRequest & request) const; + + /** + * @brief Whether an exhausted limiter answers before the token is verified + * + * Verifying a signature is the expensive half of `process`, and an + * over-limit caller is one the gateway has already decided to refuse. So + * when all three hold - the allowance is gone, the caller presented an + * Authorization header, and the route needs one - the 429 is the answer and + * the verifier never runs. + * + * The header is what separates the two refusals, and the test is its + * PRESENCE: the value is never parsed here, so a `Basic` header or a bare + * word takes this path exactly as a bearer does. With no header at all, + * `process` short-circuits before it extracts or verifies anything, so the + * anonymous 401 already costs nothing and stays the answer. + * + * The 429 this produces reports the refusal and nothing else - no + * `Retry-After`, no `X-RateLimit-*` - because nothing about the caller has + * been verified at this point. Limiter state reaches a caller the gateway + * accepted, or one on a route needing no credential, and those are answered + * further down. + * + * @param rate_limited The allowance for this caller is gone + * @param has_authorization_header The caller sent an Authorization header + * @param route_requires_authentication The route needs a credential + * @return true if the limiter answers and the verifier is skipped + * + * @note The caller evaluates the third argument only when the first two + * hold; the route lookup is work this predicate cannot avoid once it + * has been done. + */ + static bool rate_limit_precedes_validation(bool rate_limited, bool has_authorization_header, + bool route_requires_authentication) { + return rate_limited && has_authorization_header && route_requires_authentication; + } + /** * @brief Extract bearer token from Authorization header * @param auth_header The Authorization header value diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp index 6e25328eb..5d8ea51c3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp @@ -14,6 +14,8 @@ #pragma once +#include + #include #include #include @@ -43,6 +45,30 @@ class IAuthRequirementPolicy { */ virtual bool requires_authentication(const std::string & method, const std::string & path) const = 0; + /** + * @brief Whether an operator named this exact route in `auth.public_routes` + * + * Narrower than `!requires_authentication(...)`, and the difference is the + * point. Under `require_auth_for: "write"` every GET is answered without a + * credential because of the requirement level, which is not the same + * statement as "somebody took this route outside authentication on purpose". + * A handler that withholds part of its answer from an uncredentialed caller + * asks THIS question, so the withholding follows the operator's list and not + * the requirement level. + * + * False for every policy but PublicRouteExemptionPolicy, which is the only + * one that carries such a list. + * + * @param method HTTP method + * @param path Request path + * @return true if the route is exempted by name + */ + virtual bool is_public(const std::string & method, const std::string & path) const { + (void)method; + (void)path; + return false; + } + /** * @brief Get a human-readable description of this policy * @return Description string @@ -69,13 +95,17 @@ class NoAuthRequirementPolicy : public IAuthRequirementPolicy { /** * @brief Policy that always requires authentication * - * Except for public endpoints (auth endpoints, health check) + * The only exception is anything under `/api/v1/auth/`. Authentication cannot + * bootstrap through a door that demands the credential it exists to hand out. + * + * Nothing else is public here, health probes included. An operator who needs + * a route answered without a credential names it in `auth.public_routes`, + * which layers over this policy - see PublicRouteExemptionPolicy. */ class AllAuthRequirementPolicy : public IAuthRequirementPolicy { public: bool requires_authentication(const std::string & method, const std::string & path) const override { (void)method; - // Auth endpoints are always public (to allow login) return path.find("/api/v1/auth/") != 0; } @@ -155,6 +185,74 @@ class ConfigurableAuthRequirementPolicy : public IAuthRequirementPolicy { bool use_requirements_map_; }; +/// One entry of `auth.public_routes`: a method and a path this gateway answers +/// with no credential at all. +struct PublicRoute { + std::string method; ///< Upper-case HTTP method, e.g. "GET" + std::string path; ///< Full request path, e.g. "/api/v1/health" + + bool operator==(const PublicRoute & other) const { + return method == other.method && path == other.path; + } +}; + +/// Parses one `auth.public_routes` entry, written "METHOD /path". +/// +/// Matching is exact and there are no wildcards, so an operator cannot open a +/// subtree by accident: every route that stops requiring a credential is a +/// line somebody wrote and a reviewer can read. `GET /api/v1/health` opens the +/// health probe and nothing else, where `GET /api/v1/*` would have opened the +/// whole read surface with one character. +/// +/// @return the parsed route, or a message naming what is wrong with the entry. +tl::expected parse_public_route(const std::string & entry); + +/// True for an entry that is empty or whitespace only, as std::isspace reads +/// it - the same rule parse_public_route trims by. +/// +/// `[""]` is how a ROS 2 YAML file writes an empty string sequence - the +/// shipped profiles use it for `auth.clients` and for the bulk-data +/// categories - so a list written that way carries one blank entry and means +/// "no routes". Callers skip such an entry; it is neither a route nor a typo. +bool is_blank_public_route_entry(const std::string & entry); + +/// Parses a whole `auth.public_routes` list, skipping blank entries and +/// dropping entries that do not parse. Dropping keeps the route protected, +/// which is the safe reading of a malformed entry; GatewayNode validates the +/// list first and refuses to start so a typo cannot silently protect a route +/// the operator wanted open. +std::vector parse_public_routes(const std::vector & entries); + +/** + * @brief Layers an operator's `auth.public_routes` over another policy + * + * `require_auth_for` stays the primary axis; this only ever *removes* the + * credential requirement, never adds one, so wrapping cannot make a gateway + * stricter than the policy underneath, and it cannot shadow it. + * + * Both shipped profiles leave the list empty, which makes this a no-op: a + * route is open exactly where somebody said so and nowhere else. + */ +class PublicRouteExemptionPolicy : public IAuthRequirementPolicy { + public: + PublicRouteExemptionPolicy(std::unique_ptr inner, std::vector public_routes); + + bool requires_authentication(const std::string & method, const std::string & path) const override; + + bool is_public(const std::string & method, const std::string & path) const override; + + std::string description() const override; + + /// The routes this layer exempts, in the order they were configured. + const std::vector & public_routes() const { + return public_routes_; + } + + private: + std::unique_ptr inner_; + std::vector public_routes_; +}; + /** * @brief Factory to create auth requirement policies from configuration */ @@ -173,6 +271,15 @@ class AuthRequirementPolicyFactory { * @return Policy implementation based on config.enabled and config.auth_requirements */ static std::unique_ptr create(const AuthConfig & config); + + /** + * @brief Create policy for a requirement level, exempting configured routes + * @param requirement The auth requirement level + * @param public_routes Entries of `auth.public_routes`, already parsed + * @return The requirement policy, wrapped only when the list is non-empty + */ + static std::unique_ptr create(AuthRequirement requirement, + const std::vector & public_routes); }; } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/config.hpp index 7a2c3c33a..7288e7d79 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/config.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/config.hpp @@ -26,7 +26,8 @@ namespace ros2_medkit_gateway { * When enabled, the gateway will start an HTTPS server instead of HTTP. */ struct TlsConfig { - /// Whether TLS is enabled (default: false for backward compatibility) + /// Whether TLS is enabled. False matches config/gateway_params.yaml, the + /// default profile; config/gateway_params.secure.yaml sets it true. bool enabled{false}; /// Path to PEM-encoded certificate file diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rate_limiter.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rate_limiter.hpp index 6e09b02ee..9b4f8af81 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rate_limiter.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rate_limiter.hpp @@ -119,6 +119,15 @@ class RateLimiter { /// Set 429 rejection response with SOVD-compliant error body. static void apply_rejection(const RateLimitResult & result, httplib::Response & res); + /// Set a 429 that reports the refusal and nothing about the limiter. + /// + /// No `Retry-After`, no `X-RateLimit-*`, and a body with an empty + /// `parameters` object: the limit, the remaining allowance and the reset time + /// are all state a caller learns by asking, and the caller on this path + /// presented a credential nobody has verified. `apply_rejection` above is for + /// the caller the gateway has already accepted, who may be told the lot. + static void apply_bare_rejection(httplib::Response & res); + /// Remove tracking entries for clients that have been idle too long. void cleanup_stale_clients(); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rest_server.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rest_server.hpp index 026f2b854..517a676cc 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rest_server.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rest_server.hpp @@ -122,6 +122,19 @@ class RESTServer { std::unique_ptr auth_middleware_; std::unique_ptr rate_limiter_; + public: + /// The manager holding this server's clients and refresh records, or nullptr + /// while authentication is off. + /// + /// Borrowed, owned here. Exposed so the node can drive the periodic sweep of + /// expired refresh records: the store lives for the life of the process and a + /// deployment that only ever refreshes tokens reaches no other code that + /// would clear it. + AuthManager * auth_manager() const { + return auth_manager_.get(); + } + + private: // HTTP/HTTPS server manager std::unique_ptr http_server_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp index 7cbcb7cd5..32f31bf99 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp @@ -172,10 +172,21 @@ struct Health { /// why a peer refused their stream with 503. std::optional x_medkit_sse; std::optional peers; // free-form array of peer status objects + /// Present and true only when this answer was cut down for an anonymous + /// caller. Wire key: "x-medkit-reduced". + /// + /// `warnings` below promises that an empty array means "nothing flagged". + /// That promise cannot hold on a route an operator put in + /// `auth.public_routes`, where the sections are skipped before they are + /// built, so a monitor would read a withheld body as a clean bill of health. + /// This field is what tells it apart: when it is present, `warnings` says + /// nothing about the gateway and the caller needs a credential to learn more. + std::optional x_medkit_reduced; // wire key: "x-medkit-reduced" // NOT optional: these two are emitted in every mode, aggregation or not, so // the generated schema must list them in `required` and let a typed client // read them without a presence check. An empty `warnings` array is the - // "nothing flagged" answer - absence is not a value this endpoint has. + // "nothing flagged" answer - absence is not a value this endpoint has, + // except as qualified by `x_medkit_reduced` above. int64_t warning_schema_version{kWarningSchemaVersion}; std::vector warnings; }; @@ -190,8 +201,12 @@ inline constexpr auto dto_fields = std::make_tuple( field("discovery", &Health::discovery), field("x-medkit-data-provider", &Health::x_medkit_data_provider), field("x-medkit-subscription-executor", &Health::x_medkit_subscription_executor), field("x-medkit-entity-cache", &Health::x_medkit_entity_cache), field("x-medkit-sse", &Health::x_medkit_sse), - field("peers", &Health::peers), field("warning_schema_version", &Health::warning_schema_version), - field("warnings", &Health::warnings)); + field("peers", &Health::peers), + field("x-medkit-reduced", &Health::x_medkit_reduced, + "Present and true only when the caller presented no credential and the operator opened this route in " + "auth.public_routes. The answer then carries liveness only, and `warnings` says nothing about the " + "gateway - authenticate to read the full document."), + field("warning_schema_version", &Health::warning_schema_version), field("warnings", &Health::warnings)); template <> inline constexpr std::string_view dto_name = "HealthStatus"; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp index a46a11bf5..b4548bd9d 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -26,6 +26,7 @@ #include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" #include "ros2_medkit_gateway/core/aggregation/mdns_discovery.hpp" #include "ros2_medkit_gateway/core/auth/auth_config.hpp" +#include "ros2_medkit_gateway/core/auth/auth_environment.hpp" #include "ros2_medkit_gateway/core/condition_evaluator.hpp" #include "ros2_medkit_gateway/core/config.hpp" #include "ros2_medkit_gateway/core/data/topic_data_provider.hpp" @@ -335,6 +336,28 @@ class GatewayNode : public rclcpp::Node { /// so a misconfigured bringup is not a silent empty tree. void log_startup_summary(); + /// Writes the posture in force back into `auth.enabled` and + /// `auth.require_auth_for`, then refuses every later write to `auth.*` and + /// `aggregation.peer_auth_header`. + /// + /// The environment can decide the posture, and until this runs the two + /// parameters still carry the params file's values - so `ros2 param get` + /// would contradict what the gateway enforces. The three parameters that + /// carry secrets are not written here: they are declared with a sentinel + /// and `ignore_override` in the constructor, in every auth state. + /// + /// The on-set callback registered at the end refuses every later write to + /// these, because the configuration is consumed once at construction and a + /// silent success would report a change nobody applied. + /// + /// @param auth_enabled The posture in force + /// @param require_auth_for The requirement level in force + void apply_effective_auth_parameters(bool auth_enabled, const std::string & require_auth_for); + + /// Refuses runtime writes to `auth.*` and `aggregation.peer_auth_header`; + /// installed by apply_effective_auth_parameters once the posture is settled. + rclcpp::node_interfaces::OnSetParametersCallbackHandle::SharedPtr auth_parameter_guard_; + // Configuration parameters std::string server_host_; int server_port_; @@ -490,6 +513,11 @@ class GatewayNode : public rclcpp::Node { // Timer for periodic cleanup of expired cyclic subscriptions rclcpp::TimerBase::SharedPtr subscription_cleanup_timer_; + /// Sweeps expired refresh records. The store is cleared by nothing else on a + /// refresh-only workload, so without this it grows for the life of the + /// process. + rclcpp::TimerBase::SharedPtr refresh_token_cleanup_timer_; + // Timer for periodic cleanup of expired locks rclcpp::TimerBase::SharedPtr lock_cleanup_timer_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp index 9c7c01046..2f2b5ddb3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp @@ -149,6 +149,16 @@ class TypedRequest { return req_.path; } + /// The request method, upper-case, as cpp-httplib parsed it. + /// + /// Paired with `path()` it is what identifies a route to the auth policy, so + /// a handler that has to ask the policy about its own route asks about the + /// request it is serving, so the route it was registered under is stated + /// once. + const std::string & method() const { + return req_.method; + } + /// Framework-only escape hatch back to the raw cpp-httplib request. Do not /// use this from handler bodies - it exists for the routing layer and for /// helpers that need access to fields not yet wrapped by TypedRequest. The diff --git a/src/ros2_medkit_gateway/launch/bringup.launch.py b/src/ros2_medkit_gateway/launch/bringup.launch.py index 4fb776651..8cd95ca09 100644 --- a/src/ros2_medkit_gateway/launch/bringup.launch.py +++ b/src/ros2_medkit_gateway/launch/bringup.launch.py @@ -47,11 +47,26 @@ def generate_launch_description(): 'config', 'bringup_params.yaml', ) + # The same file gateway.launch.py defaults to. Named here so the argument + # below always carries a real path: gateway.launch.py loads this value as a + # parameters entry, and an empty string is not a file. + gateway_default_config = os.path.join( + get_package_share_directory('ros2_medkit_gateway'), + 'config', + 'gateway_params.yaml', + ) params_file = LaunchConfiguration('params_file') server_host = LaunchConfiguration('server_host') server_port = LaunchConfiguration('server_port') cors_allowed_origins = LaunchConfiguration('cors_allowed_origins') + tls_enabled = LaunchConfiguration('tls_enabled') + cert_file = LaunchConfiguration('cert_file') + key_file = LaunchConfiguration('key_file') + auth_enabled = LaunchConfiguration('auth_enabled') + jwt_secret = LaunchConfiguration('jwt_secret') + auth_clients = LaunchConfiguration('auth_clients') + config_file = LaunchConfiguration('config_file') args = [ DeclareLaunchArgument( @@ -70,6 +85,43 @@ def generate_launch_description(): default_value='http://localhost:3000,http://localhost:5173', description='Comma-separated CORS origins allowed to call the gateway from a browser, ' 'so the web UI works out of the box. Empty disables CORS.'), + # Forwarded to gateway.launch.py, along with config_file. Without them + # bringup can only run the profile gateway.launch.py defaults to and + # has nowhere to receive a certificate or a signing secret, so + # `ros2 launch ... bringup.launch.py config_file:= + # jwt_secret:=... cert_file:=...` is a complete command and not a + # dead end. + DeclareLaunchArgument( + 'config_file', default_value=gateway_default_config, + description='Path to a gateway YAML config. Defaults to the package ' + 'config/gateway_params.yaml, the profile with auth and TLS ' + 'off. Point it at config/gateway_params.secure.yaml in the ' + 'same directory for the closed profile. An empty value is ' + 'not accepted: gateway.launch.py loads this path, so it has ' + 'to name a file.'), + DeclareLaunchArgument( + 'tls_enabled', default_value='', + description='Serve HTTPS. Empty leaves it to the config file, which has it ' + 'off in the default profile and on in the secure one. Needs ' + 'cert_file and key_file when on.'), + DeclareLaunchArgument( + 'cert_file', default_value='', + description='PEM certificate for HTTPS. Required while tls_enabled is true.'), + DeclareLaunchArgument( + 'key_file', default_value='', + description='PEM private key matching cert_file.'), + DeclareLaunchArgument( + 'auth_enabled', default_value='', + description='Require a credential. Empty leaves it to the config file, ' + 'which has it off in the default profile and on in the secure ' + 'one.'), + DeclareLaunchArgument( + 'jwt_secret', default_value='', + description='HS256 signing secret, at least 32 characters. Required while ' + 'auth_enabled is true.'), + DeclareLaunchArgument( + 'auth_clients', default_value='', + description='Comma-separated "client_id:client_secret:role" triples.'), DeclareLaunchArgument( 'enable_fault_manager', default_value='true', description='Start the fault_manager node.'), @@ -90,7 +142,11 @@ def generate_launch_description(): gateway = _include( 'ros2_medkit_gateway', 'gateway.launch.py', launch_arguments={'server_host': server_host, 'server_port': server_port, - 'cors_allowed_origins': cors_allowed_origins}) + 'cors_allowed_origins': cors_allowed_origins, + 'tls_enabled': tls_enabled, 'cert_file': cert_file, + 'key_file': key_file, 'auth_enabled': auth_enabled, + 'jwt_secret': jwt_secret, 'auth_clients': auth_clients, + 'config_file': config_file}) fault_manager = _include( 'ros2_medkit_fault_manager', 'fault_manager.launch.py', enable_arg='enable_fault_manager', diff --git a/src/ros2_medkit_gateway/launch/gateway.launch.py b/src/ros2_medkit_gateway/launch/gateway.launch.py index 0da999c7f..2817a87a8 100644 --- a/src/ros2_medkit_gateway/launch/gateway.launch.py +++ b/src/ros2_medkit_gateway/launch/gateway.launch.py @@ -21,12 +21,102 @@ from launch.actions import DeclareLaunchArgument, OpaqueFunction from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node +import yaml # Default web UI origins enabled when the user does not override CORS, so the # bundled web UI works out of the box. A wildcard is deliberately not used. CORS_DEFAULT = 'http://localhost:3000,http://localhost:5173' +def parse_security_flag(name, raw): + """ + Return True/False for a security launch argument, or raise on anything else. + + An allowlist for true with everything else meaning false is the wrong shape + here: ``tls_enabled:=on`` and ``tls_enabled:=ture`` would both mean "serve + plain HTTP", and the override is still written, so the typo beats a config + file that had TLS on. For a flag whose two values are "encrypted" and "not", + an unrecognised spelling has to stop the launch, because picking one for + the operator is how a gateway ends up open. + """ + value = raw.strip().lower() + if value in ('true', '1', 'yes', 'on'): + return True + if value in ('false', '0', 'no', 'off'): + return False + raise RuntimeError( + f'{name}:={raw!r} is not a boolean. Use true or false. ' + f'Leaving {name} unset lets the config file decide.' + ) + + +def config_says(config_file, *path, default=False): + """ + Return a boolean from the config file at ``path``, or ``default``. + + Which profile is in force is a property of the file, not of this launch + file: ``config/gateway_params.yaml`` leaves auth and TLS off and + ``config/gateway_params.secure.yaml`` turns both on, and either can be + named through ``config_file``. Assuming one of them here is how a warning + ends up firing on a launch that is about to work perfectly, or staying + silent on one that is about to be refused. + + Anything this cannot read - a missing file, invalid YAML, or a document + that is not a mapping of node names - reads as ``default`` and says so, + because "cannot tell" is what it is. This decides whether to PRINT a + warning; the gateway parses the same file a moment later and is the + authority on what it contains. + """ + try: + with open(config_file, encoding='utf-8') as handle: + document = yaml.safe_load(handle) or {} + except (OSError, yaml.YAMLError): + return default + if not isinstance(document, dict): + print(f'[gateway.launch.py] {config_file} is not a mapping of node names, so ' + f'the auth and TLS settings in it cannot be read here. The gateway still ' + f'reads the file itself; only the advice printed below is affected.') + return default + for node in document.values(): + section = node.get('ros__parameters') if isinstance(node, dict) else None + for key in path: + if not isinstance(section, dict): + section = None + break + section = section.get(key) + if isinstance(section, bool): + return section + return default + + +def config_text(config_file, *path): + """ + Return a string value from the config file at ``path``, or ``''``. + + The string twin of :func:`config_says`, with the same "cannot tell reads as + the default" rule. Used to notice that a config file carries a + ``jwt_secret``, so the advice below stays quiet on a launch that is about to + work. + """ + try: + with open(config_file, encoding='utf-8') as handle: + document = yaml.safe_load(handle) or {} + except (OSError, yaml.YAMLError): + return '' + if not isinstance(document, dict): + return '' + for node in document.values(): + section = node.get('ros__parameters') if isinstance(node, dict) else None + for key in path: + if not isinstance(section, dict): + section = None + break + section = section.get(key) + if isinstance(section, str) and section: + return section + return '' + + def cors_override(cors_arg, config_file, default_config): """ Return the ``cors.allowed_origins`` entry for the final overrides, or {}. @@ -76,7 +166,10 @@ def generate_launch_description(): declare_override_config_arg = DeclareLaunchArgument( 'config_file', default_value=default_config, description='Path to YAML config file to override gateway parameters. Default config ' - 'is the ros2_medkit_gateway/config/gateway_params.yaml.') + 'is the ros2_medkit_gateway/config/gateway_params.yaml, which leaves auth ' + 'and TLS off. Point this at config/gateway_params.secure.yaml in the same ' + 'directory for the closed profile: auth on, require_auth_for "all", TLS on, ' + 'rate limiting on.') declare_host_arg = DeclareLaunchArgument( 'server_host', default_value='127.0.0.1', @@ -94,6 +187,54 @@ def generate_launch_description(): 'controls the periodic forced refresh. Must match the default ' 'in config/gateway_params.yaml.')) + declare_jwt_secret_arg = DeclareLaunchArgument( + 'jwt_secret', default_value='', + description=( + 'HS256 signing secret, at least 32 characters. Required whenever ' + 'auth is on - the default config leaves it off, the secure ' + 'profile turns it on, and the gateway refuses to start with auth ' + 'on and no secret. Pass one here, or point config_file at a file ' + 'that sets auth.jwt_secret and auth.clients.')) + + declare_auth_enabled_arg = DeclareLaunchArgument( + 'auth_enabled', default_value='', + description=( + 'Require a credential. Unset means the config_file decides: the ' + 'default config leaves auth off, config/gateway_params.secure.yaml ' + 'turns it on. With auth off the entity tree, the fault history ' + 'and every operation are readable by anyone who can reach the ' + 'port.')) + + 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.')) + + declare_tls_enabled_arg = DeclareLaunchArgument( + 'tls_enabled', default_value='', + description=( + 'Serve HTTPS. Unset means the config_file decides: the default ' + 'config leaves TLS off, config/gateway_params.secure.yaml turns ' + 'it on. With TLS on, cert_file and key_file are required - the ' + 'gateway refuses to start without a certificate, because it will not fall ' + 'back to plaintext.')) + + declare_cert_file_arg = DeclareLaunchArgument( + 'cert_file', default_value='', + description=( + 'PEM certificate (or full chain) for HTTPS. REQUIRED while ' + 'tls_enabled is true. For a first run, generate a self-signed ' + 'pair with scripts/generate_dev_certs.sh - browsers will warn, ' + 'which is correct for a certificate nothing has vouched for.')) + + declare_key_file_arg = DeclareLaunchArgument( + 'key_file', default_value='', + description=( + 'PEM private key matching cert_file. REQUIRED while tls_enabled ' + 'is true. Keep it chmod 600 and owned by the gateway user.')) + declare_cors_arg = DeclareLaunchArgument( 'cors_allowed_origins', default_value=CORS_DEFAULT, @@ -120,6 +261,92 @@ def _launch_setup(context, *_args, **_kwargs): param_overrides.update(cors_override( LaunchConfiguration('cors_allowed_origins').perform(context), LaunchConfiguration('config_file').perform(context), default_config)) + + # Precedence: an explicit launch argument, then whatever the config + # file says. Unset means "do not touch it", which matters because this + # launch file is included by others and is used with config_file: + # re-asserting a default here would silently override a value someone + # put in their own file on purpose. + tls_arg = LaunchConfiguration('tls_enabled').perform(context).strip() + if tls_arg: + tls_enabled = parse_security_flag('tls_enabled', tls_arg) + param_overrides['server.tls.enabled'] = tls_enabled + else: + # Nothing said otherwise, so the config file decides - read it + # and make no assumption about which profile is in force. + tls_enabled = config_says( + LaunchConfiguration('config_file').perform(context), + 'server', 'tls', 'enabled') + cert_file = LaunchConfiguration('cert_file').perform(context) + key_file = LaunchConfiguration('key_file').perform(context) + if cert_file: + param_overrides['server.tls.cert_file'] = cert_file + if key_file: + param_overrides['server.tls.key_file'] = key_file + if tls_enabled and not (cert_file and key_file): + # The gateway would refuse to start a moment from now, naming the + # config file. Name the launch arguments instead, here, where they + # are the thing the reader can actually change. + print('[gateway.launch.py] TLS is enabled and cert_file/key_file were not both ' + 'given. Pass cert_file:= key_file:=, set them in a config_file, ' + 'or pass tls_enabled:=false to serve plain HTTP. ' + 'scripts/generate_dev_certs.sh makes a self-signed pair for a first run.') + + # Same precedence as TLS above: explicit argument, then the config file. + # + # MEDKIT_JWT_SECRET, MEDKIT_CLIENTS and MEDKIT_AUTH_DISABLED are NOT + # read here. The gateway node reads them itself when it reads its + # parameters, and that is the only copy of the rule - see + # resolve_auth_environment. Applying it in a launch file as well would + # cover only the launches that include this file, while leaving `ros2 + # run ros2_medkit_gateway gateway_node` and every other exec path on a + # different rule; two copies of a posture rule is how they drift. + # The variables reach the node through its environment, which it + # inherits. + config_file = LaunchConfiguration('config_file').perform(context) + auth_arg = LaunchConfiguration('auth_enabled').perform(context).strip() + jwt_secret = LaunchConfiguration('jwt_secret').perform(context) + clients = LaunchConfiguration('auth_clients').perform(context) + auth_disabled_env = os.environ.get('MEDKIT_AUTH_DISABLED') == '1' + if auth_arg: + auth_enabled = parse_security_flag('auth_enabled', auth_arg) + param_overrides['auth.enabled'] = auth_enabled + asked_for_auth_by = 'auth_enabled:=true was given' + else: + auth_enabled = config_says(config_file, 'auth', 'enabled') + asked_for_auth_by = f'{config_file} turns authentication on' + if auth_enabled and auth_disabled_env: + # The node will refuse in a moment whichever way authentication was + # asked for - the argument and the config file lose alike. Say so + # here, where what the operator typed is on screen, so they see it + # there and do not have to find one WARN among the startup log. + print(f'[gateway.launch.py] {asked_for_auth_by} and MEDKIT_AUTH_DISABLED=1 is ' + 'in the environment, which wins: the gateway will start WITHOUT ' + 'authentication. Unset the variable to honour the configuration.') + if jwt_secret: + param_overrides['auth.jwt_secret'] = jwt_secret + if clients: + # Trimmed per entry, the same rule the gateway applies to + # MEDKIT_CLIENTS: the separator is a comma and the space a person + # writes after it belongs to the separator, while a space inside a + # field belongs to that field. + param_overrides['auth.clients'] = [ + entry for entry in (c.strip() for c in clients.split(',')) if entry] + secret_available = (jwt_secret or os.environ.get('MEDKIT_JWT_SECRET') + or config_text(config_file, 'auth', 'jwt_secret')) + if auth_enabled and not secret_available and not auth_disabled_env: + # The gateway would refuse to start a moment from now with a + # message about the config file. Say the actionable thing instead, + # here, where the launch argument that fixes it is in scope. + # + # Silent in the two cases where nothing is wrong: the secret is + # coming from somewhere this cannot see as an argument (the + # environment, or the config file itself), or MEDKIT_AUTH_DISABLED=1 + # means authentication is not starting at all. + print('[gateway.launch.py] auth is enabled and no jwt_secret was given. ' + 'Pass jwt_secret:= and ' + 'auth_clients:=::admin, set them in a config_file, ' + 'or pass auth_enabled:=false to run without authentication.') return [Node( package='ros2_medkit_gateway', executable='gateway_node', @@ -133,6 +360,12 @@ def _launch_setup(context, *_args, **_kwargs): declare_host_arg, declare_port_arg, declare_refresh_arg, + declare_auth_enabled_arg, + declare_jwt_secret_arg, + declare_clients_arg, + declare_tls_enabled_arg, + declare_cert_file_arg, + declare_key_file_arg, declare_cors_arg, OpaqueFunction(function=_launch_setup), ]) diff --git a/src/ros2_medkit_gateway/launch/gateway_https.launch.py b/src/ros2_medkit_gateway/launch/gateway_https.launch.py index b7a269100..3bc70649b 100644 --- a/src/ros2_medkit_gateway/launch/gateway_https.launch.py +++ b/src/ros2_medkit_gateway/launch/gateway_https.launch.py @@ -90,7 +90,13 @@ def generate_certificates(cert_dir: str) -> dict: return { 'cert_file': cert_file, 'key_file': key_file, - 'ca_file': ca_file if os.path.exists(ca_file) else '', + # Deliberately NOT passed as server.tls.ca_file. This CA signs the + # SERVER certificate so a client can verify the gateway; setting it + # as the gateway's ca_file turns on mutual TLS and rejects every + # client that has no certificate of its own, including the curl + # this launch file prints. Kept here only so the hint below can + # tell the user which CA to pass with --cacert. + 'ca_file_for_client': ca_file if os.path.exists(ca_file) else '', } os.makedirs(cert_dir, exist_ok=True) @@ -153,7 +159,9 @@ def generate_certificates(cert_dir: str) -> dict: return { 'cert_file': cert_file, 'key_file': key_file, - 'ca_file': ca_file, + # See the note above: this is the CA a CLIENT verifies the server with, + # not a client-certificate authority for the gateway to demand. + 'ca_file_for_client': ca_file, } @@ -196,7 +204,7 @@ def launch_setup(context): LogInfo(msg=[f' curl -k https://{server_host}:{server_port}/api/v1/health']), LogInfo(msg=['']), LogInfo(msg=['Test with CA verification:']), - LogInfo(msg=[f' curl --cacert {cert_paths["ca_file"]} ' + LogInfo(msg=[f' curl --cacert {cert_paths["ca_file_for_client"]} ' f'https://{server_host}:{server_port}/api/v1/health']), LogInfo(msg=['='*60]), diff --git a/src/ros2_medkit_gateway/package.xml b/src/ros2_medkit_gateway/package.xml index 47d9c3479..4194c80ba 100644 --- a/src/ros2_medkit_gateway/package.xml +++ b/src/ros2_medkit_gateway/package.xml @@ -35,6 +35,7 @@ ament_index_python + python3-yaml launch launch_ros ros2_medkit_fault_manager diff --git a/src/ros2_medkit_gateway/scripts/generate_dev_certs.sh b/src/ros2_medkit_gateway/scripts/generate_dev_certs.sh old mode 100644 new mode 100755 index 16fd0c216..e14b8551e --- a/src/ros2_medkit_gateway/scripts/generate_dev_certs.sh +++ b/src/ros2_medkit_gateway/scripts/generate_dev_certs.sh @@ -122,7 +122,11 @@ echo " tls:" echo " enabled: true" echo " cert_file: \"$OUTPUT_DIR/cert.pem\"" echo " key_file: \"$OUTPUT_DIR/key.pem\"" -echo " ca_file: \"$OUTPUT_DIR/ca.pem\"" +echo "" +echo " Do NOT add ca_file here. It is not the CA a client verifies the server" +echo " with - setting it makes the gateway REQUIRE a client certificate from" +echo " every caller, and the curl below would then be refused. Pass ca.pem to" +echo " the client with --cacert instead, as shown." echo "" echo "Test with curl:" echo " curl -k https://localhost:8080/api/v1/health" diff --git a/src/ros2_medkit_gateway/src/core/auth/auth_config.cpp b/src/ros2_medkit_gateway/src/core/auth/auth_config.cpp index 93de53553..ab312d498 100644 --- a/src/ros2_medkit_gateway/src/core/auth/auth_config.cpp +++ b/src/ros2_medkit_gateway/src/core/auth/auth_config.cpp @@ -15,6 +15,7 @@ #include "ros2_medkit_gateway/core/auth/auth_config.hpp" #include +#include #include namespace ros2_medkit_gateway { @@ -87,6 +88,11 @@ AuthConfigBuilder & AuthConfigBuilder::with_issuer(const std::string & issuer) { return *this; } +AuthConfigBuilder & AuthConfigBuilder::with_public_routes(const std::vector & public_routes) { + config_.public_routes = public_routes; + return *this; +} + AuthConfigBuilder & AuthConfigBuilder::add_client(const std::string & client_id, const std::string & client_secret, UserRole role) { ClientCredentials creds; @@ -150,7 +156,14 @@ std::string role_to_string(UserRole role) { UserRole string_to_role(const std::string & role_str) { std::string lower_role = role_str; - std::transform(lower_role.begin(), lower_role.end(), lower_role.begin(), ::tolower); + // ::tolower takes an int whose value must be representable as unsigned char + // or equal EOF. Where char is signed, any byte from 0x80 up arrives negative + // and the call is undefined by the standard; the cast makes it defined for + // every byte on every platform, which is what lets a role name carrying one + // be compared and refused like any other unknown role. + std::transform(lower_role.begin(), lower_role.end(), lower_role.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); if (lower_role == "viewer") { return UserRole::VIEWER; diff --git a/src/ros2_medkit_gateway/src/core/auth/auth_environment.cpp b/src/ros2_medkit_gateway/src/core/auth/auth_environment.cpp new file mode 100644 index 000000000..d6b9263b7 --- /dev/null +++ b/src/ros2_medkit_gateway/src/core/auth/auth_environment.cpp @@ -0,0 +1,245 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ros2_medkit_gateway/core/auth/auth_environment.hpp" + +#include +#include +#include +#include + +#include "ros2_medkit_gateway/core/auth/auth_config.hpp" + +namespace ros2_medkit_gateway { + +namespace { + +std::optional read_env(const char * name) { + const char * value = std::getenv(name); + if (value == nullptr) { + return std::nullopt; + } + return std::string(value); +} + +/// Drops leading and trailing spaces and tabs. +/// +/// Applied to a whole entry, never inside one. `a:b:admin, c:d:viewer` is how +/// a list gets written by hand, and the space after the comma belongs to the +/// separator; a space inside a secret belongs to the secret. +std::string trim_surrounding_space(const std::string & value) { + const auto is_space = [](unsigned char c) { + return c == ' ' || c == '\t'; + }; + std::size_t begin = 0; + while (begin < value.size() && is_space(static_cast(value[begin]))) { + ++begin; + } + std::size_t end = value.size(); + while (end > begin && is_space(static_cast(value[end - 1]))) { + --end; + } + return value.substr(begin, end - begin); +} + +/// Splits MEDKIT_CLIENTS and checks each entry, appending a notice per refusal. +/// +/// The check is on the SHAPE and on the role name, both of which are things a +/// caller can get wrong in a way that leaves the gateway closed to its own +/// operator. A refused entry is dropped and the entries around it still +/// register: one typo must not take the other credentials with it. +/// +/// `entries_seen` counts the entries that carried anything, so the caller can +/// tell "no credentials were asked for" from "every credential asked for was +/// refused" - two states that look the same in `accepted` and are not the same +/// misconfiguration. +/// +/// Positions count every comma-separated field as written, empties included, +/// so the number in a notice matches what an operator can point at in the +/// variable. +std::vector parse_client_entries(const std::string & value, std::vector & notices, + std::size_t & entries_seen) { + std::vector accepted; + std::set ids_taken; + + std::size_t position = 0; + std::size_t start = 0; + while (start <= value.size()) { + const std::size_t comma = value.find(',', start); + const std::string raw = value.substr(start, comma == std::string::npos ? std::string::npos : comma - start); + start = comma == std::string::npos ? value.size() + 1 : comma + 1; + ++position; + + const std::string entry = trim_surrounding_space(raw); + + // A trailing or doubled comma is a separator artefact, not an entry + // somebody wrote wrong. Saying nothing about it keeps the warnings that do + // appear worth reading. + if (entry.empty()) { + continue; + } + ++entries_seen; + + std::string notice = "MEDKIT_CLIENTS entry "; + notice += std::to_string(position); + + const std::size_t first_colon = entry.find(':'); + const std::size_t last_colon = entry.rfind(':'); + if (first_colon == std::string::npos || first_colon == last_colon) { + notice += " is not :: and was dropped"; + notices.push_back(std::move(notice)); + continue; + } + + const std::string id = entry.substr(0, first_colon); + const std::string secret = entry.substr(first_colon + 1, last_colon - first_colon - 1); + const std::string role = entry.substr(last_colon + 1); + if (id.empty() || secret.empty() || role.empty()) { + notice += " has an empty id, secret or role and was dropped"; + notices.push_back(std::move(notice)); + continue; + } + + try { + (void)string_to_role(role); + } catch (const std::invalid_argument &) { + // The role field is not quoted: an entry written `id:role:secret` puts + // the secret there, and every notice goes to a log. + notice += " for id \""; + notice += id; + notice += "\" names an unknown role (viewer, operator, configurator or admin) and was dropped"; + notices.push_back(std::move(notice)); + continue; + } + + // First entry for an id wins. Taking the last would make the credential in + // force depend on a position nobody thinks about, and the id is what a + // client authenticates as. + if (!ids_taken.insert(id).second) { + notice += " repeats client id \""; + notice += id; + notice += "\", which an earlier entry already claimed; the earlier one stands"; + notices.push_back(std::move(notice)); + continue; + } + + accepted.push_back(entry); + } + + return accepted; +} + +} // namespace + +AuthEnvironment resolve_auth_environment(const std::optional & auth_disabled, + const std::optional & jwt_secret, + const std::optional & clients) { + AuthEnvironment env; + + // Exactly "1". A variable set to "true", "yes" or "0" is not the documented + // opt-out, and reading any of them as one would turn authentication off for + // somebody who meant the opposite. + if (auth_disabled.has_value() && auth_disabled.value() == "1") { + env.applies = true; + env.enabled = false; + env.notices.push_back( + "MEDKIT_AUTH_DISABLED=1: authentication is OFF and every route is readable by anyone who can reach this " + "port. This overrides auth.enabled, MEDKIT_JWT_SECRET and every other source."); + return env; + } + + if (!jwt_secret.has_value() || jwt_secret.value().empty()) { + // A set-but-empty secret closes nothing, and says so. The variable is + // documented as non-empty, and an operator who exported it empty is + // holding half a configuration; silence would read as "closed". + if (jwt_secret.has_value()) { + env.notices.push_back( + "MEDKIT_JWT_SECRET is set but empty, so it closes nothing; auth.enabled and auth.jwt_secret from the " + "parameters stand. Set it to a secret of at least 32 characters to close this gateway."); + } + // MEDKIT_CLIENTS on its own does nothing, and says so. + // + // Credentials are read only where this gateway is the one closing, because + // replacing auth.clients under a secret the operator did not set would + // change who can log in to a gateway they did not ask to change. Setting + // the variable alone is a reasonable mistake - it looks like half a + // configuration and behaves like none - so it gets a line of its own. + if (clients.has_value() && !clients.value().empty()) { + env.notices.push_back( + "MEDKIT_CLIENTS is set and MEDKIT_JWT_SECRET is unset or empty, so the environment is closing nothing " + "and MEDKIT_CLIENTS is ignored. auth.clients from the parameters stands. Set MEDKIT_JWT_SECRET as well " + "to close this gateway with the credentials in MEDKIT_CLIENTS."); + } + return env; + } + + env.applies = true; + env.enabled = true; + env.require_auth_for_all = true; + env.jwt_secret = jwt_secret.value(); + env.notices.push_back( + "MEDKIT_JWT_SECRET is set: authentication is ON, auth.require_auth_for is \"all\" and the secret comes from " + "the environment. This overrides auth.enabled, auth.require_auth_for and auth.jwt_secret."); + + if (clients.has_value()) { + // Set means replace, the empty string included. A variable an operator set + // to nothing is a statement about the credentials this gateway offers, and + // falling back to the file's clients under a secret they were never issued + // against is the outcome that statement is meant to prevent. + env.clients_given = true; + env.clients = parse_client_entries(clients.value(), env.notices, env.client_entries_seen); + + std::string notice = "MEDKIT_CLIENTS supplied "; + notice += std::to_string(env.clients.size()); + notice += " client credential(s), overriding auth.clients."; + env.notices.push_back(std::move(notice)); + + if (env.client_entries_seen == 0) { + env.notices.push_back( + "MEDKIT_CLIENTS is empty, so auth.clients is replaced by nothing and no client can obtain a token. Every " + "route will refuse every caller. Give it :: entries, or unset it to keep auth.clients."); + } + } + + return env; +} + +std::vector redact_client_entries(const std::vector & entries, const std::string & sentinel) { + // The same reading GatewayNode registers by: two distinct colons, the id + // before the first, the role after the last, and a role the gateway knows. + std::vector shown; + shown.reserve(entries.size()); + for (const auto & entry : entries) { + const std::size_t first_colon = entry.find(':'); + const std::size_t last_colon = entry.rfind(':'); + if (first_colon == std::string::npos || first_colon == last_colon) { + continue; + } + UserRole role; + try { + role = string_to_role(entry.substr(last_colon + 1)); + } catch (const std::invalid_argument &) { + continue; + } + shown.push_back(entry.substr(0, first_colon) + ":" + sentinel + ":" + role_to_string(role)); + } + return shown; +} + +AuthEnvironment resolve_auth_environment_from_process() { + return resolve_auth_environment(read_env("MEDKIT_AUTH_DISABLED"), read_env("MEDKIT_JWT_SECRET"), + read_env("MEDKIT_CLIENTS")); +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp b/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp index 51ac0f626..a369af760 100644 --- a/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp +++ b/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp @@ -26,6 +26,30 @@ namespace ros2_medkit_gateway { +namespace { + +/// Compare two secrets without returning early on the first differing byte. +/// +/// The lengths are compared too, and a length mismatch is reported. That does +/// leak the length, which is acceptable: secrets here are operator-chosen and +/// their length is not the secret. What must not leak is WHICH bytes matched, +/// and the loop below always visits every byte of the expected value. +bool constant_time_equals(const std::string & expected, const std::string & presented) { + // Fold the length difference into the result and keep going, so both + // branches cost the same. + unsigned char diff = static_cast(expected.size() != presented.size()); + const std::size_t n = expected.size(); + for (std::size_t i = 0; i < n; ++i) { + // Index the presented value modulo its own size so a shorter input cannot + // read out of bounds; the length check above already forced a mismatch. + const unsigned char p = presented.empty() ? 0U : static_cast(presented[i % presented.size()]); + diff |= static_cast(static_cast(expected[i]) ^ p); + } + return diff == 0; +} + +} // namespace + // Helper to read file contents static std::string read_file_contents(const std::string & path) { std::ifstream file(path); @@ -37,27 +61,44 @@ static std::string read_file_contents(const std::string & path) { return buffer.str(); } -// Helper to validate RSA key file exists and is readable -static void validate_key_file(const std::string & path, const std::string & key_type) { +// Checks that an RS256 key file exists and carries something. +// +// The message names the PARAMETER and never the value. Under RS256 the private +// key path is read from `auth.jwt_secret`, and that parameter carries a secret +// under HS256; a message that echoed the value would put a secret into the +// startup log of any deployment that had the algorithm wrong. The parameter +// name is what the operator has to go and fix, so it is also the useful half. +static void validate_key_file(const std::string & path, const std::string & parameter_name) { if (path.empty()) { - throw std::runtime_error(key_type + " path is empty"); + throw std::runtime_error(parameter_name + " is empty and RS256 needs a key file path there"); } std::ifstream file(path); if (!file.is_open()) { - throw std::runtime_error("Failed to open " + key_type + " file: " + path); + throw std::runtime_error("the file named by " + parameter_name + " could not be opened"); } // Check file is not empty file.seekg(0, std::ios::end); if (file.tellg() == 0) { - throw std::runtime_error(key_type + " file is empty: " + path); + throw std::runtime_error("the file named by " + parameter_name + " is empty"); } } AuthManager::AuthManager(const AuthConfig & config) : config_(config) { - // Validate RS256 key files at startup (fail fast) + // RS256 keys are read here, once, and held for the life of the manager. + // + // The public key is the one that matters for cost: it verifies a signature, + // so a read inside decode_jwt charges every request that carries a token one + // file open, and a flood is made of exactly those requests. The validation + // just above has already required both files to exist and carry something, + // so the read here cannot fail in a way that check would have missed. + // + // The consequence to know about: a key rotated on disk takes effect at the + // next restart, which is also when the files are checked at all. if (config_.enabled && config_.jwt_algorithm == JwtAlgorithm::RS256) { - validate_key_file(config_.jwt_secret, "RS256 private key"); - validate_key_file(config_.jwt_public_key, "RS256 public key"); + validate_key_file(config_.jwt_secret, "auth.jwt_secret"); + validate_key_file(config_.jwt_public_key, "auth.jwt_public_key"); + rs256_private_key_ = read_file_contents(config_.jwt_secret); + rs256_public_key_ = read_file_contents(config_.jwt_public_key); } // Initialize clients from config @@ -65,8 +106,11 @@ AuthManager::AuthManager(const AuthConfig & config) : config_(config) { clients_[client.client_id] = client; } - // Create auth requirement policy from config - auth_policy_ = AuthRequirementPolicyFactory::create(config_.require_auth_for); + // Create auth requirement policy from config. `require_auth_for` decides the + // baseline; `public_routes` then lifts the credential requirement from the + // routes an operator named, and from nothing else. + auth_policy_ = + AuthRequirementPolicyFactory::create(config_.require_auth_for, parse_public_routes(config_.public_routes)); } tl::expected AuthManager::authenticate(const std::string & client_id, @@ -85,8 +129,15 @@ tl::expected AuthManager::authenticate(const s return tl::unexpected(AuthErrorResponse::invalid_client("Client is disabled")); } - // Verify secret - if (client.client_secret != client_secret) { + // Verify secret in constant time. A plain std::string comparison returns as + // soon as two bytes differ, so the time it takes to refuse leaks how many + // leading bytes were right, and a caller who can measure it can recover the + // secret one byte at a time. Every deployment that turns authentication on + // authenticates a client here, so this path carries all of them. + // + // Secrets are still stored in plaintext in the configuration; making this + // comparison constant-time does not change that and is not meant to. + if (!constant_time_equals(client.client_secret, client_secret)) { return tl::unexpected(AuthErrorResponse::invalid_client("Invalid client_secret")); } @@ -142,6 +193,20 @@ tl::expected AuthManager::authenticate(const s } tl::expected AuthManager::refresh_access_token(const std::string & refresh_token) { + // Sweep first, on every call, whatever this one goes on to answer. + // + // A client that authorises once and refreshes for a month touches this path + // and no other, so without a sweep here the store is cleared only by the + // node's timer - and in any process without that timer, which is every unit + // test of this class, never. First, and never on the way out, because the + // paths that fail (an expired refresh token, a record already gone) are + // exactly the ones a long-lived client produces, and a sweep placed past + // them does not run when it is needed. + { + std::lock_guard lock(refresh_tokens_mutex_); + cleanup_expired_locked(); + } + // Decode and validate refresh token auto decode_result = decode_jwt(refresh_token); if (!decode_result) { @@ -211,6 +276,8 @@ tl::expected AuthManager::refresh_access_token TokenValidationResult AuthManager::validate_token(const std::string & token, TokenType expected_type) const { TokenValidationResult result; + token_validations_.fetch_add(1, std::memory_order_relaxed); + auto decode_result = decode_jwt(token); if (!decode_result) { result.valid = false; @@ -248,7 +315,28 @@ TokenValidationResult AuthManager::validate_token(const std::string & token, Tok return result; } - // Check if associated refresh token is revoked (for access tokens) + // The refresh records are a DENYLIST: a record held and marked revoked + // refuses the access tokens minted from it, and a record this gateway does + // not hold carries no information either way. + // + // Every access token names the refresh record it came from, and the records + // live in this process's memory. Reading an absent record as "invalid" + // therefore refuses two whole classes of token that are sound: every token + // issued before a restart, and every token minted by another gateway sharing + // this JWT configuration, which is what `aggregation.forward_auth` puts on a + // forwarded request (docs/config/aggregation.rst). Such a token verifies + // under the shared secret, names a client this gateway knows, and sits + // inside its expiry; the missing record says only that this process was not + // the one that issued it. + // + // The trade, stated plainly: a token revoked on this gateway is honoured + // again across a restart, for at most `token_expiry_seconds` - the longest a + // live access token can outlast the moment its record was lost. While the + // process runs a revocation holds for the whole life of every token this + // gateway issued, because cleanup_expired_locked keeps a record until + // nothing minted from it can still be valid; for a token minted elsewhere + // that is true where the issuer's access expiry is at most this gateway's + // (see revoke_refresh_token). if (claims.refresh_token_id.has_value()) { auto record = get_refresh_token(claims.refresh_token_id.value()); if (record.has_value() && record->revoked) { @@ -260,6 +348,19 @@ TokenValidationResult AuthManager::validate_token(const std::string & token, Tok result.valid = true; result.claims = claims; + + // The role comes from THIS gateway's client table, and the role claim in the + // token is not consulted. + // + // The claim is signed, so it cannot be edited in flight, but it says what the + // ISSUING gateway granted. Under a shared JWT configuration a peer receives + // tokens another gateway minted, and letting the claim decide would export + // the issuer's grants: a client the peer lists as `viewer` would write on the + // peer because the aggregator lists it as `admin`. Each gateway grants what + // its own configuration says, so an operator can read one file and know what + // a client may do here. + result.claims->role = client->role; + return result; } @@ -331,44 +432,123 @@ bool AuthManager::requires_authentication(const std::string & method, const std: return auth_policy_->requires_authentication(method, path); } +bool AuthManager::is_public_route(const std::string & method, const std::string & path) const { + // No `config_.enabled` short-circuit here, unlike requires_authentication + // above. With authentication off nothing is anonymous and nothing is + // withheld, and the callers say so themselves; this accessor reports what + // the operator listed, which is a property of the configuration and says + // nothing about the request. + return auth_policy_ != nullptr && auth_policy_->is_public(method, path); +} + bool AuthManager::revoke_refresh_token(const std::string & refresh_token) { - // Decode token to get the jti - auto decode_result = decode_jwt(refresh_token); + // Signature and issuer verified, the expiry not. The last access token + // minted from a refresh token can outlive it by a whole access lifetime, so + // a revocation has to reach a refresh token past its own expiry; the record + // it writes is bounded by the sweep like any other. + auto decode_result = decode_jwt(refresh_token, Expiry::IGNORE); if (!decode_result) { return false; } const auto & claims = decode_result.value(); + // Refresh tokens only, checked the same way refresh_access_token checks it. + // + // The records are keyed by a REFRESH token's jti, and validate_token looks + // one up by the `refresh_token_id` an access token carries. Writing an + // access token's own jti into that map therefore stores a record nothing + // ever reads, and the token it was meant to withdraw goes on working while + // the call reports success. Refusing here keeps "revoked" meaning one thing. + // + // AuthHandlers::post_revoke (auth_handlers.cpp) answers 200 either way, per + // RFC 7009 §2.2: what a caller may learn from /auth/revoke is nothing about + // the token they sent. + if (claims.typ != TokenType::REFRESH) { + return false; + } + std::lock_guard lock(refresh_tokens_mutex_); auto it = refresh_tokens_.find(claims.jti); - if (it == refresh_tokens_.end()) { - return false; + if (it != refresh_tokens_.end()) { + it->second.revoked = true; + return true; } - it->second.revoked = true; + // A token this gateway did not issue, revoked here anyway. + // + // Under a shared JWT configuration - which is what aggregation.forward_auth + // describes - a peer is handed tokens another gateway minted and holds no + // record of any of them. Since validate_token reads the records as a + // denylist, "no record" would make revocation a no-op on exactly the gateway + // an operator is trying to lock down. Writing the record is what gives them + // a way to refuse a token here. + // + // The record carries the token's own refresh expiry, clamped to THIS + // gateway's refresh lifetime, and the sweep holds it for this gateway's + // access lifetime past that - the only lifetimes this process knows. The + // clamp is what bounds the store: the issuer's refresh expiry is carried in + // the token and this gateway does not control it, so an issuer with a + // refresh lifetime of years would otherwise leave records here for years. + // Under the rule in docs/config/aggregation.rst - gateways sharing a signing + // configuration share both expiries - the clamp changes nothing and the + // record covers every access token the issuer can mint from it; a peer with + // the shorter access expiry drops the record while a late-minted token of + // the issuer's is still live, and one with the shorter refresh expiry drops + // it while the issuer can still refresh. + const auto now_ts = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + const auto own_refresh_horizon = now_ts + config_.refresh_token_expiry_seconds; + RefreshTokenRecord foreign; + foreign.token_id = claims.jti; + foreign.client_id = claims.sub; + foreign.role = claims.role; + foreign.issued_at = claims.iat; + foreign.expires_at = claims.exp < own_refresh_horizon ? claims.exp : own_refresh_horizon; + foreign.revoked = true; + refresh_tokens_[claims.jti] = foreign; return true; } -size_t AuthManager::cleanup_expired_tokens() { +size_t AuthManager::cleanup_expired_locked() { auto now = std::chrono::system_clock::now(); auto now_ts = std::chrono::duration_cast(now.time_since_epoch()).count(); - std::lock_guard lock(refresh_tokens_mutex_); - size_t count = 0; + // A record outlives its own expiry by one access-token lifetime. + // + // A revoked record is what refuses the access tokens minted from it, so + // dropping it the instant the refresh token expires ends the revocation + // while tokens it withdrew are still live. The last access token can be + // minted a second before the refresh token expires and is then promised a + // full token_expiry_seconds; sweeping on expires_at alone would start + // honouring it again about a minute later, with most of its life left. + // Holding every record - revoked ones included, they take the same branch - + // until nothing minted from it can still be valid costs one extra lifetime + // of memory per client and removes the whole race. + const int64_t grace = static_cast(config_.token_expiry_seconds); + size_t count = 0; for (auto it = refresh_tokens_.begin(); it != refresh_tokens_.end();) { - if (it->second.expires_at < now_ts) { + if (it->second.expires_at + grace < now_ts) { it = refresh_tokens_.erase(it); ++count; } else { ++it; } } - return count; } +size_t AuthManager::refresh_token_count() const { + std::lock_guard lock(refresh_tokens_mutex_); + return refresh_tokens_.size(); +} + +size_t AuthManager::cleanup_expired_tokens() { + std::lock_guard lock(refresh_tokens_mutex_); + return cleanup_expired_locked(); +} + bool AuthManager::register_client(const std::string & client_id, const std::string & client_secret, UserRole role) { std::lock_guard lock(clients_mutex_); @@ -440,17 +620,15 @@ std::string AuthManager::generate_jwt(const JwtClaims & claims) const { case JwtAlgorithm::HS256: return builder.sign(jwt::algorithm::hs256{config_.jwt_secret}); - case JwtAlgorithm::RS256: { - std::string private_key = read_file_contents(config_.jwt_secret); - return builder.sign(jwt::algorithm::rs256("", private_key, "", "")); - } + case JwtAlgorithm::RS256: + return builder.sign(jwt::algorithm::rs256("", rs256_private_key_, "", "")); default: throw std::runtime_error("Unsupported JWT algorithm"); } } -tl::expected AuthManager::decode_jwt(const std::string & token) const { +tl::expected AuthManager::decode_jwt(const std::string & token, Expiry expiry) const { try { // Decode token first auto decoded = jwt::decode(token); @@ -461,14 +639,20 @@ tl::expected AuthManager::decode_jwt(const std::string & case JwtAlgorithm::HS256: { auto verifier = jwt::verify().allow_algorithm(jwt::algorithm::hs256{config_.jwt_secret}).with_issuer(config_.issuer); + if (expiry == Expiry::IGNORE) { + verifier.with_claim("exp", [](const auto &, std::error_code &) {}); + } verifier.verify(decoded); break; } case JwtAlgorithm::RS256: { - std::string public_key = read_file_contents(config_.jwt_public_key); - auto verifier = - jwt::verify().allow_algorithm(jwt::algorithm::rs256(public_key, "", "", "")).with_issuer(config_.issuer); + auto verifier = jwt::verify() + .allow_algorithm(jwt::algorithm::rs256(rs256_public_key_, "", "", "")) + .with_issuer(config_.issuer); + if (expiry == Expiry::IGNORE) { + verifier.with_claim("exp", [](const auto &, std::error_code &) {}); + } verifier.verify(decoded); break; } @@ -609,6 +793,21 @@ bool AuthManager::matches_path(const std::string & pattern, const std::string & void AuthManager::store_refresh_token(const RefreshTokenRecord & record) { std::lock_guard lock(refresh_tokens_mutex_); + + // Sweep before inserting, which bounds the map on the authorisation path. + // + // One of three sweeps, and they cover different traffic. This one runs per + // authorisation; refresh_access_token() runs one per refresh, which is the + // only path a client that logs in once and refreshes forever ever touches; + // and the node drives a timer for a process doing neither. Keeping it here + // as well makes the bound a property of the data structure, observable in a + // test with no timer running and no wall clock to wait on. + // + // The cost is a scan per authorisation. The map only ever holds unexpired + // records, so it is sized by how many tokens are live at once, not by how + // many have ever been issued. + cleanup_expired_locked(); + refresh_tokens_[record.token_id] = record; } diff --git a/src/ros2_medkit_gateway/src/core/auth/auth_middleware.cpp b/src/ros2_medkit_gateway/src/core/auth/auth_middleware.cpp index d83f2b021..e149a2f5f 100644 --- a/src/ros2_medkit_gateway/src/core/auth/auth_middleware.cpp +++ b/src/ros2_medkit_gateway/src/core/auth/auth_middleware.cpp @@ -24,6 +24,13 @@ AuthMiddleware::AuthMiddleware(const AuthConfig & config, AuthManager * auth_man : config_(config), auth_manager_(auth_manager) { } +bool AuthMiddleware::requires_authentication(const AuthRequest & request) const { + if (!config_.enabled || auth_manager_ == nullptr) { + return false; + } + return auth_manager_->requires_authentication(request.method, request.path); +} + AuthMiddlewareResult AuthMiddleware::process(const AuthRequest & request) const { // If auth is not enabled, allow all requests if (!config_.enabled || auth_manager_ == nullptr) { diff --git a/src/ros2_medkit_gateway/src/core/auth/auth_requirement_policy.cpp b/src/ros2_medkit_gateway/src/core/auth/auth_requirement_policy.cpp index 16393f3b8..b7185f48f 100644 --- a/src/ros2_medkit_gateway/src/core/auth/auth_requirement_policy.cpp +++ b/src/ros2_medkit_gateway/src/core/auth/auth_requirement_policy.cpp @@ -15,7 +15,12 @@ #include "ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp" #include +#include +#include #include +#include +#include +#include namespace ros2_medkit_gateway { @@ -168,8 +173,121 @@ std::unique_ptr AuthRequirementPolicyFactory::create(con return std::make_unique(); } - // Use the require_auth_for setting from config - return create(config.require_auth_for); + return create(config.require_auth_for, parse_public_routes(config.public_routes)); +} + +// Whitespace as std::isspace reads it, so the rule that skips a blank entry and +// the rule that trims a real one agree on what blank means. +static std::string trim_whitespace(const std::string & entry) { + auto is_space = [](unsigned char c) { + return std::isspace(c) != 0; + }; + auto begin = std::find_if_not(entry.begin(), entry.end(), is_space); + auto end = std::find_if_not(entry.rbegin(), entry.rend(), is_space).base(); + return (begin < end) ? std::string(begin, end) : std::string(); +} + +bool is_blank_public_route_entry(const std::string & entry) { + return trim_whitespace(entry).empty(); +} + +std::vector parse_public_routes(const std::vector & entries) { + std::vector routes; + routes.reserve(entries.size()); + for (const auto & entry : entries) { + if (is_blank_public_route_entry(entry)) { + continue; + } + auto parsed = parse_public_route(entry); + if (parsed.has_value()) { + routes.push_back(*parsed); + } + } + return routes; +} + +std::unique_ptr +AuthRequirementPolicyFactory::create(AuthRequirement requirement, const std::vector & public_routes) { + auto policy = create(requirement); + if (public_routes.empty()) { + return policy; + } + return std::make_unique(std::move(policy), public_routes); +} + +tl::expected parse_public_route(const std::string & entry) { + const std::string trimmed = trim_whitespace(entry); + + if (trimmed.empty()) { + return tl::unexpected("entry is empty"); + } + + const size_t space = trimmed.find(' '); + if (space == std::string::npos) { + return tl::unexpected("expected \"METHOD /path\", e.g. \"GET /api/v1/health\""); + } + + PublicRoute route; + route.method = trimmed.substr(0, space); + route.path = trimmed.substr(space + 1); + + // The path is compared against what cpp-httplib hands the middleware, which + // is a single token. A second space means two paths or a stray argument, and + // either way the entry does not describe one route. + if (route.path.find(' ') != std::string::npos) { + return tl::unexpected("path contains a space: \"" + route.path + "\""); + } + + std::transform(route.method.begin(), route.method.end(), route.method.begin(), [](unsigned char c) { + return static_cast(std::toupper(c)); + }); + + static const std::vector kMethods = {"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}; + if (std::find(kMethods.begin(), kMethods.end(), route.method) == kMethods.end()) { + return tl::unexpected("unknown HTTP method \"" + route.method + "\""); + } + + if (route.path.empty() || route.path.front() != '/') { + return tl::unexpected("path must start with \"/\": \"" + route.path + "\""); + } + + // Wildcards are refused, and never quietly ignored. Accepting the character and + // then matching it literally would read as "this opens the subtree" and + // silently open nothing; refusing says so while the operator is watching. + if (route.path.find('*') != std::string::npos) { + return tl::unexpected("wildcards are not supported; name each route exactly: \"" + route.path + "\""); + } + + return route; +} + +PublicRouteExemptionPolicy::PublicRouteExemptionPolicy(std::unique_ptr inner, + std::vector public_routes) + : inner_(std::move(inner)), public_routes_(std::move(public_routes)) { +} + +bool PublicRouteExemptionPolicy::requires_authentication(const std::string & method, const std::string & path) const { + if (is_public(method, path)) { + return false; + } + return inner_->requires_authentication(method, path); +} + +bool PublicRouteExemptionPolicy::is_public(const std::string & method, const std::string & path) const { + for (const auto & route : public_routes_) { + if (route.method == method && route.path == path) { + return true; + } + } + return false; +} + +std::string PublicRouteExemptionPolicy::description() const { + std::string desc = inner_->description() + "; public_routes:"; + for (const auto & route : public_routes_) { + desc += " " + route.method + " " + route.path; + } + return desc; } } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/core/config.cpp b/src/ros2_medkit_gateway/src/core/config.cpp index b140bd110..37cb72fc9 100644 --- a/src/ros2_medkit_gateway/src/core/config.cpp +++ b/src/ros2_medkit_gateway/src/core/config.cpp @@ -58,11 +58,6 @@ std::string TlsConfig::validate() const { return "TLS: ca_file does not exist or is not readable: " + ca_file; } - // TODO(future): Add mutual TLS validation when implemented - // if (mutual_tls && ca_file.empty()) { - // return "TLS: ca_file is required when mutual_tls is enabled"; - // } - // Validate minimum TLS version if (min_version != "1.2" && min_version != "1.3") { return "TLS: min_version must be '1.2' or '1.3', got: " + min_version; diff --git a/src/ros2_medkit_gateway/src/core/http/rate_limiter.cpp b/src/ros2_medkit_gateway/src/core/http/rate_limiter.cpp index 5f2e4c569..87fc504c7 100644 --- a/src/ros2_medkit_gateway/src/core/http/rate_limiter.cpp +++ b/src/ros2_medkit_gateway/src/core/http/rate_limiter.cpp @@ -283,6 +283,17 @@ void RateLimiter::apply_rejection(const RateLimitResult & result, httplib::Respo res.set_content(error.dump(), "application/json"); } +void RateLimiter::apply_bare_rejection(httplib::Response & res) { + res.status = 429; + + nlohmann::json error; + error["error_code"] = ERR_RATE_LIMIT_EXCEEDED; + error["message"] = "Too many requests."; + error["parameters"] = nlohmann::json::object(); + + res.set_content(error.dump(), "application/json"); +} + void RateLimiter::cleanup_stale_clients() { std::lock_guard lock(clients_mutex_); auto now = std::chrono::steady_clock::now(); diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index a8f32986e..a52478d8b 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,8 @@ #include #include "ros2_medkit_gateway/core/aggregation/network_utils.hpp" +#include "ros2_medkit_gateway/core/auth/auth_environment.hpp" +#include "ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp" #include "ros2_medkit_gateway/core/data/topic_data_provider.hpp" #include "ros2_medkit_gateway/core/discovery/refresh_debounce.hpp" #include "ros2_medkit_gateway/core/entity_validation.hpp" @@ -46,6 +49,39 @@ using namespace std::chrono_literals; namespace ros2_medkit_gateway { +namespace { + +// A parameter override by name, read without declaring the parameter. +// +// The map is the node parameters interface's, which is where a params file, a +// `-p` argument and a programmatic override all end up merged; +// NodeOptions::parameter_overrides() carries the programmatic ones only. +using ParameterOverrides = std::map; + +std::string override_string(const ParameterOverrides & overrides, const std::string & name) { + const auto found = overrides.find(name); + if (found == overrides.end()) { + return std::string(); + } + if (found->second.get_type() != rclcpp::ParameterType::PARAMETER_STRING) { + throw std::invalid_argument(name + " must be a string"); + } + return found->second.get(); +} + +std::vector override_string_array(const ParameterOverrides & overrides, const std::string & name) { + const auto found = overrides.find(name); + if (found == overrides.end()) { + return {}; + } + if (found->second.get_type() != rclcpp::ParameterType::PARAMETER_STRING_ARRAY) { + throw std::invalid_argument(name + " must be a list of strings"); + } + return found->second.get>(); +} + +} // namespace + GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medkit_gateway", options) { RCLCPP_INFO(get_logger(), "Initializing ROS 2 Medkit Gateway..."); @@ -165,14 +201,51 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki // Authentication parameters (REQ_INTEROP_086, REQ_INTEROP_087) declare_parameter("auth.enabled", false); - declare_parameter("auth.jwt_secret", ""); declare_parameter("auth.jwt_public_key", ""); declare_parameter("auth.jwt_algorithm", "HS256"); declare_parameter("auth.token_expiry_seconds", 3600); declare_parameter("auth.refresh_token_expiry_seconds", 86400); declare_parameter("auth.require_auth_for", "write"); declare_parameter("auth.issuer", "ros2_medkit_gateway"); - declare_parameter("auth.clients", std::vector{}); + declare_parameter("auth.public_routes", std::vector{}); + + // The parameters that carry secrets are never declared with their value. + // + // A declared value is served by the parameter services to any participant + // on the domain and published on /parameter_events, so the signing secret, + // the client list and the bearer this gateway presents to its peers are + // taken from the node's overrides here, handed to their consumers below, + // and declared with a sentinel naming the origin and `ignore_override` - in + // every auth state, because an open gateway still carries the secrets of the + // file that asked for auth, and a peer sharing that file signs with them. + // Under RS256 `auth.jwt_secret` is a key path and is served no more than a + // secret would be. The on-set guard installed by + // apply_effective_auth_parameters keeps the sentinels from being written. + // + // The environment is resolved here because the sentinels name it; its + // notices are logged with the rest of the auth configuration below. + const auto auth_env = resolve_auth_environment_from_process(); + const auto & secret_overrides = get_node_parameters_interface()->get_parameter_overrides(); + const std::string configured_jwt_secret = override_string(secret_overrides, "auth.jwt_secret"); + const std::vector configured_clients = override_string_array(secret_overrides, "auth.clients"); + const std::string configured_peer_auth_header = override_string(secret_overrides, "aggregation.peer_auth_header"); + { + const rcl_interfaces::msg::ParameterDescriptor plain; + std::string secret_shown; + if (!auth_env.jwt_secret.empty()) { + secret_shown = ""; + } else if (!configured_jwt_secret.empty()) { + secret_shown = ""; + } + declare_parameter("auth.jwt_secret", secret_shown, plain, /*ignore_override=*/true); + const std::vector clients_shown = + auth_env.clients_given ? redact_client_entries(auth_env.clients, "") + : redact_client_entries(configured_clients, ""); + declare_parameter("auth.clients", clients_shown, plain, /*ignore_override=*/true); + declare_parameter("aggregation.peer_auth_header", + configured_peer_auth_header.empty() ? std::string() : std::string(""), plain, + /*ignore_override=*/true); + } // OpenAPI documentation endpoints declare_parameter("docs.enabled", true); @@ -252,7 +325,6 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki declare_parameter("aggregation.mdns_name", std::string("")); // defaults to hostname // Security: forward Authorization header to peers (default: false to prevent token leakage) declare_parameter("aggregation.forward_auth", false); - declare_parameter("aggregation.peer_auth_header", ""); // Security: require TLS for all peer URLs (default: false) declare_parameter("aggregation.require_tls", false); // URL scheme for mDNS-discovered peer URLs (default: "http") @@ -435,7 +507,6 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki .with_key_file(get_parameter("server.tls.key_file").as_string()) .with_ca_file(get_parameter("server.tls.ca_file").as_string()) .with_min_version(get_parameter("server.tls.min_version").as_string()) - // TODO(future): Add .with_mutual_tls() when implemented .build(); // Note: HttpServerManager will log TLS configuration details } catch (const std::exception & e) { @@ -455,23 +526,111 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki } // Build Authentication configuration (REQ_INTEROP_086, REQ_INTEROP_087) - bool auth_enabled = get_parameter("auth.enabled").as_bool(); + // + // The environment gets the first word, and this is the only place that rule + // lives. Everything upstream - the container entrypoint, gateway.launch.py - + // passes the three variables through and holds no copy of it, because the + // paths that reach this node are not all the same: `docker run ros2 + // launch ... bringup.launch.py` and `ros2 run ros2_medkit_gateway + // gateway_node` both exec past any `-p` an entrypoint would have added, and + // a rule applied in a launch file reaches only the launches that include it. + // Reading the variables where the parameters are read covers every path by + // construction. `auth_env` was resolved with the parameter declarations + // above, because the sentinels the secret parameters are declared with name + // where the value came from. + for (const auto & notice : auth_env.notices) { + RCLCPP_WARN(get_logger(), "%s", notice.c_str()); + } + + // Routes the operator has taken outside authentication, validated before the + // posture is decided. + // + // A malformed entry is a configuration error whenever the key is set, and + // `auth.enabled` does not change that: a list validated only while + // authentication is on means a typo sits unnoticed until somebody closes the + // gateway, which is the worst moment to discover it. A typo must stop the + // gateway while somebody is watching - silently dropping an entry would leave + // a route protected that the operator believes is reachable, and silently + // widening it would be worse. + // + // A blank entry is skipped and never refused: `[""]` is how a ROS 2 YAML file + // writes an empty string sequence. + const auto public_routes = get_parameter("auth.public_routes").as_string_array(); + for (const auto & entry : public_routes) { + if (is_blank_public_route_entry(entry)) { + continue; + } + auto parsed = parse_public_route(entry); + if (!parsed) { + RCLCPP_FATAL(get_logger(), "auth.public_routes entry \"%s\" is invalid: %s", entry.c_str(), + parsed.error().c_str()); + throw std::runtime_error("auth.public_routes entry \"" + entry + "\" is invalid: " + parsed.error()); + } + } + + bool auth_enabled = auth_env.applies ? auth_env.enabled : get_parameter("auth.enabled").as_bool(); + // The requirement level actually in force, kept in scope for the write-back + // below. "none" while authentication is off, which is what the gateway then + // enforces whatever any parameter says. + std::string effective_require_auth_for = "none"; if (auth_enabled) { try { + // "all" whenever the environment is what closed this gateway: "write" + // leaves every read open, and the entity tree, the fault history and the + // operation list are the disclosure. + const std::string require_auth_for = + auth_env.require_auth_for_all ? std::string("all") : get_parameter("auth.require_auth_for").as_string(); + effective_require_auth_for = require_auth_for; + const bool secret_from_environment = !auth_env.jwt_secret.empty(); + const std::string jwt_secret = secret_from_environment ? auth_env.jwt_secret : configured_jwt_secret; + const std::string jwt_algorithm = get_parameter("auth.jwt_algorithm").as_string(); + + // MEDKIT_JWT_SECRET carries a secret, and under RS256 `auth.jwt_secret` + // is a path to a private key file. The combination has no reading that + // works: taken as a path it names no file, taken as a key it is not one. + // Refusing here says so while the operator is watching, in place of an + // open() failure quoting a value nobody wants in a log. + if (secret_from_environment && string_to_algorithm(jwt_algorithm) == JwtAlgorithm::RS256) { + throw std::invalid_argument( + "MEDKIT_JWT_SECRET is set and auth.jwt_algorithm is RS256. The environment variable carries a shared " + "secret, which is HS256; RS256 reads auth.jwt_secret as a path to a private key file. Set " + "auth.jwt_algorithm to HS256, or configure the RS256 key paths and unset MEDKIT_JWT_SECRET."); + } + + // A list the operator wrote and the gateway could not use even once. + // Starting anyway produces a gateway closed to everybody including its + // operator, which is the same failure as a missing secret and is refused + // the same way. An EMPTY MEDKIT_CLIENTS is a different statement and + // reaches this point with entries_seen at zero; it warns and starts. + if (auth_env.clients_given && auth_env.client_entries_seen > 0 && auth_env.clients.empty()) { + throw std::invalid_argument( + "every MEDKIT_CLIENTS entry was refused, so no client can obtain a token and every route would refuse " + "every caller. The WARN lines above name each entry by position."); + } + AuthConfigBuilder auth_builder; auth_builder.with_enabled(true) - .with_jwt_secret(get_parameter("auth.jwt_secret").as_string()) + .with_jwt_secret(jwt_secret) .with_jwt_public_key(get_parameter("auth.jwt_public_key").as_string()) - .with_algorithm(string_to_algorithm(get_parameter("auth.jwt_algorithm").as_string())) + .with_algorithm(string_to_algorithm(jwt_algorithm)) .with_token_expiry(static_cast(get_parameter("auth.token_expiry_seconds").as_int())) .with_refresh_token_expiry(static_cast(get_parameter("auth.refresh_token_expiry_seconds").as_int())) - .with_require_auth_for(string_to_auth_requirement(get_parameter("auth.require_auth_for").as_string())) + .with_require_auth_for(string_to_auth_requirement(require_auth_for)) .with_issuer(get_parameter("auth.issuer").as_string()); // Parse clients from configuration // Format: "client_id:client_secret:role" (e.g., "admin:secret123:admin") - auto clients = get_parameter("auth.clients").as_string_array(); + const auto & clients = auth_env.clients_given ? auth_env.clients : configured_clients; + if (clients.empty()) { + RCLCPP_WARN(get_logger(), + "Authentication is on and no client is configured, so nothing can obtain a token. Set " + "auth.clients, or MEDKIT_CLIENTS=::admin in the environment."); + } + // Positions are 1-based and count every entry as given, so a warning + // names what the operator can point at in the list. + std::size_t client_position = 0; for (const auto & client_str : clients) { + ++client_position; if (client_str.empty()) { continue; } @@ -486,29 +645,71 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki UserRole role = string_to_role(role_str); auth_builder.add_client(client_id, client_secret, role); RCLCPP_INFO(get_logger(), "Registered client '%s' with role '%s'", client_id.c_str(), role_str.c_str()); - } catch (const std::exception & e) { - RCLCPP_WARN(get_logger(), "Invalid role '%s' for client '%s': %s", role_str.c_str(), client_id.c_str(), - e.what()); + } catch (const std::exception &) { + // The role field is not quoted: an entry written `id:role:secret` + // puts the secret there. + RCLCPP_WARN(get_logger(), + "auth.clients entry %zu for id '%s' names an unknown role (viewer, operator, configurator " + "or admin) and was dropped.", + client_position, client_id.c_str()); } } else { - RCLCPP_WARN(get_logger(), "Invalid client format: '%s'. Expected 'client_id:client_secret:role'", - client_str.c_str()); + // The entry is NOT echoed. Whatever else a malformed entry is, it is + // a line somebody put a secret in, and a startup log is shipped, + // aggregated and kept. The id is quoted only where a colon makes it + // readable; with none there is nothing to quote that is not secret. + if (first_colon == std::string::npos) { + RCLCPP_WARN(get_logger(), "auth.clients entry %zu has no ':' and was dropped. Expected 'id:secret:role'.", + client_position); + } else { + RCLCPP_WARN(get_logger(), "auth.clients entry %zu for id '%s' is not 'id:secret:role' and was dropped.", + client_position, client_str.substr(0, first_colon).c_str()); + } } } + auth_builder.with_public_routes(public_routes); + auth_config_ = auth_builder.build(); RCLCPP_INFO(get_logger(), "Authentication enabled - algorithm: %s, require_auth_for: %s", - algorithm_to_string(auth_config_.jwt_algorithm).c_str(), - get_parameter("auth.require_auth_for").as_string().c_str()); + algorithm_to_string(auth_config_.jwt_algorithm).c_str(), require_auth_for.c_str()); + for (const auto & entry : public_routes) { + if (is_blank_public_route_entry(entry)) { + continue; + } + // One line per route, at WARN: every entry here is a hole somebody + // opened on purpose, and an operator reading the startup log should see + // the whole public surface without going to look for the config file. + RCLCPP_WARN(get_logger(), "auth.public_routes: %s is answered WITHOUT a credential", entry.c_str()); + } } catch (const std::exception & e) { - // Fail closed: authentication was explicitly requested but could not be - // built (e.g. empty jwt_secret). Refuse to start rather than silently + // Fail closed: authentication was requested but could not be configured + // (e.g. an empty or too-short jwt_secret). Refuse to start, because // serving an unauthenticated API under a configuration that asked for - // auth. + // auth is the outcome the setting exists to prevent. + // + // The source line names where the refused value came from, and the + // environment supplies exactly three: the secret, the client list and + // the posture. Everything else - the expiries, the algorithm, the + // issuer, the public routes - is read from the parameters whether or not + // the environment closed this gateway, so naming the environment for + // those sends an operator to variables that had nothing to do with it, + // and naming the parameters for a secret sends them to a file the + // gateway did not read. + const bool env_supplied_the_secret = !auth_env.jwt_secret.empty(); + const bool env_supplied_the_clients = auth_env.clients_given; + std::string source = "The settings came from the parameters: auth.*."; + if (env_supplied_the_secret || env_supplied_the_clients) { + source = "The environment supplied "; + source += env_supplied_the_secret ? "auth.jwt_secret (MEDKIT_JWT_SECRET)" : ""; + source += (env_supplied_the_secret && env_supplied_the_clients) ? " and " : ""; + source += env_supplied_the_clients ? "auth.clients (MEDKIT_CLIENTS)" : ""; + source += "; every other auth.* value came from the parameters."; + } RCLCPP_FATAL(get_logger(), - "Authentication is enabled (auth.enabled=true) but the auth configuration is invalid: %s. " - "Refusing to start unauthenticated - fix the auth configuration or disable auth.", - e.what()); + "Authentication is enabled but the auth configuration is invalid: %s. %s " + "Refusing to start unauthenticated.", + e.what(), source.c_str()); throw std::runtime_error(std::string("Invalid authentication configuration: ") + e.what()); } } else { @@ -516,6 +717,8 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki auth_config_ = AuthConfig{}; } + apply_effective_auth_parameters(auth_enabled, effective_require_auth_for); + // Build Rate Limiting configuration bool rate_limit_enabled = get_parameter("rate_limiting.enabled").as_bool(); if (rate_limit_enabled) { @@ -1190,7 +1393,7 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki agg_config.discover = get_parameter("aggregation.discover").as_bool(); agg_config.mdns_service = get_parameter("aggregation.mdns_service").as_string(); agg_config.forward_auth = get_parameter("aggregation.forward_auth").as_bool(); - agg_config.peer_auth_header = get_parameter("aggregation.peer_auth_header").as_string(); + agg_config.peer_auth_header = configured_peer_auth_header; agg_config.require_tls = get_parameter("aggregation.require_tls").as_bool(); agg_config.peer_scheme = get_parameter("aggregation.peer_scheme").as_string(); if (agg_config.peer_scheme != "http" && agg_config.peer_scheme != "https") { @@ -1566,6 +1769,26 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki } }); + // Periodic sweep of expired refresh records (every 5 minutes). + // + // The store grows by one record per authorisation and shrinks only when a + // sweep runs. The authorisation and refresh paths each run one, so a busy + // gateway is swept by its own traffic; a gateway whose clients have gone + // quiet is swept by nothing else, and their records would sit in the map + // until the process ends. The timer bounds the store whatever the traffic + // mix, and it is cheap: the sweep walks the map under one lock and drops + // records nothing minted from can still be valid. + refresh_token_cleanup_timer_ = create_wall_timer(std::chrono::minutes(5), [this]() { + auto * auth_manager = rest_server_ ? rest_server_->auth_manager() : nullptr; + if (auth_manager == nullptr) { + return; + } + size_t removed = auth_manager->cleanup_expired_tokens(); + if (removed > 0) { + RCLCPP_DEBUG(get_logger(), "Cleaned up %zu expired refresh token records", removed); + } + }); + // Start REST server with configured host, port, CORS, auth, and TLS rest_server_ = std::make_unique(this, server_host_, server_port_, cors_config_, auth_config_, rate_limit_config_, tls_config_); @@ -1635,6 +1858,54 @@ std::string GatewayNode::connectable_host(const std::string & bind_host) { return bind_host; } +void GatewayNode::apply_effective_auth_parameters(bool auth_enabled, const std::string & require_auth_for) { + // Introspection has to agree with enforcement. + // + // The environment decides the posture, and the parameters hold whatever the + // params file said until this writes the decision back. Without it `ros2 + // param get auth.enabled` reports the file on a gateway refusing every + // request, so an operator reading the parameters to find out how a container + // is running is told the opposite of the truth. + // + // No secret is written back here or anywhere. auth.jwt_secret, auth.clients + // and aggregation.peer_auth_header are declared with a sentinel and + // `ignore_override` at the top of the constructor, in every auth state, and + // the guard below keeps them so. + std::vector effective; + effective.emplace_back("auth.enabled", auth_enabled); + effective.emplace_back("auth.require_auth_for", require_auth_for); + + for (const auto & parameter : effective) { + // One at a time, so a rejected parameter names itself. set_parameters() + // reports per-parameter results that are easy to drop on the floor. + auto result = set_parameter(parameter); + if (!result.successful) { + RCLCPP_WARN(get_logger(), "Could not write back %s: %s", parameter.get_name().c_str(), result.reason.c_str()); + } + } + + // Registered AFTER the write-back above, which is the only writer this node + // has for these. Everything after this point is somebody outside asking for a + // runtime change the gateway cannot make: the auth configuration and the + // peer bearer are read once at construction and handed to AuthManager, + // RESTServer, the route table and AggregationManager, so accepting a set + // would report a change nobody applied - and for the three secrets it would + // also put a value where only a sentinel is served. + auth_parameter_guard_ = add_on_set_parameters_callback([](const std::vector & parameters) { + rcl_interfaces::msg::SetParametersResult result; + result.successful = true; + for (const auto & parameter : parameters) { + const std::string & name = parameter.get_name(); + if (name.rfind("auth.", 0) == 0 || name == "aggregation.peer_auth_header") { + result.successful = false; + result.reason = name + " is read at start; restart the gateway with the new configuration"; + return result; + } + } + return result; + }); +} + void GatewayNode::log_startup_summary() { size_t topic_count = 0; size_t peer_node_count = 0; diff --git a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index fcc0948d0..44c3547fd 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -17,6 +17,7 @@ #include #include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" +#include "ros2_medkit_gateway/core/auth/auth_middleware.hpp" #include "ros2_medkit_gateway/core/auth/auth_models.hpp" #include "ros2_medkit_gateway/core/data/topic_data_provider.hpp" #include "ros2_medkit_gateway/core/discovery/discovery_enums.hpp" @@ -50,13 +51,69 @@ ErrorInfo make_internal_error(const char * where, const std::exception & e) { } // namespace +namespace { + +/// True when this answer must be cut down to liveness only. +/// +/// Two conditions, and both are needed. The route has to be one an operator +/// named in `auth.public_routes` - the documented case, where something that +/// cannot hold a credential was given a door of its own - and the caller has to +/// have presented no token this gateway accepts. On such a route the full body +/// is more than the probe asked for: the linking warnings name entities and ROS +/// node FQNs, and the entity cache reports how many apps, areas and components +/// this gateway sees. +/// +/// The operator's list is the test, and `auth.enabled` on its own is not: +/// under `require_auth_for: "write"` every GET is open by the requirement +/// level, with nobody deciding anything route by route, and the full body +/// belongs to an anonymous caller there. docs/config/server.rst promises the +/// cut on a named route and nowhere else. +/// +/// One case is wider than the list: with authentication on and no manager to +/// ask, this cuts the body. That path means the gateway cannot answer the +/// question at all, and liveness is the safe thing to say when the check +/// itself is unavailable. +bool serves_reduced_body(const HandlerContext & ctx, const http::TypedRequest & req) { + if (!ctx.auth_config().enabled) { + return false; // Nothing is anonymous when nothing is authenticated. + } + auto * manager = ctx.auth_manager(); + if (manager == nullptr) { + return true; // Fail closed: cannot verify, so do not disclose. + } + if (!manager->is_public_route(req.method(), req.path())) { + return false; + } + auto header = req.header("Authorization"); + if (!header) { + return true; + } + auto token = AuthMiddleware::extract_bearer_token(*header); + if (!token) { + return true; + } + return !manager->validate_token(*token).valid; +} + +} // namespace + http::Result HealthHandlers::get_health(const http::TypedRequest & req) { - (void)req; // Unused parameter try { dto::Health response; response.status = "healthy"; response.timestamp = std::chrono::system_clock::now().time_since_epoch().count(); + // Liveness and nothing else for an anonymous caller on a route somebody + // opened by name. Returned before any of the sections below are built, so + // a section added later is private by default, and stays private until + // someone decides otherwise. The flag is what keeps the empty + // `warnings` below from reading as "nothing is wrong here" to a monitor + // that never presented a credential. + if (serves_reduced_body(ctx_, req)) { + response.x_medkit_reduced = true; + return response; + } + // Operator-actionable warnings the gateway flags without taking itself // offline. Collected across every subsystem that can produce one, so the // array and its schema version are part of the /health contract whether or diff --git a/src/ros2_medkit_gateway/src/http/http_server.cpp b/src/ros2_medkit_gateway/src/http/http_server.cpp index 3979bd477..42459e203 100644 --- a/src/ros2_medkit_gateway/src/http/http_server.cpp +++ b/src/ros2_medkit_gateway/src/http/http_server.cpp @@ -17,6 +17,11 @@ #include #include +#ifdef CPPHTTPLIB_OPENSSL_SUPPORT +// TLS1_2_VERSION / TLS1_3_VERSION and SSL_CTX_set_min_proto_version. +#include +#endif + namespace ros2_medkit_gateway { HttpServerManager::HttpServerManager(const TlsConfig & tls_config, std::size_t thread_pool_size, @@ -24,8 +29,19 @@ HttpServerManager::HttpServerManager(const TlsConfig & tls_config, std::size_t t : tls_config_(tls_config), thread_pool_size_(thread_pool_size), keep_alive_timeout_sec_(keep_alive_timeout_sec) { #ifdef CPPHTTPLIB_OPENSSL_SUPPORT if (tls_config_.enabled) { - // Create SSL server with certificate and key - ssl_server_ = std::make_unique(tls_config_.cert_file.c_str(), tls_config_.key_file.c_str()); + // A non-empty ca_file turns on mutual TLS. The SSLServer constructor does + // the work itself: given a client CA path it calls + // SSL_CTX_load_verify_locations and then + // SSL_CTX_set_verify(SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT). + // + // That pairing is why this is all-or-nothing per gateway: with a CA set, + // a client that presents NO certificate is rejected at the handshake. + // There is no "verify it if offered" middle setting without patching the + // vendored header. Leaving ca_file empty keeps ordinary server-only TLS, + // which is what the SOVD bearer-token flow expects. + ssl_server_ = + std::make_unique(tls_config_.cert_file.c_str(), tls_config_.key_file.c_str(), + tls_config_.ca_file.empty() ? nullptr : tls_config_.ca_file.c_str()); if (!ssl_server_->is_valid()) { throw std::runtime_error( @@ -33,13 +49,29 @@ HttpServerManager::HttpServerManager(const TlsConfig & tls_config, std::size_t t " (key configured: " + (tls_config_.key_file.empty() ? "no" : "yes") + ")"); } + // The constructor above ignores what SSL_CTX_load_verify_locations returned, + // so `is_valid()` is true for a ca_file that exists but is unreadable or is + // not PEM. The gateway would then start, log "REQUIRED (mutual TLS)", and + // reject every client at the handshake with no trust store to check them + // against - an outage that reads as a client problem. Load it again and + // look at the answer this time; loading the same file twice is a no-op + // beyond re-adding the same certificates to the store. + if (!tls_config_.ca_file.empty() && + SSL_CTX_load_verify_locations(ssl_server_->ssl_context(), tls_config_.ca_file.c_str(), nullptr) != 1) { + throw std::runtime_error( + "Mutual TLS is configured but the client CA bundle could not be loaded: " + tls_config_.ca_file + + ". The file must be readable by the gateway and contain PEM certificates."); + } + // Configure additional TLS settings configure_tls(); apply_thread_pool(*ssl_server_); apply_keep_alive(*ssl_server_); - RCLCPP_INFO(rclcpp::get_logger("http_server"), "TLS/HTTPS enabled - cert: %s, min_version: %s", - tls_config_.cert_file.c_str(), tls_config_.min_version.c_str()); + RCLCPP_INFO(rclcpp::get_logger("http_server"), + "TLS/HTTPS enabled - cert: %s, min_version: %s, client certificates: %s", tls_config_.cert_file.c_str(), + tls_config_.min_version.c_str(), + tls_config_.ca_file.empty() ? "not required" : "REQUIRED (mutual TLS)"); // Note: key_file path intentionally not logged for security reasons } else { server_ = std::make_unique(); @@ -138,26 +170,26 @@ void HttpServerManager::configure_tls() { return; } - // YAGNI Decision: min_version field exists in TlsConfig for future extensibility - // but is not fully implemented. - // - // Rationale: - // - cpp-httplib's SSLServer doesn't expose SSL_CTX for min_version configuration - // - Modern OpenSSL (1.1.1+) defaults to TLS 1.2+ which is secure + // Set the protocol floor on our own context, so nothing is inherited. // - // Future implementation options: - // 1. Fork cpp-httplib to expose SSL_CTX for SSL_CTX_set_min_proto_version() - // 2. Use OpenSSL system-wide configuration (/etc/ssl/openssl.cnf) - // 3. Replace cpp-httplib with Boost.Beast or another library with full SSL control + // Two reasons it has to be us. The SSLServer constructor calls + // SSL_CTX_set_min_proto_version(ctx_, TLS1_1_VERSION), so the library asks + // for a floor of TLS 1.1. What a deployment actually gets on top of that is + // whatever the local OpenSSL policy allows, which differs between the + // distributions we ship for. Neither of those is a decision this project + // made, and SOVD requires TLS 1.2 as the minimum, so the value is set here + // where it can be read and tested. // - // TODO(future): Add mutual TLS support - requires cpp-httplib modifications - // to expose SSL_CTX for SSL_CTX_set_verify() with SSL_VERIFY_PEER - - if (tls_config_.min_version != "1.2") { - RCLCPP_WARN(rclcpp::get_logger("http_server"), - "min_version='%s' requested but cpp-httplib uses OpenSSL defaults (TLS 1.2+). " - "Custom min_version not enforced.", - tls_config_.min_version.c_str()); + // The string is validated in TlsConfig::validate(), which rejects anything + // other than "1.2" or "1.3" before a server is ever constructed. + const int min_proto = (tls_config_.min_version == "1.3") ? TLS1_3_VERSION : TLS1_2_VERSION; + SSL_CTX * ctx = ssl_server_->ssl_context(); + if (ctx == nullptr || SSL_CTX_set_min_proto_version(ctx, min_proto) != 1) { + // Refuse to serve. Falling back to the library floor would hand a caller + // that asked for TLS 1.3 a connection negotiated at 1.1, and nothing + // downstream can tell the two apart - so the weaker transport would look + // exactly like the one that was configured. + throw std::runtime_error("Failed to set the minimum TLS version to " + tls_config_.min_version); } // Log TLS handshake failures for debugging diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 5b070aece..d3dfede47 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -317,8 +317,25 @@ void RESTServer::setup_pre_routing_handler() { } } - // 2. Handle preflight OPTIONS requests - if (req.method == "OPTIONS") { + // 2. Handle preflight OPTIONS requests. + // + // This is answered WITHOUT a credential, and it has to be. A browser + // never puts Authorization on a preflight - asking permission before + // sending the real request, headers included, is the entire purpose of + // the mechanism - so requiring one here does not harden the gateway, it + // makes every browser client impossible. + // + // It is safe because a preflight discloses nothing about the system: the + // response is the CORS policy for an origin the operator configured, with + // no body, and the real request that follows is authenticated normally. + // Treat this as a named exemption alongside /auth/, not as an oversight. + // A real preflight carries Access-Control-Request-Method; the browser + // sends it to ask whether the method it is about to use is allowed. + // Requiring it is what keeps the exemption to the case that genuinely + // cannot authenticate: matching on the method and the origin alone would + // let a plain anonymous OPTIONS take this early return, which is + // "OPTIONS is public", which is wider than "a preflight is public". + if (req.method == "OPTIONS" && req.has_header("Access-Control-Request-Method")) { if (origin_allowed) { res.set_header("Access-Control-Max-Age", std::to_string(cors_config_.max_age_seconds)); res.status = 204; @@ -329,22 +346,66 @@ void RESTServer::setup_pre_routing_handler() { } } - // 3. Rate limiting check. If rejected, return Handled (CORS headers already set) - if (rate_limiter_ && rate_limiter_->is_enabled() && req.method != "OPTIONS") { - auto rl_result = rate_limiter_->check(req.remote_addr, req.path); - RateLimiter::apply_headers(rl_result, res); - if (!rl_result.allowed) { - RateLimiter::apply_rejection(rl_result, res); - return handled(req, res); - } + // 3. Rate limiting is METERED here, and ANSWERED at one of two later + // points depending on what the caller presented. + // + // Metering before authentication makes every request spend its allowance, + // a refused one included, so a flood of bad credentials cannot run free. + // The cost of the refusal is the other half: an over-limit caller sending + // any Authorization header at all pays for a signature verification per + // request under the old order, which under RS256 is the expensive path. + // + // The X-RateLimit-* headers are NOT written here. They report how much + // allowance is left and when it resets, which is limiter state, and at + // metering time nothing is known about the caller yet. They go on at step + // 5, where the caller has been accepted or the route needs nobody. + bool rate_limited = false; + RateLimitResult rl_result; + bool rate_metered = false; + // Every method reaching this line is metered, OPTIONS included. With CORS + // on, a preflight - an OPTIONS carrying Access-Control-Request-Method - + // has already been answered at step 2; with CORS off there is no preflight + // to exempt. Any other OPTIONS is an ordinary request to a route: where the + // route needs a credential it runs the token verifier like any other + // request, so it spends allowance like any other. The exemption belongs + // to the preflight; one for the method would let a flood of plain OPTIONS + // cost a signature check each for free. + if (rate_limiter_ && rate_limiter_->is_enabled()) { + rl_result = rate_limiter_->check(req.remote_addr, req.path); + rate_limited = !rl_result.allowed; + rate_metered = true; } - // 1. Handle CORS (existing logic) - - // Handle Authentication if enabled + // 4. Authentication, with the limiter given the first word where it has + // one to say. if (auth_middleware_ && auth_middleware_->is_enabled()) { - // Use AuthMiddleware to process the request auto auth_request = AuthMiddleware::from_httplib_request(req); + + // An exhausted caller who presented an Authorization header is already + // being refused, so the signature never needs checking - see + // rate_limit_precedes_validation for the three conditions and why the + // header is one of them. The `&&` order matters: the route question is a + // policy lookup, and it is asked only on the path that can use the + // answer. + if (rate_limited && auth_request.authorization_header.has_value() && + AuthMiddleware::rate_limit_precedes_validation(rate_limited, true, + auth_middleware_->requires_authentication(auth_request))) { + // Bare: the refusal and nothing about the limiter. Whoever holds this + // header has not been verified, so the allowance, the reset time and + // the retry delay stay behind the credential check they would + // otherwise sit in front of. + RateLimiter::apply_bare_rejection(res); + return handled(req, res); + } + + // A caller with no credential keeps the anonymous 401. `process` returns + // on the missing Authorization header before it extracts or verifies + // anything, so that refusal already costs nothing beyond the route + // lookup - which is the whole of what refusing it earlier would buy. A + // second refusal path here would produce a 401 of a different shape from + // every other one: no WWW-Authenticate and no error document, so a + // client could not tell a missing credential from an expired one, and + // the difference is itself a signal about which of the two decided. auto result = auth_middleware_->process(auth_request); if (!result.allowed) { @@ -353,6 +414,18 @@ void RESTServer::setup_pre_routing_handler() { } } + // 5. Now the limiter may speak on every remaining request, headers + // included. The caller reached this line with a credential this gateway + // accepts, or on a route that needs none, so the allowance and the reset + // time tell them something they are entitled to know. + if (rate_metered) { + RateLimiter::apply_headers(rl_result, res); + } + if (rate_limited) { + RateLimiter::apply_rejection(rl_result, res); + return handled(req, res); + } + return httplib::Server::HandlerResponse::Unhandled; }); } diff --git a/src/ros2_medkit_gateway/test/test_auth_environment.cpp b/src/ros2_medkit_gateway/test/test_auth_environment.cpp new file mode 100644 index 000000000..93d5916eb --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_auth_environment.cpp @@ -0,0 +1,372 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// The environment rule the gateway node applies to its auth parameters. +/// +/// One rule, one place, and this sweeps its whole input space: the three +/// variables are independent, so the interesting cases are the combinations, +/// not a single happy path. The precedence between MEDKIT_AUTH_DISABLED and +/// MEDKIT_JWT_SECRET is the half that decides whether a container its operator +/// believes closed is actually closed. + +#include + +#include +#include +#include + +#include "ros2_medkit_gateway/core/auth/auth_environment.hpp" + +using namespace ros2_medkit_gateway; + +namespace { + +constexpr const char * kSecret = "an_environment_supplied_secret_of_at_least_32"; + +std::optional unset() { + return std::nullopt; +} + +/// True when some notice mentions `needle`. The notices are what an operator +/// reads to learn the environment overrode their file, so their presence is +/// part of the contract, not decoration. +bool mentions(const AuthEnvironment & env, const std::string & needle) { + return std::any_of(env.notices.begin(), env.notices.end(), [&needle](const std::string & n) { + return n.find(needle) != std::string::npos; + }); +} + +} // namespace + +// Nothing set: the environment says nothing and every parameter stands. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, AnEmptyEnvironmentDecidesNothing) { + auto env = resolve_auth_environment(unset(), unset(), unset()); + EXPECT_FALSE(env.applies); + EXPECT_TRUE(env.notices.empty()); + EXPECT_FALSE(env.clients_given); +} + +// The closing statement, and the whole of it: on, "all", the secret, and the +// clients. Leaving require_auth_for at the open profile's "write" was the +// failure this rule exists to prevent - every read stays open and the operator +// is told the gateway is closed. +// @verifies REQ_INTEROP_086, REQ_INTEROP_087 +TEST(AuthEnvironmentTest, ASecretClosesTheGatewayForEveryRoute) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), std::string("svc:svc_secret:admin")); + + EXPECT_TRUE(env.applies); + EXPECT_TRUE(env.enabled); + EXPECT_TRUE(env.require_auth_for_all); + EXPECT_EQ(env.jwt_secret, kSecret); + ASSERT_EQ(env.clients.size(), 1U); + EXPECT_EQ(env.clients[0], "svc:svc_secret:admin"); + EXPECT_TRUE(env.clients_given); + EXPECT_TRUE(mentions(env, "MEDKIT_JWT_SECRET")); +} + +// MEDKIT_AUTH_DISABLED=1 wins, including over a secret that would otherwise +// close the gateway. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, DisabledWinsOverASecret) { + auto env = resolve_auth_environment(std::string("1"), std::string(kSecret), std::string("svc:svc_secret:admin")); + + EXPECT_TRUE(env.applies); + EXPECT_FALSE(env.enabled); + EXPECT_FALSE(env.require_auth_for_all); + EXPECT_TRUE(env.jwt_secret.empty()); + EXPECT_TRUE(env.clients.empty()); + EXPECT_TRUE(mentions(env, "MEDKIT_AUTH_DISABLED=1")); +} + +// Only the exact string "1". A variable set to something that looks +// affirmative must not turn authentication off for somebody who meant the +// opposite, and "0" must not turn it off either. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, OnlyTheLiteralOneDisables) { + for (const auto & value : {"", "0", "true", "TRUE", "yes", "01", "1 ", " 1", "2"}) { + auto env = resolve_auth_environment(std::string(value), std::string(kSecret), std::string("svc:s:admin")); + EXPECT_TRUE(env.enabled) << "MEDKIT_AUTH_DISABLED=\"" << value << "\" was read as the opt-out"; + } +} + +// Disabled alone, with no secret anywhere: still a statement, and still the +// one that must reach the node - a params file with auth on has to lose. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, DisabledAloneStillOverridesTheParameters) { + auto env = resolve_auth_environment(std::string("1"), unset(), unset()); + EXPECT_TRUE(env.applies); + EXPECT_FALSE(env.enabled); +} + +// An empty secret is not a secret. Docker sets a variable to the empty string +// when `-e MEDKIT_JWT_SECRET` is passed with no value, and reading that as +// "close the gateway" would produce a gateway with authentication on and an +// empty signing key, which refuses to start. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, AnEmptySecretIsNotAStatement) { + auto env = resolve_auth_environment(unset(), std::string(""), std::string("svc:s:admin")); + EXPECT_FALSE(env.applies); + EXPECT_FALSE(env.clients_given); +} + +// Clients are read only where a token can be obtained. With authentication +// off, replacing auth.clients would be a change nobody asked for. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, ClientsAreIgnoredWhereNoTokenCanBeIssued) { + auto disabled = resolve_auth_environment(std::string("1"), unset(), std::string("svc:s:admin")); + EXPECT_FALSE(disabled.clients_given); + + auto silent = resolve_auth_environment(unset(), unset(), std::string("svc:s:admin")); + EXPECT_FALSE(silent.clients_given); +} + +// MEDKIT_CLIENTS on its own closes nothing, and the operator is told. +// +// Credentials are read only where the environment is what closes this gateway. +// Setting the variable alone looks like half a configuration and behaves like +// none, so silence here reads as "the credential was accepted" to whoever set +// it and then cannot log in. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, ClientsWithoutASecretAreIgnoredOutLoud) { + auto env = resolve_auth_environment(unset(), unset(), std::string("svc:s:admin")); + + EXPECT_FALSE(env.applies); + EXPECT_FALSE(env.clients_given) << "auth.clients was replaced with no secret to close the gateway"; + EXPECT_TRUE(mentions(env, "MEDKIT_CLIENTS is set and MEDKIT_JWT_SECRET is unset or empty")) + << "MEDKIT_CLIENTS was ignored in silence"; + EXPECT_TRUE(mentions(env, "auth.clients from the parameters stands")); +} + +// A set-but-empty MEDKIT_JWT_SECRET closes nothing, and says so. The variable +// is documented as non-empty, and an operator who exported it empty is holding +// half a configuration; silence here reads as "the gateway is closed". +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, AnEmptySecretClosesNothingAndSaysSo) { + auto env = resolve_auth_environment(unset(), std::string(""), unset()); + EXPECT_FALSE(env.applies); + EXPECT_TRUE(mentions(env, "MEDKIT_JWT_SECRET is set but empty")) + << "an empty secret was folded into unset in silence"; +} + +// With clients beside it, the notice says the secret is empty, not that it is +// unset: the operator did set it. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, ClientsWithAnEmptySecretAreToldTheSecretIsEmpty) { + auto env = resolve_auth_environment(unset(), std::string(""), std::string("svc:s:admin")); + EXPECT_FALSE(env.applies); + EXPECT_FALSE(env.clients_given); + EXPECT_TRUE(mentions(env, "MEDKIT_CLIENTS is set and MEDKIT_JWT_SECRET is unset or empty")); +} + +// A swapped entry `id:role:secret` puts the secret where the role goes; the +// notice names the position and the id and never the role field. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, ABadRoleFieldIsNotEchoed) { + auto env = resolve_auth_environment(unset(), std::string("a_secret_of_at_least_32_characters_long_x"), + std::string("svc:admin:s3cret_swapped_zz")); + EXPECT_TRUE(env.clients.empty()); + EXPECT_TRUE(mentions(env, "names an unknown role")); + for (const auto & notice : env.notices) { + EXPECT_EQ(notice.find("s3cret_swapped_zz"), std::string::npos) << notice; + } +} + +// The redacted client list mirrors what the gateway registers: one +// `::` per entry with two distinct colons and a role the +// gateway knows, in order, the role in its canonical spelling. Anything else is +// omitted, because a malformed entry may carry its secret in any field. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, RedactedClientEntriesShowIdAndRoleOnly) { + const std::vector entries = {"a:s3cret:admin", "b:s3cret:Viewer", "nocolons", "c:s3cret:wizard", + "twofield:s3cret", "d:s3:cret:operator", ""}; + const auto shown = redact_client_entries(entries, ""); + const std::vector expected = {"a::admin", "b::viewer", "d::operator"}; + EXPECT_EQ(shown, expected); + for (const auto & line : shown) { + EXPECT_EQ(line.find("s3"), std::string::npos) << line; + } + EXPECT_TRUE(redact_client_entries({}, "").empty()); + EXPECT_TRUE(redact_client_entries({""}, "").empty()) << "the empty-sequence idiom is not an entry"; +} + +// An empty MEDKIT_CLIENTS with no secret is not worth a line: nothing was +// asked for and nothing happened. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, AnEmptyClientsVariableWithNoSecretSaysNothing) { + auto env = resolve_auth_environment(unset(), unset(), std::string("")); + EXPECT_FALSE(env.applies); + EXPECT_TRUE(env.notices.empty()); +} + +// Set means replace, and the empty string is set. Falling back to the file's +// credentials here would hand out tokens against a secret those credentials +// were never issued under. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, AnEmptyClientListReplacesTheFilesClientsWithNone) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), std::string("")); + + EXPECT_TRUE(env.clients_given) << "an empty MEDKIT_CLIENTS left auth.clients standing"; + EXPECT_TRUE(env.clients.empty()); + EXPECT_EQ(env.client_entries_seen, 0U) << "an empty value must not look like a list that failed to parse"; + EXPECT_TRUE(mentions(env, "no client can obtain a token")) + << "the consequence of an empty client list was not stated"; +} + +// The separator is a comma, and a list written by hand puts a space after it. +// Space INSIDE a field belongs to that field: a secret may legitimately carry +// one, and trimming it would authenticate a credential the operator did not +// configure. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, SurroundingSpaceIsDroppedAndInnerSpaceIsKept) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), + std::string(" one:a:admin ,\ttwo:b:viewer\t, three:c c:operator ")); + + ASSERT_EQ(env.clients.size(), 3U); + EXPECT_EQ(env.clients[0], "one:a:admin"); + EXPECT_EQ(env.clients[1], "two:b:viewer"); + EXPECT_EQ(env.clients[2], "three:c c:operator") << "a space inside the secret was trimmed away"; +} + +// A whitespace-only entry is a separator artefact like an empty one. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, AWhitespaceOnlyEntryIsSkippedInSilence) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), std::string("one:a:admin, ,two:b:viewer")); + + EXPECT_EQ(env.clients.size(), 2U); + EXPECT_FALSE(mentions(env, "entry 2")) << "a whitespace-only entry was reported as malformed"; +} + +// Positions name what an operator can point at in the variable, so they count +// every comma-separated field as written, empties included. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, PositionsCountEveryFieldAsWritten) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), std::string("one:a:admin,,,bad,two:b:viewer")); + + EXPECT_EQ(env.clients.size(), 2U); + EXPECT_TRUE(mentions(env, "entry 4")) << "the malformed entry is the fourth field and was numbered otherwise"; + EXPECT_FALSE(mentions(env, "entry 2")); + EXPECT_FALSE(mentions(env, "entry 3")); +} + +// An id names the credential a client authenticates as. Taking the last entry +// would make the secret in force depend on a position nobody thinks about. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, TheFirstEntryForAnIdWins) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), + std::string("svc:first:admin,svc:second:viewer,other:x:viewer")); + + ASSERT_EQ(env.clients.size(), 2U); + EXPECT_EQ(env.clients[0], "svc:first:admin"); + EXPECT_EQ(env.clients[1], "other:x:viewer"); + EXPECT_TRUE(mentions(env, "repeats client id")) << "the duplicate was dropped without saying so"; + EXPECT_TRUE(mentions(env, "entry 2")); +} + +// The state the caller has to be able to distinguish: a list that was written +// and refused entirely, which is a misconfiguration, against an empty value, +// which is a decision. Both leave `clients` empty. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, EntriesSeenSeparatesAnEmptyListFromARefusedOne) { + auto refused = resolve_auth_environment(unset(), std::string(kSecret), std::string("nocolons,alsobad")); + EXPECT_TRUE(refused.clients.empty()); + EXPECT_EQ(refused.client_entries_seen, 2U); + + auto empty = resolve_auth_environment(unset(), std::string(kSecret), std::string(",,")); + EXPECT_TRUE(empty.clients.empty()); + EXPECT_EQ(empty.client_entries_seen, 0U); +} + +// The separator contract: commas between entries, three colon-separated fields +// in each, and a secret that may itself contain colons because the id stops at +// the FIRST colon and the role starts after the LAST. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, ClientsSplitOnCommasAndKeepColonsInTheSecret) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), + std::string("one:a:admin,two:b1:b2:b3:viewer,three:c:operator")); + + ASSERT_EQ(env.clients.size(), 3U); + EXPECT_EQ(env.clients[0], "one:a:admin"); + EXPECT_EQ(env.clients[1], "two:b1:b2:b3:viewer") << "a secret containing colons was mangled"; + EXPECT_EQ(env.clients[2], "three:c:operator"); +} + +// Every role name the gateway knows, and nothing else. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, EveryDocumentedRoleIsAccepted) { + auto env = + resolve_auth_environment(unset(), std::string(kSecret), + std::string("a:s:viewer,b:s:operator,c:s:configurator,d:s:admin,e:s:ADMIN,f:s:Viewer")); + EXPECT_EQ(env.clients.size(), 6U) << "a documented role was refused, or case-folding stopped working"; +} + +// A malformed entry is dropped, named by its position, and takes nothing else +// with it. Dropping the whole list would leave a closed container nobody can +// open, which is worse than the typo. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, AMalformedEntryIsDroppedAndNamedByPosition) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), + std::string("good:s:admin,nocolons,bad:s:wizard,:s:admin,last:s:viewer")); + + ASSERT_EQ(env.clients.size(), 2U); + EXPECT_EQ(env.clients[0], "good:s:admin"); + EXPECT_EQ(env.clients[1], "last:s:viewer"); + + EXPECT_TRUE(mentions(env, "entry 2")) << "the entry with no colons was not named"; + EXPECT_TRUE(mentions(env, "entry 3")) << "the entry with an unknown role was not named"; + EXPECT_TRUE(mentions(env, "entry 4")) << "the entry with an empty id was not named"; + EXPECT_TRUE(mentions(env, "names an unknown role")) << "the entry with an unknown role was not explained"; + EXPECT_FALSE(mentions(env, "wizard")) << "the role field was quoted back, and a swapped entry puts the secret there"; +} + +// Degenerate separators. A trailing or doubled comma is a habit, not a typo, +// and warning about it would drown the warnings that matter. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, EmptyEntriesAreSkippedWithoutComplaint) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), std::string(",,one:s:admin,,two:s:viewer,,")); + + ASSERT_EQ(env.clients.size(), 2U); + EXPECT_FALSE(mentions(env, "entry 1")) << "an empty entry was reported as malformed"; + EXPECT_FALSE(mentions(env, "and was dropped")); +} + +// Every field has to be there. An entry with an empty secret would register a +// client authenticating on the empty string. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, AnEmptyFieldRefusesTheEntry) { + for (const auto & entry : {":secret:admin", "id::admin", "id:secret:", "::", "::admin"}) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), std::string(entry)); + EXPECT_TRUE(env.clients.empty()) << "entry \"" << entry << "\" was accepted"; + EXPECT_TRUE(env.clients_given) << "entry \"" << entry << "\" left auth.clients standing"; + } +} + +// Two fields is the shape people reach for first, and "id:role" is the one +// that gets through a check counting colons loosely: with only the presence of +// a colon required, the field after it is read as the role, it parses, and a +// client is registered whose secret is its own role name. Every entry here has +// one colon; the last three end in a legal role and are the discriminating +// cases. +// @verifies REQ_INTEROP_086 +TEST(AuthEnvironmentTest, TwoFieldsIsNotAClient) { + for (const auto & entry : {"svc:secret", "svc:", ":svc", "svc:admin", "svc:viewer", "svc:operator"}) { + auto env = resolve_auth_environment(unset(), std::string(kSecret), std::string(entry)); + EXPECT_TRUE(env.clients.empty()) << "\"" << entry << "\" was registered as a client"; + EXPECT_TRUE(mentions(env, "entry 1")) << "\"" << entry << "\" was dropped without naming its position"; + EXPECT_TRUE(mentions(env, "is not ::")) + << "\"" << entry + << "\" was refused for some other reason than its shape, so the shape check is not what " + "caught it"; + } +} diff --git a/src/ros2_medkit_gateway/test/test_auth_manager.cpp b/src/ros2_medkit_gateway/test/test_auth_manager.cpp index aad73dd5d..1d4ab6525 100644 --- a/src/ros2_medkit_gateway/test/test_auth_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_auth_manager.cpp @@ -15,9 +15,12 @@ #include #include +#include #include +#include #include "ros2_medkit_gateway/core/auth/auth.hpp" +#include "ros2_medkit_gateway/core/http/rate_limiter.hpp" using namespace ros2_medkit_gateway; @@ -529,6 +532,62 @@ TEST(AuthManagerRequirementTest, RequireAuthForAll) { EXPECT_FALSE(manager.requires_authentication("POST", "/api/v1/auth/authorize")); } +// An access token keeps its promised lifetime after its refresh token expires. +// +// validate_token() refuses an access token whose refresh record is gone, which +// is what makes a revocation survive. The cost is that the sweep decides how +// long an access token really lives: sweeping on the refresh token's own +// expiry would cut short the last access token minted from it, which was +// promised a full token_expiry_seconds a moment earlier. This is the test that +// fails if the grace period is removed. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRequirementTest, AccessTokenOutlivesItsExpiredRefreshRecord) { + // The two expiries are equal, which is the tightest the builder allows + // (refresh must be >= access). That is also the worst case: refresh_access_token + // reuses the refresh token's jti and does not rotate it, so the access token + // it mints is promised token_expiry_seconds from NOW while the record still + // dies at its original expiry. Every refresh therefore produces an access + // token that outlives its own record. + // Three seconds, not one. Two constraints set these numbers. + // + // Expiries are whole seconds, so the sweep's comparison only moves at second + // boundaries: with a one-second expiry and a 1.3 s wait, `expires_at < now` + // is still false through integer truncation, and the test would pass with or + // without the grace period - measuring nothing. + // + // And the waits are wall-clock on a machine running the rest of the suite, so + // each one has to sit well clear of the boundary it is about, and never just + // past it. The record expires at t+3 and is swept after t+6; the checks are + // at t+4 and t+9, leaving 2 s and 3 s of slack for a late wake-up. + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret_key_min_32_chars_life_") + .with_token_expiry(3) + .with_refresh_token_expiry(3) + .with_require_auth_for(AuthRequirement::ALL) + .build(); + config.clients.push_back({"c", "s", UserRole::ADMIN, true}); + + AuthManager manager(config); + ASSERT_TRUE(manager.authenticate("c", "s").has_value()); + ASSERT_EQ(manager.refresh_token_count(), 1u); + + // t+4s: past the record's own expiry (t+3), inside the one access-token + // lifetime it is held for (to t+6). Sweeping here is what cut an access token + // short. + std::this_thread::sleep_for(std::chrono::milliseconds(4000)); + manager.cleanup_expired_tokens(); + EXPECT_EQ(manager.refresh_token_count(), 1u) + << "the record was dropped at its own expiry, so any access token minted " + "from it in its last moments is refused with most of its life left"; + + // t+9s: past the grace too. It does not live forever, or a revocation would + // be honoured out of a map that only ever grows. + std::this_thread::sleep_for(std::chrono::milliseconds(5000)); + manager.cleanup_expired_tokens(); + EXPECT_EQ(manager.refresh_token_count(), 0u) << "the record outlived even its grace period"; +} + // Test none auth requirement mode TEST(AuthManagerRequirementTest, RequireAuthForNone) { AuthConfig config = AuthConfigBuilder() @@ -677,6 +736,205 @@ TEST_F(AuthManagerTest, CleanupExpiredTokens) { EXPECT_GE(cleaned, 1); } +// --------------------------------------------------------------------------- +// Refresh-record growth, constant-time secret comparison, and revocation. +// --------------------------------------------------------------------------- + +namespace { + +/// A manager with a single admin client, parameterised on the two expiry +/// values, so a test can put them at their endpoints, away from one +/// comfortable middle value. +AuthManager make_manager(int access_expiry, int refresh_expiry) { + auto config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("expiry_sweep_secret_key_at_least_32_chars_long") + .with_require_auth_for(AuthRequirement::ALL) + .with_token_expiry(access_expiry) + .with_refresh_token_expiry(refresh_expiry) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + return AuthManager(config); +} + +} // namespace + +// The store is bounded by the sweep the authorisation path runs, so what this +// asserts is the COUNT after ordinary use. A sweep called directly by a test +// returns the right answer whether or not production ever reaches it, which is +// why the count and not the return value is the subject. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerTokenLifetimeTest, RepeatedLoginsDoNotGrowTheStoreWithoutBound) { + // Refresh expiry at its minimum legal value: validate() requires + // refresh >= access, so this is the endpoint, not a convenient number. + auto manager = make_manager(1, 1); + + for (int i = 0; i < 5; ++i) { + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + } + EXPECT_EQ(manager.refresh_token_count(), 5U) << "records should accumulate while they are live"; + + // Past the refresh expiry AND the access-token lifetime a record is held for + // beyond it, the next authorisation must clear them out. Records expire at + // t+1 and are swept after t+2, so this waits to t+4: far enough clear of the + // boundary that a late wake-up on a loaded machine cannot land short of it. + std::this_thread::sleep_for(std::chrono::milliseconds(4000)); + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + + EXPECT_EQ(manager.refresh_token_count(), 1U) + << "the five expired records survived a later authorisation - the sweep is not running"; +} + +// The other endpoint. A long-lived refresh token must NOT be swept: an +// over-eager sweep would log clients out mid-session, which is the opposite +// failure and just as real. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerTokenLifetimeTest, LongLivedRecordsAreNotSweptEarly) { + auto manager = make_manager(1, 86400); + + for (int i = 0; i < 4; ++i) { + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + } + std::this_thread::sleep_for(std::chrono::seconds(2)); + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + + EXPECT_EQ(manager.refresh_token_count(), 5U) << "records well inside their expiry were discarded"; +} + +// Degenerate case: access and refresh expiry equal and both large. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerTokenLifetimeTest, EqualAccessAndRefreshExpiryKeepsRecords) { + auto manager = make_manager(3600, 3600); + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + ASSERT_TRUE(manager.authenticate("svc", "svc_secret").has_value()); + EXPECT_EQ(manager.refresh_token_count(), 2U); +} + +// A wrong secret must be refused whatever its shape. The interesting inputs +// are the ones a short-circuiting comparison treats differently from a +// constant-time one: a correct prefix, and a value that extends the real one. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerSecretComparisonTest, OnlyTheExactSecretAuthenticates) { + auto manager = make_manager(3600, 3600); + + EXPECT_TRUE(manager.authenticate("svc", "svc_secret").has_value()) << "the real secret must work"; + + // The last two are the ones that matter. Everything before them differs in + // length, or in the first byte, so a comparison that checked the length and + // then only a prefix would satisfy the whole list. "svc_secreT" differs only + // in the FINAL byte: shorten the comparison loop by one and it is accepted + // while every other case here still fails correctly. + for (const auto & wrong : + {"", "s", "svc_secre", "svc_secret_", "svc_secretX", "SVC_SECRET", "xxxxxxxxxx", "svc_secreT", "Svc_secret"}) { + EXPECT_FALSE(manager.authenticate("svc", wrong).has_value()) << "secret \"" << wrong << "\" was accepted"; + } +} + +// The denylist half: a record held and marked revoked refuses the access +// tokens minted from it. This is the whole of what the record store decides, +// so it is the half that must not regress. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, ARevokedRecordRefusesTheAccessTokenMintedFromIt) { + auto manager = make_manager(3600, 3600); + auto issued = manager.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + + EXPECT_TRUE(manager.validate_token(issued->access_token).valid) + << "the token must be valid while its record is present and not revoked"; + + ASSERT_TRUE(issued->refresh_token.has_value()); + ASSERT_TRUE(manager.revoke_refresh_token(issued->refresh_token.value())); + EXPECT_FALSE(manager.validate_token(issued->access_token).valid) + << "an access token whose refresh record was revoked was still accepted"; +} + +// A second manager standing in for the same gateway after a restart: same +// secret and issuer, so the signature still verifies, and no records, because +// they lived in the memory of the process that is gone. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, ARestartKeepsAcceptingATokenItCanStillVerify) { + auto before = make_manager(3600, 3600); + auto issued = before.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(before.validate_token(issued->access_token).valid); + + auto after_restart = make_manager(3600, 3600); + EXPECT_TRUE(after_restart.validate_token(issued->access_token).valid) + << "a token that verifies under the configured secret and is inside its expiry was refused " + "because this process holds no record of issuing it"; +} + +// The cross-instance case the same rule has to serve: two gateways sharing a +// JWT configuration, which is the deployment `aggregation.forward_auth` +// describes. The peer never issued this token and never will hold a record for +// it, so an allowlist would refuse every forwarded request. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, APeerSharingTheJwtConfigurationAcceptsTheOthersToken) { + auto aggregator = make_manager(3600, 3600); + auto peer = make_manager(3600, 3600); + + auto issued = aggregator.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + + EXPECT_TRUE(peer.validate_token(issued->access_token).valid) + << "a peer sharing the secret, the issuer and the client refused a token the aggregator " + "minted - forward_auth cannot work against such a peer"; +} + +// The control for the two above: nothing here is accepting tokens blindly. A +// manager configured with a different secret refuses the same token, so the +// acceptances are the signature verifying and not a check that stopped running. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, AGatewayWithAnotherSecretRefusesTheToken) { + auto issuer = make_manager(3600, 3600); + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + + auto stranger_config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("a_completely_different_secret_key_at_least_32_chars") + .with_require_auth_for(AuthRequirement::ALL) + .with_token_expiry(3600) + .with_refresh_token_expiry(3600) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + AuthManager stranger(stranger_config); + + EXPECT_FALSE(stranger.validate_token(issued->access_token).valid) + << "a gateway that shares no secret with the issuer accepted its token"; +} + +// The revocation has to outlast the tokens it withdraws. +// +// The race the grace period closes: an access token minted just before the +// refresh token expires is promised a full access lifetime, so it is still +// live after the record's own expiry has passed. Sweeping on expires_at alone +// would drop the record there and start honouring a withdrawn token again. +// +// Both expiries are three seconds - config validation requires refresh >= +// access, so this is the tightest legal pair - and the check lands at about +// t+4: past the refresh token's expiry, inside the access token's. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, ARevokedRecordOutlivesTheTokensItWithdraws) { + auto manager = make_manager(3, 3); + auto issued = manager.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(issued->refresh_token.has_value()); + + // Mint a late access token, then withdraw the record it came from. + std::this_thread::sleep_for(std::chrono::milliseconds(2000)); + auto late = manager.refresh_access_token(issued->refresh_token.value()); + ASSERT_TRUE(late.has_value()) << "the refresh token expired before the late access token was minted"; + ASSERT_TRUE(manager.revoke_refresh_token(issued->refresh_token.value())); + + std::this_thread::sleep_for(std::chrono::milliseconds(2000)); + + EXPECT_EQ(manager.cleanup_expired_tokens(), 0U) << "the sweep dropped a revoked record past its own expiry"; + EXPECT_EQ(manager.refresh_token_count(), 1U); + EXPECT_FALSE(manager.validate_token(late->access_token).valid) + << "the revocation stopped holding while the token it withdrew was still live"; +} + // Test JwtClaims TEST(JwtClaimsTest, ToJson) { JwtClaims claims; @@ -1130,6 +1388,124 @@ TEST_F(AuthRequirementPolicyTest, AllAuthPolicyAlwaysRequiresAuth) { EXPECT_TRUE(policy.requires_authentication("DELETE", "/api/v1/admin/users")); } +// Health is NOT special to the ALL policy. It is closed like everything else +// until an operator names it in auth.public_routes, and this is the test that +// fails if somebody hardcodes the exemption back in. +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, AllAuthPolicyDoesNotExemptHealth) { + AllAuthRequirementPolicy policy; + + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/health")); + EXPECT_TRUE(policy.requires_authentication("HEAD", "/api/v1/health")); +} + +// An entry of auth.public_routes opens the route it names and nothing beside +// it. Widening the comparison to a prefix, or dropping the method, is the +// natural next edit and would open a hole, so the boundary is pinned here. +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, PublicRouteExemptionOpensOnlyWhatItNames) { + auto policy = AuthRequirementPolicyFactory::create(AuthRequirement::ALL, {{"GET", "/api/v1/health"}}); + + // The route the operator named. + EXPECT_FALSE(policy->requires_authentication("GET", "/api/v1/health")); + + // Only GET. A write to the health path is not a liveness probe, and + // cpp-httplib dispatches HEAD into the GET handler table, so dropping the + // method check would hand the status document to an anonymous HEAD. + EXPECT_TRUE(policy->requires_authentication("POST", "/api/v1/health")); + EXPECT_TRUE(policy->requires_authentication("PUT", "/api/v1/health")); + EXPECT_TRUE(policy->requires_authentication("DELETE", "/api/v1/health")); + EXPECT_TRUE(policy->requires_authentication("PATCH", "/api/v1/health")); + EXPECT_TRUE(policy->requires_authentication("HEAD", "/api/v1/health")); + + // Only that exact path. A prefix or suffix match would hand an attacker a + // trivial bypass: append or prepend the magic word and walk in. + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v1/health/detail")); + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v1/healthz")); + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v1/components/health")); + EXPECT_TRUE(policy->requires_authentication("GET", "/health")); + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v2/health")); + + // And the rest of the surface is untouched by the entry. + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v1/areas")); + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v1/")); +} + +// The layer only ever removes a requirement. Wrapping must not make a gateway +// stricter than the policy underneath, or an operator who adds a probe route +// would silently close the reads that `write` leaves open. +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, PublicRouteExemptionNeverAddsARequirement) { + auto policy = AuthRequirementPolicyFactory::create(AuthRequirement::WRITE, {{"POST", "/api/v1/health"}}); + + EXPECT_FALSE(policy->requires_authentication("GET", "/api/v1/areas")); + EXPECT_FALSE(policy->requires_authentication("POST", "/api/v1/health")); + EXPECT_TRUE(policy->requires_authentication("POST", "/api/v1/areas")); +} + +// An empty list must leave the policy exactly as it was, or "closed by +// default" would depend on the wrapper behaving itself. +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, EmptyPublicRoutesChangesNothing) { + auto policy = AuthRequirementPolicyFactory::create(AuthRequirement::ALL, {}); + + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v1/health")); + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v1/areas")); + EXPECT_FALSE(policy->requires_authentication("POST", "/api/v1/auth/authorize")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, PublicRouteEntryParsing) { + auto ok = parse_public_route("GET /api/v1/health"); + ASSERT_TRUE(ok.has_value()); + EXPECT_EQ(ok->method, "GET"); + EXPECT_EQ(ok->path, "/api/v1/health"); + + // Case and surrounding whitespace are the operator's typing, not a decision. + auto lower = parse_public_route(" get /api/v1/health "); + ASSERT_TRUE(lower.has_value()); + EXPECT_EQ(lower->method, "GET"); + EXPECT_EQ(lower->path, "/api/v1/health"); + + // Everything below must be refused, and never half-understood. A wildcard + // accepted and then matched literally would read as "this opens the subtree" + // and open nothing, which is the worst of both. + EXPECT_FALSE(parse_public_route("/api/v1/health").has_value()); + EXPECT_FALSE(parse_public_route("GET").has_value()); + EXPECT_FALSE(parse_public_route("").has_value()); + EXPECT_FALSE(parse_public_route(" ").has_value()); + EXPECT_FALSE(parse_public_route("FETCH /api/v1/health").has_value()); + EXPECT_FALSE(parse_public_route("GET api/v1/health").has_value()); + EXPECT_FALSE(parse_public_route("GET /api/v1/*").has_value()); + EXPECT_FALSE(parse_public_route("GET /api/v1/health extra").has_value()); +} + +// A malformed entry must not quietly open something. Dropping it keeps the +// route protected; GatewayNode refuses to start so the typo is not silent. +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, MalformedPublicRoutesAreDropped) { + auto routes = parse_public_routes({"GET /api/v1/health", "nonsense", "GET /api/v1/*"}); + + ASSERT_EQ(routes.size(), 1u); + EXPECT_EQ(routes[0].path, "/api/v1/health"); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, AllAuthPolicyExemptsAuthEndpoints) { + AllAuthRequirementPolicy policy; + + // Authentication cannot bootstrap through a door that already demands the + // credential it exists to hand out. + EXPECT_FALSE(policy.requires_authentication("POST", "/api/v1/auth/authorize")); + EXPECT_FALSE(policy.requires_authentication("POST", "/api/v1/auth/token")); + EXPECT_FALSE(policy.requires_authentication("POST", "/api/v1/auth/revoke")); + + // The prefix must be anchored: a path that merely mentions auth later is + // not an auth endpoint. + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/components/auth/data")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/authorization")); +} + // @verifies REQ_INTEROP_086 TEST_F(AuthRequirementPolicyTest, WriteOnlyPolicyForGetRequests) { WriteOnlyAuthRequirementPolicy policy; @@ -1268,6 +1644,568 @@ TEST_F(AuthRequirementPolicyTest, PolicyDescriptions) { EXPECT_NE(write_only.description(), configurable.description()); } +// `is_public` answers a narrower question than `!requires_authentication`, and +// a handler that withholds part of its body has to ask the narrow one. Under +// "write" every GET is answered anonymously and NONE of them is public in this +// sense, because nobody named one. +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, IsPublicNamesOnlyTheListedRoutes) { + PublicRouteExemptionPolicy policy(std::make_unique(), + std::vector{PublicRoute{"GET", "/api/v1/health"}}); + + EXPECT_TRUE(policy.is_public("GET", "/api/v1/health")); + + // Answered without a credential by the requirement level, and not listed. + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/areas")); + EXPECT_FALSE(policy.is_public("GET", "/api/v1/areas")) + << "a route open only because reads are open was reported as named by an operator"; + + // Exact matching, same as requires_authentication. + EXPECT_FALSE(policy.is_public("HEAD", "/api/v1/health")); + EXPECT_FALSE(policy.is_public("GET", "/api/v1/healthz")); + EXPECT_FALSE(policy.is_public("GET", "/api/v1/health/")); +} + +// Every other policy carries no list, so nothing is public in this sense - not +// even /auth/*, which is open because authentication cannot bootstrap through +// a closed door, not because somebody listed it. +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, APolicyWithNoListHasNoPublicRoutes) { + AllAuthRequirementPolicy all_auth; + WriteOnlyAuthRequirementPolicy write_only; + NoAuthRequirementPolicy no_auth; + + for (const IAuthRequirementPolicy * policy : {static_cast(&all_auth), + static_cast(&write_only), + static_cast(&no_auth)}) { + EXPECT_FALSE(policy->is_public("GET", "/api/v1/health")) << policy->description(); + EXPECT_FALSE(policy->is_public("POST", "/api/v1/auth/authorize")) << policy->description(); + } +} + +// The same question through the manager, which is how a handler reaches it. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerPublicRouteTest, TheManagerReportsTheOperatorsList) { + auto config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("public_route_probe_secret_key_at_least_32_chars") + .with_require_auth_for(AuthRequirement::WRITE) + .with_public_routes({"GET /api/v1/health"}) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + AuthManager manager(config); + + EXPECT_TRUE(manager.is_public_route("GET", "/api/v1/health")); + EXPECT_FALSE(manager.is_public_route("GET", "/api/v1/areas")); + EXPECT_FALSE(manager.requires_authentication("GET", "/api/v1/areas")) + << "the fixture is not in \"write\" mode, so the case it was built for is not being exercised"; +} + +// With no list at all, which is both shipped profiles. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerPublicRouteTest, AnEmptyListMakesNothingPublic) { + auto config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("public_route_probe_secret_key_at_least_32_chars") + .with_require_auth_for(AuthRequirement::WRITE) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + AuthManager manager(config); + + EXPECT_FALSE(manager.is_public_route("GET", "/api/v1/health")); + EXPECT_FALSE(manager.is_public_route("GET", "/api/v1/areas")); +} + +// The ordering rule the pre-routing handler applies, swept over its whole +// input space - three booleans, eight cases. Only one of them may skip the +// verifier, and the "no header" row is the one that must not: an anonymous +// caller keeps the 401 every other anonymous caller gets. +// @verifies REQ_INTEROP_086 +TEST(RateLimitOrderingTest, OnlyAnExhaustedCallerWithACredentialSkipsTheVerifier) { + struct Case { + bool rate_limited; + bool has_header; + bool route_protected; + bool expected; + }; + const Case cases[] = { + {false, false, false, false}, {false, false, true, false}, {false, true, false, false}, + {false, true, true, false}, {true, false, false, false}, {true, false, true, false}, + {true, true, false, false}, {true, true, true, true}, + }; + + for (const auto & c : cases) { + EXPECT_EQ(AuthMiddleware::rate_limit_precedes_validation(c.rate_limited, c.has_header, c.route_protected), + c.expected) + << "rate_limited=" << c.rate_limited << " has_header=" << c.has_header + << " route_protected=" << c.route_protected; + } +} + +// The instrument for the claim, checked against itself: the counter has to +// move when a token IS verified, or a test that sees it stay put proves +// nothing. +// @verifies REQ_INTEROP_086 +TEST(RateLimitOrderingTest, TheValidationCounterMovesWhenTheVerifierRuns) { + auto manager = make_manager(3600, 3600); + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("expiry_sweep_secret_key_at_least_32_chars_long") + .with_require_auth_for(AuthRequirement::ALL) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + AuthMiddleware middleware(config, &manager); + + const size_t before = manager.token_validation_count(); + + AuthRequest anonymous; + anonymous.method = "GET"; + anonymous.path = "/api/v1/areas"; + EXPECT_FALSE(middleware.process(anonymous).allowed); + EXPECT_EQ(manager.token_validation_count(), before) << "a request with no Authorization header reached the verifier"; + + AuthRequest garbage = anonymous; + garbage.authorization_header = "Bearer not-a-token"; + EXPECT_FALSE(middleware.process(garbage).allowed); + EXPECT_EQ(manager.token_validation_count(), before + 1) + << "a bearer that reached process() was not counted, so the counter cannot witness a skipped verify"; + + // And the route question the ordering rule asks, answered by the same object + // the middleware uses. + EXPECT_TRUE(middleware.requires_authentication(anonymous)); + AuthRequest auth_route = anonymous; + auth_route.path = "/api/v1/auth/authorize"; + EXPECT_FALSE(middleware.requires_authentication(auth_route)); +} + +// Revocation has to work on a gateway that never issued the token, or it does +// not work at all under a shared JWT configuration: a peer holds no record of +// anything the aggregator minted, and with the records read as a denylist +// "no record" would make revoke a no-op on exactly the gateway being locked +// down. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, RevokingAForeignTokenRefusesItHere) { + auto issuer = make_manager(3600, 3600); + auto peer = make_manager(3600, 3600); + + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(issued->refresh_token.has_value()); + + ASSERT_TRUE(peer.validate_token(issued->access_token).valid) << "the peer must accept it before it is revoked"; + + EXPECT_TRUE(peer.revoke_refresh_token(issued->refresh_token.value())) + << "the peer declined to revoke a token minted elsewhere"; + EXPECT_FALSE(peer.validate_token(issued->access_token).valid) << "the peer went on accepting a token revoked on it"; + + // Only here. Revocation is per gateway, and the issuer was never told. + EXPECT_TRUE(issuer.validate_token(issued->access_token).valid) + << "revoking on the peer reached across to the issuer, which shares no state with it"; +} + +// The record written for a foreign token expires with the token, so this +// cannot grow past the tokens in flight. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, AForeignRevocationRecordIsSweptWithItsToken) { + auto issuer = make_manager(1, 1); + auto peer = make_manager(1, 1); + + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(issued->refresh_token.has_value()); + ASSERT_TRUE(peer.revoke_refresh_token(issued->refresh_token.value())); + EXPECT_EQ(peer.refresh_token_count(), 1U); + + std::this_thread::sleep_for(std::chrono::milliseconds(4000)); + EXPECT_EQ(peer.cleanup_expired_tokens(), 1U); + EXPECT_EQ(peer.refresh_token_count(), 0U); +} + +// The role a token carries is what the ISSUING gateway granted. Under a shared +// JWT configuration that is a different gateway, and letting the claim decide +// would export one deployment's grants into another: a client this gateway +// lists as viewer would write here because the issuer listed it as admin. +// @verifies REQ_INTEROP_086, REQ_INTEROP_087 +TEST(AuthManagerRoleTest, TheRoleComesFromThisGatewaysClientTable) { + auto admin_side = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("expiry_sweep_secret_key_at_least_32_chars_long") + .with_require_auth_for(AuthRequirement::ALL) + .with_token_expiry(3600) + .with_refresh_token_expiry(3600) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + auto viewer_side = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("expiry_sweep_secret_key_at_least_32_chars_long") + .with_require_auth_for(AuthRequirement::ALL) + .with_token_expiry(3600) + .with_refresh_token_expiry(3600) + .add_client("svc", "svc_secret", UserRole::VIEWER) + .build(); + + AuthManager issuer(admin_side); + AuthManager peer(viewer_side); + + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + EXPECT_EQ(issued->scope, "admin"); + + auto on_issuer = issuer.validate_token(issued->access_token); + ASSERT_TRUE(on_issuer.valid); + EXPECT_EQ(on_issuer.claims->role, UserRole::ADMIN); + + auto on_peer = peer.validate_token(issued->access_token); + ASSERT_TRUE(on_peer.valid) << "the peer must still accept the token; only the role it grants differs"; + EXPECT_EQ(on_peer.claims->role, UserRole::VIEWER) + << "the peer granted the role the token claimed; its own table is what decides here"; +} + +// A refresh-only workload reaches no other code that would clear the store, so +// the refresh path sweeps too. Without it the map grows for the life of a +// process whose clients authorise once and refresh forever. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerTokenLifetimeTest, ARefreshOnlyWorkloadBoundsTheStore) { + // Both expiries at their minimum, so the sweep point is issue + 2 s and the + // wait below clears it by three. Timestamps are whole seconds, so two + // authorisations either side of a second boundary carry expiries a second + // apart; a margin of one would make the later record's fate depend on where + // in a second the test happened to start. + auto manager = make_manager(1, 1); + + auto first = manager.authenticate("svc", "svc_secret"); + ASSERT_TRUE(first.has_value()); + ASSERT_TRUE(first->refresh_token.has_value()); + + // A second login, so there is an older record for the sweep to find. + auto second = manager.authenticate("svc", "svc_secret"); + ASSERT_TRUE(second.has_value()); + ASSERT_TRUE(second->refresh_token.has_value()); + EXPECT_EQ(manager.refresh_token_count(), 2U); + + // Past both refresh expiries and the access lifetime held beyond them. + std::this_thread::sleep_for(std::chrono::milliseconds(5000)); + + // Refreshing is the ONLY call made here. It fails, because the refresh token + // expired too - and the sweep still has to have run, which is the point. + (void)manager.refresh_access_token(first->refresh_token.value()); + + EXPECT_EQ(manager.refresh_token_count(), 0U) + << "a workload that only ever refreshes left expired records in the store"; +} + +// What the predicate and the middleware do together, on a lambda that repeats +// the server's decision order. +// +// This pins two things: that `rate_limit_precedes_validation` selects the rows +// it claims to, and that `process` leaves the verifier untouched on each of +// them. It is a COPY of the order rest_server applies, so it cannot catch that +// file being reordered - the instrument for the server's own order is the +// integration case test_04_an_exhausted_caller_with_a_header_gets_a_bare_429, +// which drives a running gateway. +// @verifies REQ_INTEROP_086 +TEST(RateLimitOrderingTest, TheVerifierIsNotReachedOnTheExhaustedHeaderPath) { + auto manager = make_manager(3600, 3600); + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("expiry_sweep_secret_key_at_least_32_chars_long") + .with_require_auth_for(AuthRequirement::ALL) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + AuthMiddleware middleware(config, &manager); + + RateLimitConfig rl_config; + rl_config.enabled = true; + rl_config.global_requests_per_minute = 3; + rl_config.client_requests_per_minute = 3; + RateLimiter limiter(rl_config); + + // The pre-routing decision, in the order rest_server makes it: meter, then + // let the limiter answer where it may, then authenticate. + const auto serve = [&](const std::string & path, std::optional header) { + AuthRequest request; + request.method = "GET"; + request.path = path; + request.authorization_header = std::move(header); + + auto rl = limiter.check("10.0.0.7", path); + const bool rate_limited = !rl.allowed; + if (rate_limited && request.authorization_header.has_value() && + AuthMiddleware::rate_limit_precedes_validation(rate_limited, true, + middleware.requires_authentication(request))) { + return 429; + } + auto result = middleware.process(request); + if (!result.allowed) { + return result.status_code; + } + return rate_limited ? 429 : 200; + }; + + const std::string protected_path = "/api/v1/areas"; + const std::string public_path = "/api/v1/auth/authorize"; + + // Spend the allowance with requests carrying no credential, so nothing here + // has verified anything yet. + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(serve(protected_path, std::nullopt), 401); + } + const size_t before = manager.token_validation_count(); + ASSERT_EQ(before, 0U) << "an uncredentialed request reached the verifier"; + + // Exhausted, with a header: 429, and the verifier untouched. + EXPECT_EQ(serve(protected_path, std::string("Bearer not-a-token")), 429); + EXPECT_EQ(manager.token_validation_count(), before) + << "the gateway verified a token belonging to a caller it had already refused"; + + // Exhausted, no header: the anonymous 401, still no verification. + EXPECT_EQ(serve(protected_path, std::nullopt), 401); + EXPECT_EQ(manager.token_validation_count(), before); + + // Exhausted, header, PUBLIC route. The limiter has no special word here, so + // process() runs - and on a route needing no credential it returns before it + // looks at the header, so the verifier is still not reached. + EXPECT_EQ(serve(public_path, std::string("Bearer not-a-token")), 429); + EXPECT_EQ(manager.token_validation_count(), before) << "a public route put an unverified header through the verifier"; +} + +// The mirror of the test above: the harness it uses does reach the verifier +// when the allowance is there, so a counter that never moved would not be +// evidence of anything. +// @verifies REQ_INTEROP_086 +TEST(RateLimitOrderingTest, TheSameHarnessReachesTheVerifierWithAllowanceLeft) { + auto manager = make_manager(3600, 3600); + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("expiry_sweep_secret_key_at_least_32_chars_long") + .with_require_auth_for(AuthRequirement::ALL) + .add_client("svc", "svc_secret", UserRole::ADMIN) + .build(); + AuthMiddleware middleware(config, &manager); + + RateLimitConfig rl_config; + rl_config.enabled = true; + rl_config.global_requests_per_minute = 100; + rl_config.client_requests_per_minute = 100; + RateLimiter limiter(rl_config); + + AuthRequest request; + request.method = "GET"; + request.path = "/api/v1/areas"; + request.authorization_header = "Bearer not-a-token"; + + auto rl = limiter.check("10.0.0.8", request.path); + ASSERT_TRUE(rl.allowed); + EXPECT_FALSE(middleware.process(request).allowed); + EXPECT_EQ(manager.token_validation_count(), 1U) << "the harness never reaches the verifier at all"; +} + +// `[""]` is how a ROS 2 YAML file writes an empty string sequence, and both +// shipped profiles use that idiom for auth.clients. A list written that way +// carries one blank entry, means "no routes", and must neither open anything +// nor stop the gateway. +// @verifies REQ_INTEROP_086 +TEST(PublicRouteParsingTest, ABlankEntryIsNeitherARouteNorATypo) { + EXPECT_TRUE(is_blank_public_route_entry("")); + EXPECT_TRUE(is_blank_public_route_entry(" ")); + EXPECT_TRUE(is_blank_public_route_entry("\t")); + EXPECT_FALSE(is_blank_public_route_entry("GET /api/v1/health")); + EXPECT_FALSE(is_blank_public_route_entry("nonsense")); + + EXPECT_TRUE(parse_public_routes({""}).empty()); + EXPECT_TRUE(parse_public_routes({"", " "}).empty()); + + // A blank entry beside a real one leaves the real one standing. + auto mixed = parse_public_routes({"", "GET /api/v1/health"}); + ASSERT_EQ(mixed.size(), 1U); + EXPECT_EQ(mixed[0].method, "GET"); + EXPECT_EQ(mixed[0].path, "/api/v1/health"); +} + +// /auth/revoke takes refresh tokens. An access token's own jti is not a key +// anything reads - validate_token looks up the `refresh_token_id` claim - so +// writing a record under it would store a revocation nothing consults while +// reporting success to the caller who asked for one. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, AnAccessTokenIsNotRevocable) { + auto manager = make_manager(3600, 3600); + auto issued = manager.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + + const size_t before = manager.refresh_token_count(); + EXPECT_FALSE(manager.revoke_refresh_token(issued->access_token)) << "an access token was accepted for revocation"; + EXPECT_EQ(manager.refresh_token_count(), before) << "a record was written for an access token"; + EXPECT_TRUE(manager.validate_token(issued->access_token).valid) + << "the token was reported revoked and went on working, which is the state this refuses to create"; + + // The refresh token it came with is still revocable, so the refusal above is + // about the token TYPE and not about revocation having stopped working. + ASSERT_TRUE(issued->refresh_token.has_value()); + EXPECT_TRUE(manager.revoke_refresh_token(issued->refresh_token.value())); + EXPECT_FALSE(manager.validate_token(issued->access_token).valid); +} + +// A foreign revocation record is held until the token's refresh expiry plus +// the LOCAL access lifetime, so whether it outlives every token the issuer can +// mint from it depends on how the two gateways' access expiries compare. Both +// directions are pinned: where the peer's is no shorter the revocation holds +// for every token; where it is shorter, a token minted late on the issuer +// outlives the record, which is what the documented rule exists to prevent. +// +// Timestamps are whole seconds, so each sleep below leaves at least half a +// second of margin against where in a second the block happened to start. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, AForeignRevocationHoldsOnlyWhereTheExpiriesAgree) { + // Equal configurations: the record outlives the token. + { + auto issuer = make_manager(2, 2); + auto peer = make_manager(2, 2); + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(issued->refresh_token.has_value()); + ASSERT_TRUE(peer.revoke_refresh_token(issued->refresh_token.value())); + EXPECT_FALSE(peer.validate_token(issued->access_token).valid); + } + + // The peer's access expiry is LONGER than the issuer's: the record stands + // past the last moment any token minted from that refresh token can verify. + // The issuer's refresh token expires one second after issue, so nothing it + // mints can be valid two and a half seconds later - and the record is held + // a further three past the refresh expiry. + { + auto issuer = make_manager(1, 1); + auto peer = make_manager(3, 3); + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(issued->refresh_token.has_value()); + ASSERT_TRUE(peer.revoke_refresh_token(issued->refresh_token.value())); + EXPECT_FALSE(peer.validate_token(issued->access_token).valid); + + std::this_thread::sleep_for(std::chrono::milliseconds(2500)); + EXPECT_EQ(peer.cleanup_expired_tokens(), 0U) + << "the peer swept the record while a token the issuer minted could still have been live"; + } + + // The peer's access expiry is SHORTER than the issuer's: the lapse. + // + // The issuer's tokens live five seconds and the peer's one. The refresh + // token is exchanged three and a half seconds after issue, so the access + // token it mints has five seconds from there; the peer's record, sized by + // the refresh expiry (five) plus the peer's own access lifetime (one), is + // gone a second earlier. Between the two the token verifies on the peer + // with the revocation forgotten. + { + auto issuer = make_manager(5, 5); + auto peer = make_manager(1, 1); + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(issued->refresh_token.has_value()); + ASSERT_TRUE(peer.revoke_refresh_token(issued->refresh_token.value())); + + std::this_thread::sleep_for(std::chrono::milliseconds(3500)); + auto late = issuer.refresh_access_token(issued->refresh_token.value()); + ASSERT_TRUE(late.has_value()) << "the issuer refused a refresh inside the refresh token's life: " + << late.error().error_description; + EXPECT_FALSE(peer.validate_token(late->access_token).valid) << "the revocation must hold while the record is up"; + + std::this_thread::sleep_for(std::chrono::milliseconds(3500)); + EXPECT_EQ(peer.cleanup_expired_tokens(), 1U) << "the record was sized by something other than the refresh expiry " + "plus the peer's own access lifetime"; + EXPECT_TRUE(peer.validate_token(late->access_token).valid) + << "the late token was refused after the record was gone, so the lapse the documented rule guards " + "against does not exist and the docs overstate it"; + } +} + +// A revocation reaches a refresh token past its own expiry. +// +// The last access token minted from a refresh token can outlive it by a whole +// access lifetime, and a revoke that refused the expired refresh token would +// leave that access token unrevocable for exactly that long. On the revoke +// path the signature and the issuer are verified and the expiry is not; the +// record is written either way, and the sweep still bounds it. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, AnExpiredRefreshTokenStillRevokesItsAccessTokens) { + auto issuer = make_manager(4, 4); + auto peer = make_manager(4, 4); + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(issued->refresh_token.has_value()); + const std::string refresh = issued->refresh_token.value(); + + // Exchanged inside the refresh token's life, so the access token it mints + // has four seconds from here, which is past the refresh expiry. + std::this_thread::sleep_for(std::chrono::milliseconds(2500)); + auto late = issuer.refresh_access_token(refresh); + ASSERT_TRUE(late.has_value()) << late.error().error_description; + + // Now the refresh token has expired and the access token has not; both are + // asserted, so the revocation below is measured on exactly that state. + std::this_thread::sleep_for(std::chrono::milliseconds(2000)); + ASSERT_FALSE(issuer.refresh_access_token(refresh).has_value()) + << "the refresh token is still live, so this pins nothing"; + ASSERT_TRUE(issuer.validate_token(late->access_token).valid) + << "the access token expired first, so this pins nothing"; + + EXPECT_TRUE(issuer.revoke_refresh_token(refresh)) << "an expired refresh token was refused for revocation"; + auto on_issuer = issuer.validate_token(late->access_token); + EXPECT_FALSE(on_issuer.valid); + EXPECT_NE(on_issuer.error.find("revoked"), std::string::npos) << on_issuer.error; + + // The foreign variant: a peer that never saw the token writes the record. + EXPECT_TRUE(peer.revoke_refresh_token(refresh)) << "an expired foreign refresh token was refused for revocation"; + auto on_peer = peer.validate_token(late->access_token); + EXPECT_FALSE(on_peer.valid); + EXPECT_NE(on_peer.error.find("revoked"), std::string::npos) << on_peer.error; +} + +// A foreign record is held for at most this gateway's own refresh lifetime. +// +// The record is sized by the issuer's refresh expiry, which this gateway does +// not control; an issuer with a refresh lifetime of years would otherwise leave +// records here for years. Under the shared-configuration rule the clamp +// changes nothing, which is what makes the rule safe to state. +// @verifies REQ_INTEROP_086 +TEST(AuthManagerRevocationTest, AForeignRecordIsHeldNoLongerThanThisGatewaysRefreshLifetime) { + auto issuer = make_manager(1, 315360000); + auto peer = make_manager(1, 1); + auto issued = issuer.authenticate("svc", "svc_secret"); + ASSERT_TRUE(issued.has_value()); + ASSERT_TRUE(issued->refresh_token.has_value()); + ASSERT_TRUE(peer.revoke_refresh_token(issued->refresh_token.value())); + EXPECT_FALSE(peer.validate_token(issued->access_token).valid); + + // Past the peer's refresh lifetime and its access lifetime on top. + std::this_thread::sleep_for(std::chrono::milliseconds(3000)); + EXPECT_EQ(peer.cleanup_expired_tokens(), 1U) << "a foreign record outlived this gateway's own refresh lifetime"; +} + +// A blank entry is any whitespace, the same rule parse_public_route trims by: +// a list written `["\n"]` is as blank as one written `[" "]`. +// @verifies REQ_INTEROP_086 +TEST(PublicRouteParsingTest, ABlankEntryIsAnyWhitespace) { + for (const char * blank : {"", " ", "\t", "\n", "\r\n", "\v", "\f", " \n\t "}) { + EXPECT_TRUE(is_blank_public_route_entry(blank)) << "entry " << testing::PrintToString(std::string(blank)); + EXPECT_TRUE(parse_public_routes({blank}).empty()) << "entry " << testing::PrintToString(std::string(blank)); + } + EXPECT_FALSE(is_blank_public_route_entry(" x ")); + EXPECT_FALSE(is_blank_public_route_entry("\nGET /api/v1/health\n")); +} + +// A role name carrying a byte at or above 0x80 is refused like any other +// unknown role, and lower-casing it is defined behaviour. +// @verifies REQ_INTEROP_086 +TEST(AuthConfigRoleTest, ANonAsciiRoleNameIsRefusedAndDoesNotCrash) { + for (const auto & name : {"\xC3\xA4" + "dmin", + "admin\xFF", "\x80", "\xFF\xFE"}) { + EXPECT_THROW((void)string_to_role(name), std::invalid_argument) << "role \"" << name << "\" was accepted"; + } + // The ASCII path still works, so the loop above is not passing because + // everything throws. + EXPECT_EQ(string_to_role("ADMIN"), UserRole::ADMIN); +} + int main(int argc, char ** argv) { testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From 90fab0ecb4708755dc5f4ce70d37ac803e265ef6 Mon Sep 17 00:00:00 2001 From: bburda Date: Sun, 13 Sep 2026 13:55:28 +0200 Subject: [PATCH 2/4] docker: layer the packaged config and close the image through the environment 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 --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 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- from that inspected digest in one invocation. :latest moves on every merge, so :main- is the immutable reference to pin. The multi-arch workflow owns the semver tags and sha-, which name its manifest list. The per-push workflow owns :latest and :main-, 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. --- .../workflows/docker-publish-multiarch.yml | 7 + .github/workflows/docker-publish.yml | 38 ++- Dockerfile | 42 ++- docker/container_params.yaml | 21 ++ docker/entrypoint.sh | 45 ++- docker/gateway_docker_params.yaml | 19 -- scripts/smoke_image_auth_posture.sh | 280 ++++++++++++++++++ 7 files changed, 422 insertions(+), 30 deletions(-) create mode 100644 docker/container_params.yaml delete mode 100644 docker/gateway_docker_params.yaml create mode 100755 scripts/smoke_image_auth_posture.sh diff --git a/.github/workflows/docker-publish-multiarch.yml b/.github/workflows/docker-publish-multiarch.yml index 9e24c3bcc..be2922052 100644 --- a/.github/workflows/docker-publish-multiarch.yml +++ b/.github/workflows/docker-publish-multiarch.yml @@ -166,6 +166,13 @@ jobs: # `latest` belongs to docker-publish.yml, which moves it on # every push to main; without this the action's default # `latest=auto` would have a release take that tag over. + # + # The split of the rest: this workflow owns the semver tags + # and sha-, all of them naming the multi-arch manifest + # list. docker-publish.yml owns `latest` and main-, + # both naming 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 artefacts. flavor: latest=false tags: | # version tag (0.7.0 -> 0.7.0) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a0ec8b1a7..3dfc8b73f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -94,10 +94,40 @@ jobs: python3 src/ros2_medkit_plugins/ros2_medkit_opcua/test/inspect_build_variant.py \ "$RUNNER_TEMP/plugin-from-image.so" --expect read-only - # :latest is created from the inspected digest, so the tag resolves - # to the artifact the step above read, attestation manifest and all. + # What the image serves, checked before it is named. The + # entrypoint's dispatch and the image's config layering are shell + # and Dockerfile, which no colcon test reaches. The digest checked + # here is the one the tag step below points at. + - name: Check the image's auth posture + run: | + scripts/smoke_image_auth_posture.sh \ + ${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}@${{ steps.build.outputs.digest }} + + # Both tags are created from the inspected digest, so either one + # resolves to the artifact the step above read, attestation + # manifest and all. One invocation, so the two tags cannot come to + # name different manifests. + # + # :latest moves on every merge, which leaves a user who pulls it no + # way back to the behaviour they had yesterday - and the defaults + # this image ships are exactly the kind of thing that changes under + # them. main- is the immutable reference to pin. The + # multi-arch workflow publishes the semver tags and fires only on a + # release tag; this one runs on every push to main, where there is + # no version number to use. + # + # The `main-` prefix is what keeps the two workflows apart. This + # image is linux/amd64 only, while the multi-arch workflow pushes a + # two-architecture manifest list under sha-. A release tag is + # normally cut on a commit that is also on main, so a shared tag + # name would be written by both and resolve to whichever job + # finished last. The prefix names the branch this image came from, + # which is also what a reader of the tag wants to know. - name: Tag the inspected digest run: | + set -euo pipefail + image=${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }} docker buildx imagetools create \ - -t ${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}:latest \ - ${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}@${{ steps.build.outputs.digest }} + -t "$image:latest" \ + -t "$image:main-${GITHUB_SHA::7}" \ + "$image@${{ steps.build.outputs.digest }}" diff --git a/Dockerfile b/Dockerfile index ff13846bc..d2e4574e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -164,8 +164,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Copy built workspace from builder (builder uses /root/ws, runtime uses /home/medkit/ws) COPY --from=builder /root/ws/install/ ${COLCON_WS}/install/ -# Default config - can be overridden via volume mount -COPY docker/gateway_docker_params.yaml /etc/ros2_medkit/params.yaml +# Three layers, applied left to right by the entrypoint, each one able to +# override the one before it: +# +# base.yaml the file the package ships, so the image and a source +# install share one posture, so the two cannot drift +# container.yaml the two values a container needs on top of it +# params.yaml the mount point, and the last word among the files +# +# The image's own copy of params.yaml repeats container.yaml, so an unmounted +# container is exactly the second layer. Mounting a file replaces that copy and +# nothing else: base.yaml still supplies every key the mounted file leaves out, +# and the mounted file wins wherever it speaks - server.host and +# refresh_interval_ms included. rclcpp applies the merged node entries in order +# of first appearance, and the entrypoint puts these files before the caller's +# arguments, so a `-p` the caller passes wins over the files; had the image put +# those two keys in front of the files as `-p` arguments, a mounted file could +# set neither. +# +# For the closed profile, point --params-file at +# config/gateway_params.secure.yaml inside the image. +COPY src/ros2_medkit_gateway/config/gateway_params.yaml /etc/ros2_medkit/base.yaml +COPY docker/container_params.yaml /etc/ros2_medkit/container.yaml +COPY docker/container_params.yaml /etc/ros2_medkit/params.yaml # When running via ros2 run (as this container does), plugin .so paths must be # configured explicitly via plugins..path parameters in the params file. @@ -188,4 +209,19 @@ USER medkit EXPOSE 8080 ENTRYPOINT ["/entrypoint.sh"] -CMD ["--ros-args", "--params-file", "/etc/ros2_medkit/params.yaml"] +# Empty, and explicitly so: the base image sets a CMD of its own, and inheriting +# it would send `docker run ` down the entrypoint's "exec a command" +# branch when it should start the gateway. +# +# The three config layers live in the entrypoint, which puts them in front of +# whatever arguments a caller passes. Here they would be part of the CMD, and a +# caller passing arguments replaces the CMD - so `docker run --ros-args +# -p server.port:=9090` would drop all three and bind loopback inside the +# container. +# +# To run the closed profile, point --params-file at the packaged file: +# docker run --ros-args --params-file \ +# /home/medkit/ws/install/ros2_medkit_gateway/share/ros2_medkit_gateway/config/gateway_params.secure.yaml +# That profile enables TLS, so the container also needs a certificate and key +# (server.tls.cert_file / server.tls.key_file) or it refuses to start. +CMD [] diff --git a/docker/container_params.yaml b/docker/container_params.yaml new file mode 100644 index 000000000..806795daf --- /dev/null +++ b/docker/container_params.yaml @@ -0,0 +1,21 @@ +# The two values a container needs that a host install does not. +# +# Bind every interface, because the port is published and the gateway is reached +# through it. Refresh faster, because a container's graph turns over as sibling +# containers come and go. +# +# A file, and placed where the mount point can still beat it. rclcpp applies +# the merged node entries in order of first appearance, and every `-p` joins the +# entry the first `-p` created; the entrypoint passes these layers first and the +# caller's arguments after them. As `-p` arguments these two keys would sit +# above the mount point and a mounted /etc/ros2_medkit/params.yaml could set +# neither. +# +# CORS names no origin here. A published image allowing development origins is a +# setting nobody chose; a deployment that runs the web UI next to the gateway +# names its own origin in the file it mounts. +ros2_medkit_gateway: + ros__parameters: + server: + host: "0.0.0.0" + refresh_interval_ms: 2000 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ef82bb433..8278b9e37 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -24,13 +24,50 @@ source "${COLCON_WS}/install/setup.bash" # Default to FastDDS (can be overridden via RMW_IMPLEMENTATION env var) export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}" +# Closing the image is opt-in, through the environment. +# +# The packaged params file is config/gateway_params.yaml, the same one a source +# install gets, and it leaves authentication off. So `docker run ` is the +# gateway a reader of the quickstart expects, and the web UI - which sends no +# Authorization header - talks to it. +# +# MEDKIT_JWT_SECRET, MEDKIT_CLIENTS and MEDKIT_AUTH_DISABLED are passed through +# and nothing here interprets them. The gateway node reads them itself when it +# reads its parameters, so the rule holds on every path out of this script - +# the `ros2 run` below, `docker run ros2 launch ... bringup.launch.py`, +# and `docker run bash` followed by anything. A copy of the rule here +# would cover only the first, and the exec paths would quietly run on different +# terms. +# +# Exported, which is the step that matters: a variable passed with `docker run +# -e` is already in this shell's environment, and `export` is what carries it +# into the environment of what this script execs. +export MEDKIT_JWT_SECRET MEDKIT_CLIENTS MEDKIT_AUTH_DISABLED + # Dispatch on the first argument: -# - empty, or starts with "-" (the default CMD "--ros-args --params-file ..." -# or an override like --ros-args -p server.port:=9090): run the gateway node -# directly, so `docker run ` and arg-only overrides keep working. +# - empty, or starts with "-" (an override like --ros-args -p +# server.port:=9090): run the gateway node with the image's three config +# layers first and the caller's arguments after them. # - a full command (e.g. `ros2 launch ros2_medkit_gateway bringup.launch.py` # or `bash`): exec it as-is, so the image can launch the whole bringup stack. +# +# The three --params-file arguments belong here and not in the Dockerfile CMD, +# because a caller passing arguments REPLACES the CMD. As part of the CMD they +# would be dropped by any `docker run --ros-args ...`, leaving a gateway +# on the packaged loopback bind and reachable from nothing outside the +# container. Supplied here, an override changes the one key it names. +# +# The caller's arguments follow, and their own --ros-args opens a second group. +# rclcpp applies the merged node entries in order of first appearance, and the +# caller's `-p` is the first one on this command line, so its entry comes after +# these files and wins over them - which is what lets an override change the +# key it names while the layers supply everything else. if [ -z "$1" ] || [ "${1#-}" != "$1" ]; then - exec ros2 run ros2_medkit_gateway gateway_node "$@" + exec ros2 run ros2_medkit_gateway gateway_node \ + --ros-args \ + --params-file /etc/ros2_medkit/base.yaml \ + --params-file /etc/ros2_medkit/container.yaml \ + --params-file /etc/ros2_medkit/params.yaml \ + "$@" fi exec "$@" diff --git a/docker/gateway_docker_params.yaml b/docker/gateway_docker_params.yaml deleted file mode 100644 index 1d4049a57..000000000 --- a/docker/gateway_docker_params.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Default gateway configuration for Docker deployment. -# Override by mounting your own file at /etc/ros2_medkit/params.yaml -# or passing --ros-args --params-file to the container. -ros2_medkit_gateway: - ros__parameters: - server: - host: "0.0.0.0" - port: 8080 - refresh_interval_ms: 2000 - # The web UI runs as a separate origin (its own host/port), so the - # documented "run the web UI next to the gateway" path needs CORS. Without - # it the browser gets "Failed to fetch". These are the default web UI - # origins; a wildcard is deliberately NOT used - with auth disabled and - # write methods enabled it would let any site drive cross-origin writes. - # Add your own UI origin(s) here, and enable JWT auth for production. - cors: - allowed_origins: - - "http://localhost:3000" - - "http://localhost:5173" diff --git a/scripts/smoke_image_auth_posture.sh b/scripts/smoke_image_auth_posture.sh new file mode 100755 index 000000000..b8625db26 --- /dev/null +++ b/scripts/smoke_image_auth_posture.sh @@ -0,0 +1,280 @@ +#!/bin/bash +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Check what a BUILT IMAGE actually serves. +# +# Usage: scripts/smoke_image_auth_posture.sh +# +# WHAT THIS DRIVES, and what it does not. The rule the gateway applies to the +# three MEDKIT_* variables is covered by colcon tests, which run the node +# directly. This drives the image around it: the entrypoint exporting the +# variables and prepending the three config layers, the layers themselves, and +# the packaged config they combine with. A colcon test reaches none of that. +# +# Two instruments. The posture cases read an HTTP status code from the +# container's bridge address. The layering cases read the gateway's own startup +# line out of `docker logs`, which carries the bind address, the port and the +# refresh interval in force: +# +# Configuration: REST API at 0.0.0.0:8080, backstop refresh interval: 2000ms +# +# 1. no environment -> anonymous GET /areas answers 200 +# 2. MEDKIT_JWT_SECRET set -> anonymous GET /areas answers 401, +# and MEDKIT_CLIENTS obtains a working token +# 3. a mounted params file with auth off, plus MEDKIT_JWT_SECRET +# -> anonymous GET /areas answers 401 +# 4. a file mounted at /etc/ros2_medkit/params.yaml changing +# refresh_interval_ms -> the running gateway reports that value +# 5. an arg-only override (-p server.port:=9090) +# -> the gateway answers on 9090 and still binds +# every interface +# +# Case 2 carries the token check because a container closed to its operator as +# well as to everyone else would pass the 401 on its own, and that is the +# failure an image ships silently: MEDKIT_CLIENTS never reaching the gateway +# looks identical from outside until somebody tries to log in. +# +# Case 3 is the one that says the environment beats the file. Without it, a +# gateway that consulted the file and deferred to it would pass cases 1 and 2 +# while leaving a container its operator believes closed wide open. +# +# Cases 4 and 5 are the layering: the mounted file has to win, and an override +# has to change the key it names WITHOUT taking the layers with it. +# +# The gateway is reached at the container's bridge address, never at a +# published port on localhost: where the Docker daemon is not in this shell's +# network namespace, -p publishes somewhere this script cannot see and every +# request times out while the container is perfectly healthy. + +set -euo pipefail + +IMAGE="${1:?usage: $0 }" +SECRET="smoke_image_posture_secret_of_at_least_32_chars" +CLIENT_ID="smoke" +CLIENT_SECRET="smoke_client_secret" +CLIENTS="${CLIENT_ID}:${CLIENT_SECRET}:admin" +WORKDIR="$(mktemp -d)" + +# Fixed, not collected as the cases run: each case calls posture_of inside a +# command substitution to capture the status code, and a subshell cannot append +# to the parent's array - so a list built that way is empty when the trap fires +# and every container survives the run. +CONTAINERS=(medkit-smoke-open medkit-smoke-closed medkit-smoke-override + medkit-smoke-mount medkit-smoke-argonly) + +cleanup() { + for name in "${CONTAINERS[@]}"; do + docker rm -f "$name" >/dev/null 2>&1 || true + done + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +# The status code an anonymous GET /areas gets from a container, once it is +# answering at all. Prints the code on stdout; anything else goes to stderr so +# the caller can capture the one value. +# +# Called as: posture_of -- +# The container args replace the image CMD, which is how a case points the +# gateway at its own params file. +# +# COPY_FILE, when set, is copied into the container at COPY_DEST before it +# starts. A bind mount would be simpler and is wrong here: the daemon resolves +# a -v source path on ITS filesystem, so where the daemon is not in this +# shell's namespace the container gets nothing and the gateway dies parsing an +# absent file. `docker cp` streams the bytes through the API instead, which +# works either way. +posture_of() { + local name=$1 + shift + local flags=() + while [ $# -gt 0 ] && [ "$1" != "--" ]; do + flags+=("$1") + shift + done + if [ $# -gt 0 ]; then + shift # drop the -- + fi + docker rm -f "$name" >/dev/null 2>&1 || true + docker create --name "$name" "${flags[@]}" "$IMAGE" "$@" >/dev/null + if [ -n "${COPY_FILE:-}" ]; then + docker cp "$COPY_FILE" "$name:${COPY_DEST:?COPY_DEST required with COPY_FILE}" + fi + docker start "$name" >/dev/null + + local ip + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$name") + if [ -z "$ip" ]; then + echo "$name: container has no bridge address" >&2 + docker logs "$name" >&2 2>&1 || true + echo "000" + return 0 + fi + + # curl prints 000 itself when it cannot connect, and exits non-zero; the + # `|| true` keeps that exit from ending the script and leaves the 000 for + # the loop to retry on. + # ARG_PORT lets a case that moved the port probe the port it moved it to. + local port="${ARG_PORT:-8080}" + local code="000" + for _ in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://$ip:$port/api/v1/areas") || true + [ -n "$code" ] && [ "$code" != "000" ] && break + sleep 2 + done + if [ "$code" = "000" ]; then + echo "$name: never answered on http://$ip:$port" >&2 + docker logs "$name" >&2 2>&1 || true + fi + # Printed either way, 000 included. Returning non-zero here would abort the + # whole script under `set -e` before `expect` could name the case, so a + # container that never answered would end the run with no diagnostic at all. + echo "$code" +} + +# The label names the case AND the request, because not every case below is an +# anonymous GET /areas. +# Waits for the gateway's own startup line in `docker logs` and returns it. +# +# The startup line is the read-back instrument: the gateway prints it before it +# starts listening, so it is there once posture_of has had an answer, and it +# carries both values these cases are about: +# +# Configuration: REST API at 0.0.0.0:8080, backstop refresh interval: 2000ms +startup_line() { + local name=$1 + local line="" + for _ in $(seq 1 60); do + line=$(docker logs "$name" 2>&1 | grep -m1 'Configuration: REST API at' || true) + [ -n "$line" ] && break + sleep 2 + done + echo "$line" +} + +# Reports whether a startup line contains an expected fragment. +expect_in_startup() { + local label=$1 want=$2 line=$3 + if [ "${line#*"$want"}" != "$line" ]; then + echo "ok $label -> $want" + else + echo "FAIL $label -> ${line:-}, expected to contain $want" >&2 + return 1 + fi +} + +expect() { + local label=$1 want=$2 got=$3 + if [ "$got" = "$want" ]; then + echo "ok $label -> $got" + else + echo "FAIL $label -> $got, expected $want" >&2 + return 1 + fi +} + +# A params file that turns authentication OFF and names a secret of its own, +# which is exactly the file an entrypoint must not defer to when the +# environment says closed. +cat > "$WORKDIR/auth-off.yaml" <<'YAML' +ros2_medkit_gateway: + ros__parameters: + auth: + enabled: false + require_auth_for: "write" + jwt_secret: "a_file_supplied_secret_of_at_least_32_characters" +YAML +# Readable and traversable by the image's non-root user. +chmod 755 "$WORKDIR" +chmod a+r "$WORKDIR/auth-off.yaml" + +failures=0 + +open_code=$(posture_of medkit-smoke-open --) +expect "default, no environment: anonymous GET /areas" 200 "$open_code" \ + || failures=$((failures + 1)) + +closed_code=$(posture_of medkit-smoke-closed \ + -e MEDKIT_JWT_SECRET="$SECRET" -e MEDKIT_CLIENTS="$CLIENTS" --) +expect "MEDKIT_JWT_SECRET set: anonymous GET /areas" 401 "$closed_code" \ + || failures=$((failures + 1)) + +# The credential in MEDKIT_CLIENTS has to reach the gateway, or the container is +# closed to its operator as well as to everyone else - which looks identical +# from outside until somebody tries to log in. Run against the container the +# case above just measured, so the token is exchanged against that posture. +closed_ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ + medkit-smoke-closed) +token=$(curl -s -X POST "http://$closed_ip:8080/api/v1/auth/authorize" \ + -H 'Content-Type: application/json' \ + -d "{\"grant_type\":\"client_credentials\",\"client_id\":\"$CLIENT_ID\",\"client_secret\":\"$CLIENT_SECRET\"}" \ + | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +if [ -z "$token" ]; then + echo "FAIL MEDKIT_CLIENTS: POST /auth/authorize issued no token" >&2 + docker logs medkit-smoke-closed >&2 2>&1 || true + failures=$((failures + 1)) +else + authed_code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $token" "http://$closed_ip:8080/api/v1/areas") + expect "MEDKIT_CLIENTS credential: GET /areas with its token" 200 "$authed_code" \ + || failures=$((failures + 1)) +fi + +# Arguments here are passed BEHIND the image's three config layers, which the +# entrypoint supplies, so server.host stays 0.0.0.0 without being repeated - +# and the case exercises that layering as well as the precedence it is about. +override_code=$(COPY_FILE="$WORKDIR/auth-off.yaml" COPY_DEST=/tmp/auth-off.yaml \ + posture_of medkit-smoke-override \ + -e MEDKIT_JWT_SECRET="$SECRET" -e MEDKIT_CLIENTS="$CLIENTS" \ + -- --ros-args --params-file /tmp/auth-off.yaml) +expect "environment over a params file with auth off: anonymous GET /areas" 401 \ + "$override_code" || failures=$((failures + 1)) + +# A mounted file at the documented mount point has to beat the image's own +# layers. refresh_interval_ms is the probe: the packaged config says 30000, the +# image's container layer says 2000, and a mount saying 4321 must win. Read back +# from the running gateway, because the file proves only what was written. +cat > "$WORKDIR/mounted.yaml" <<'YAML' +ros2_medkit_gateway: + ros__parameters: + refresh_interval_ms: 4321 +YAML +chmod a+r "$WORKDIR/mounted.yaml" + +mount_code=$(COPY_FILE="$WORKDIR/mounted.yaml" COPY_DEST=/etc/ros2_medkit/params.yaml \ + posture_of medkit-smoke-mount --) +expect "mounted params file: anonymous GET /areas" 200 "$mount_code" \ + || failures=$((failures + 1)) + +expect_in_startup "mounted params file: backstop refresh interval" \ + "backstop refresh interval: 4321ms" "$(startup_line medkit-smoke-mount)" \ + || failures=$((failures + 1)) + +# An arg-only override replaces the CMD. The three config layers live in the +# entrypoint precisely so that does not drop them: the port changes and the +# container still binds every interface, which is what makes it reachable. +argonly_code=$(ARG_PORT=9090 posture_of medkit-smoke-argonly -- --ros-args -p server.port:=9090) +expect "arg-only override: anonymous GET /areas on 9090" 200 "$argonly_code" \ + || failures=$((failures + 1)) + +expect_in_startup "arg-only override: bind address and port" \ + "REST API at 0.0.0.0:9090" "$(startup_line medkit-smoke-argonly)" \ + || failures=$((failures + 1)) + +if [ "$failures" -ne 0 ]; then + echo "$failures image posture case(s) failed" >&2 + exit 1 +fi +echo "image posture: every case as documented" From 8a0e58c997601e44f8e89a5a1983d9b35be307b8 Mon Sep 17 00:00:00 2001 From: bburda Date: Sun, 13 Sep 2026 13:55:53 +0200 Subject: [PATCH 3/4] integration_tests: pin both profiles, the auth environment and the limiter 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. --- .../CMakeLists.txt | 3 +- src/ros2_medkit_integration_tests/package.xml | 1 + .../gateway_test_case.py | 12 +- .../ros2_medkit_test_utils/launch_helpers.py | 10 +- .../features/test_closed_by_default.test.py | 959 +++++++++++++++++ .../features/test_env_auth_contract.test.py | 975 ++++++++++++++++++ .../test_env_closes_the_gateway.test.py | 161 +++ .../test_forward_auth_across_gateways.test.py | 549 ++++++++++ .../test_open_default_profile.test.py | 153 +++ .../features/test_openapi_contract.test.py | 10 + .../test/features/test_secure_profile.test.py | 237 +++++ .../features/test_tls_protocol_floor.test.py | 334 ++++++ 12 files changed, 3399 insertions(+), 5 deletions(-) create mode 100644 src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py create mode 100644 src/ros2_medkit_integration_tests/test/features/test_env_auth_contract.test.py create mode 100644 src/ros2_medkit_integration_tests/test/features/test_env_closes_the_gateway.test.py create mode 100644 src/ros2_medkit_integration_tests/test/features/test_forward_auth_across_gateways.test.py create mode 100644 src/ros2_medkit_integration_tests/test/features/test_open_default_profile.test.py create mode 100644 src/ros2_medkit_integration_tests/test/features/test_secure_profile.test.py create mode 100644 src/ros2_medkit_integration_tests/test/features/test_tls_protocol_floor.test.py diff --git a/src/ros2_medkit_integration_tests/CMakeLists.txt b/src/ros2_medkit_integration_tests/CMakeLists.txt index dab635448..765abdf2e 100644 --- a/src/ros2_medkit_integration_tests/CMakeLists.txt +++ b/src/ros2_medkit_integration_tests/CMakeLists.txt @@ -275,7 +275,8 @@ if(BUILD_TESTING) set(_TWO_GATEWAY_TESTS test_peer_recovery test_triggers_restore_before_discovery - test_aggregator_fault_stream) + test_aggregator_fault_stream + test_forward_auth_across_gateways) set(_TWO_GATEWAY_DOMAINS 2) # Tests whose polling budgets do not fit the glob's default timeout. Keyed by diff --git a/src/ros2_medkit_integration_tests/package.xml b/src/ros2_medkit_integration_tests/package.xml index 3ae4e8ae0..58a2f60fe 100644 --- a/src/ros2_medkit_integration_tests/package.xml +++ b/src/ros2_medkit_integration_tests/package.xml @@ -35,6 +35,7 @@ ament_index_python python3-requests python3-jsonschema + python3-yaml ament_cmake_flake8 ros2_medkit_gateway ros2_medkit_fault_manager diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py index 22b9cb57c..5b8fcce73 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py @@ -93,7 +93,15 @@ def setUpClass(cls): @classmethod def _wait_for_gateway_health(cls): - """Poll GET /health until the gateway responds with 200. + """Poll GET /health until the gateway answers at all. + + A refusal counts as up. What this waits for is a process that is + listening and speaking HTTP, and a 401 proves both: the request was + received, routed and decided on. Requiring 200 would instead make this + wait for a specific auth configuration - against a gateway running + ``require_auth_for: all`` with an empty ``auth.public_routes`` that 200 + never arrives, so a test class using this helper would time out against + a perfectly healthy gateway. Uses ``time.monotonic()`` for a reliable, monotonic clock. @@ -108,7 +116,7 @@ def _wait_for_gateway_health(cls): while time.monotonic() < deadline: try: response = requests.get(f'{cls.BASE_URL}/health', timeout=2) - if response.status_code == 200: + if response.status_code in (200, 401, 403): return except requests.exceptions.RequestException: pass diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py index f36a329de..199d9c20c 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py @@ -102,7 +102,7 @@ def create_gateway_node(*, port=DEFAULT_PORT, name='ros2_medkit_gateway', extra_params=None, coverage=True, extra_env=None, - respawn=False, respawn_delay=1.0): + respawn=False, respawn_delay=1.0, params_file=None): """Create a ``gateway_node`` launch action with standard config. Parameters @@ -132,6 +132,11 @@ def create_gateway_node(*, port=DEFAULT_PORT, name='ros2_medkit_gateway', and the DDS participant are released before the replacement binds them, which also gives a test a window in which the port is provably down - the only way to tell "restarted" from "never died". + params_file : str or None + Path to a YAML params file loaded BEFORE the inline parameters, so a + test can launch a shipped profile - ``config/gateway_params.yaml`` or + ``config/gateway_params.secure.yaml`` - and still override the port and + the credentials a committed file cannot carry. Returns ------- @@ -142,6 +147,7 @@ def create_gateway_node(*, port=DEFAULT_PORT, name='ros2_medkit_gateway', params = {'refresh_interval_ms': 1000, 'server.port': port} if extra_params: params.update(extra_params) + file_then_inline = ([params_file] if params_file else []) + [params] env = dict(get_coverage_env() if coverage else {}) if extra_env: @@ -152,7 +158,7 @@ def create_gateway_node(*, port=DEFAULT_PORT, name='ros2_medkit_gateway', executable='gateway_node', name=name, output='screen', - parameters=[params], + parameters=file_then_inline, additional_env=env, respawn=respawn, respawn_delay=respawn_delay, diff --git a/src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py b/src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py new file mode 100644 index 000000000..a3b9a4670 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_closed_by_default.test.py @@ -0,0 +1,959 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Every route refuses an uncredentialed request under the secure profile. + +The gateway's own half of the closed-door acceptance, run against +`config/gateway_params.secure.yaml` - the profile a deployment uses to close a +gateway. It does not check configuration values: it asks the RUNNING gateway +for its route table and then probes every route in it. A test that asserted +`require_auth_for == "all"` would keep passing the day a route is registered +outside the policy, which is the failure this is here to catch. + +The route table comes from RouteRegistry via `GET /api/v1/`, so a route added +next year is swept the day it is registered, with nothing here to update. + +Two exemptions, and both are named with their reason in EXEMPT below. + +@verifies REQ_INTEROP_086, REQ_INTEROP_087 +""" + +import os +import unittest + +from ament_index_python.packages import get_package_share_directory +import launch +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase +from ros2_medkit_test_utils.launch_helpers import create_gateway_node + +CLOSED_PORT = get_test_port() +CORS_PORT = get_test_port(1) +CLOSED_BASE_URL = f'http://127.0.0.1:{CLOSED_PORT}{API_BASE_PATH}' +CLOSED_ROOT = f'http://127.0.0.1:{CLOSED_PORT}' +CORS_BASE_URL = f'http://127.0.0.1:{CORS_PORT}{API_BASE_PATH}' +RL_PORT = get_test_port(2) +RL_BASE_URL = f'http://127.0.0.1:{RL_PORT}{API_BASE_PATH}' +PUBLIC_PORT = get_test_port(3) +PUBLIC_BASE_URL = f'http://127.0.0.1:{PUBLIC_PORT}{API_BASE_PATH}' +WRITE_PORT = get_test_port(4) +WRITE_BASE_URL = f'http://127.0.0.1:{WRITE_PORT}{API_BASE_PATH}' +ALLOWED_ORIGIN = 'https://ui.example' + +SECURE_PARAMS = os.path.join( + get_package_share_directory('ros2_medkit_gateway'), + 'config', 'gateway_params.secure.yaml' +) + +# At least 32 characters, or the gateway refuses to start under HS256. +JWT_SECRET = 'closed_by_default_integration_secret_key_0123456789' +CLIENT_ID = 'diagbox' +CLIENT_SECRET = 'diagbox_client_secret' + +# A path parameter is filled with an id that exists on no gateway. A route that +# refuses for a nonexistent id refuses for a real one, and a probe that turns +# out to reach an OPEN route cannot mutate anything real. +PROBE_ID = 'closed-by-default-probe' + + +@pytest.mark.launch_test +def generate_test_description(): + """Gateway loaded from the secure profile, the way a deployment closes one.""" + gateway_node = create_gateway_node( + port=CLOSED_PORT, + params_file=SECURE_PARAMS, + extra_params={ + 'server.host': '127.0.0.1', + # TLS is a deployment artefact, not the posture under test; the + # route sweep below is about who may reach a route, not about how + # the bytes travel. test_tls_protocol_floor covers the transport. + 'server.tls.enabled': False, + # Two settings of that profile are turned back off here, and each + # would otherwise make the sweep prove less than it claims. + # + # Rate limiting: the profile allows 120 requests per client per + # minute and this sweep sends two per route, so most of it would + # answer 429 where 401 is the point - a refusal for the wrong reason. + # rl_gateway below is the gateway that exists to test the + # limiter's interaction with authentication. + 'rate_limiting.enabled': False, + # Docs: the profile turns the /docs routes off to reduce the + # surface. They are registered routes and this sweep is about + # every registered route, so leaving them off would quietly + # shrink what it covers. + 'docs.enabled': True, + # The secret and the client cannot come from a committed file - a + # committed secret is a secret every deployment shares - so they + # are supplied here the way a deployment supplies them. + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:admin'], + }, + ) + + cors_gateway = create_gateway_node( + port=CORS_PORT, + name='gateway_with_cors', + extra_params={ + 'server.host': '127.0.0.1', + 'auth.enabled': True, + 'auth.require_auth_for': 'all', + 'auth.issuer': 'ros2_medkit_gateway', + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:admin'], + # CORS on, which is what puts a preflight through the pre-routing handler. + 'cors.allowed_origins': [ALLOWED_ORIGIN], + }, + ) + + # Rate limiting on, and tight, so the limiter can actually be exhausted + # inside a test. The ordering between the limiter and authentication is + # only observable against a gateway in this state. + rl_gateway = create_gateway_node( + port=RL_PORT, + name='gateway_with_rate_limit', + extra_params={ + 'server.host': '127.0.0.1', + 'auth.enabled': True, + 'auth.require_auth_for': 'all', + 'auth.issuer': 'ros2_medkit_gateway', + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:admin'], + # Small enough that a test can exhaust it, with room for the + # credentialed half of the case below to obtain a token and make a + # request before the allowance is gone. + 'rate_limiting.enabled': True, + 'rate_limiting.global_requests_per_minute': 5, + 'rate_limiting.client_requests_per_minute': 5, + }, + ) + + # The opt-in half. `auth.public_routes` is empty on every gateway above, so + # without this one nothing here would exercise the knob an operator uses to + # take a route outside authentication, and "empty by default" would be + # indistinguishable from "the setting does nothing". + public_route_gateway = create_gateway_node( + port=PUBLIC_PORT, + name='gateway_with_public_route', + extra_params={ + 'server.host': '127.0.0.1', + 'auth.enabled': True, + 'auth.require_auth_for': 'all', + 'auth.issuer': 'ros2_medkit_gateway', + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:admin'], + 'auth.public_routes': ['GET /api/v1/health'], + }, + ) + + # Authentication on, but at the OTHER requirement level. Every GET is open + # here because reads are open, and no route was singled out - which is a + # different statement from "an operator opened this one", and the two must + # not be confused by anything that decides what to disclose. + write_mode_gateway = create_gateway_node( + port=WRITE_PORT, + name='gateway_write_mode', + extra_params={ + 'server.host': '127.0.0.1', + 'auth.enabled': True, + 'auth.require_auth_for': 'write', + 'auth.issuer': 'ros2_medkit_gateway', + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:admin'], + }, + ) + + return launch.LaunchDescription([ + gateway_node, + cors_gateway, + rl_gateway, + public_route_gateway, + write_mode_gateway, + launch_testing.actions.ReadyToTest(), + ]), {'gateway_node': gateway_node, 'cors_gateway': cors_gateway, + 'rl_gateway': rl_gateway, 'public_route_gateway': public_route_gateway, + 'write_mode_gateway': write_mode_gateway} + + +def _is_exempt(method, path): + """Routes that are deliberately reachable without a credential. + + /auth/* alone, and only because authentication cannot bootstrap through a + door that already demands the credential it exists to hand out. + + Health is NOT here. `auth.public_routes` is empty as shipped, so a probe + that wants an uncredentialed answer is a decision an operator makes and + writes down; TestConfiguredPublicRoute below covers that path. + """ + del method # the one exemption is path-shaped: every method under /auth/ + return path.startswith(f'{API_BASE_PATH}/auth/') + + +class TestClosedByDefault(GatewayTestCase): + """The gateway refuses every route it serves, bar the named exemptions.""" + + BASE_URL = CLOSED_BASE_URL + + @classmethod + def setUpClass(cls): + super().setUpClass() + resp = requests.post( + f'{CLOSED_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=10, + ) + assert resp.status_code == 200, f'could not obtain a token: {resp.status_code} {resp.text}' + cls.token = resp.json()['access_token'] + cls.auth = {'Authorization': f'Bearer {cls.token}'} + + # The route table, read from the gateway itself. A hardened gateway + # does not list its routes anonymously, so this read authenticates. + root = requests.get(f'{CLOSED_BASE_URL}/', headers=cls.auth, timeout=10) + assert root.status_code == 200, f'route table unreadable: {root.status_code}' + cls.endpoints = root.json().get('endpoints', []) + assert cls.endpoints, 'gateway reported no endpoints - nothing would be proven' + + @staticmethod + def _fill(path): + out, depth = [], 0 + for ch in path: + if ch == '{': + depth += 1 + if depth == 1: + out.append(PROBE_ID) + elif ch == '}': + depth -= 1 + elif depth == 0: + out.append(ch) + return ''.join(out) + + def test_01_route_table_is_substantial(self): + """A sweep over three routes would prove almost nothing.""" + self.assertGreater( + len(self.endpoints), 50, + f'expected the full gateway surface, got {len(self.endpoints)} routes' + ) + + def test_02_no_route_answers_without_a_credential(self): + """Sweep EVERY registered route. This is the acceptance.""" + answered = [] + for entry in self.endpoints: + method, _, raw = entry.partition(' ') + if not raw: + continue + if _is_exempt(method, raw): + continue + path = self._fill(raw) + # A write method needs a body: without Content-Length the server + # waits for one that never arrives and the probe times out with no + # status, measuring nothing at all. + kwargs = {'timeout': 15} + if method in ('POST', 'PUT', 'PATCH'): + kwargs['json'] = {} + try: + resp = requests.request(method, f'{CLOSED_ROOT}{path}', **kwargs) + except requests.RequestException as exc: + answered.append(f'{method} {path} -> transport error {exc}') + continue + # 401/403 only. A 404 is an ANSWER: the gateway parsed the request + # and told an anonymous caller what does not exist here. + if resp.status_code not in (401, 403): + answered.append(f'{method} {path} -> {resp.status_code}') + + self.assertEqual( + [], answered, + 'these routes answered an uncredentialed request:\n ' + + '\n '.join(answered) + ) + + def test_03_a_wrong_credential_is_refused_everywhere(self): + """A token this gateway never issued gets no further than none at all.""" + bad = {'Authorization': 'Bearer not.a.real.token'} + answered = [] + for entry in self.endpoints: + method, _, raw = entry.partition(' ') + if not raw or _is_exempt(method, raw): + continue + path = self._fill(raw) + kwargs = {'timeout': 15, 'headers': bad} + if method in ('POST', 'PUT', 'PATCH'): + kwargs['json'] = {} + try: + resp = requests.request(method, f'{CLOSED_ROOT}{path}', **kwargs) + except requests.RequestException as exc: + answered.append(f'{method} {path} -> transport error {exc}') + continue + if resp.status_code not in (401, 403): + answered.append(f'{method} {path} -> {resp.status_code}') + + self.assertEqual( + [], answered, + 'these routes accepted a forged credential:\n ' + '\n '.join(answered) + ) + + def test_04_reads_are_refused_not_just_writes(self): + """The require_auth_for="write" hole, pinned directly. + + Under "write" every one of these answers 200 to an anonymous caller, + and they are the disclosure: the entity tree names the machines. + """ + for path in ('/', '/areas', '/components', '/apps', '/functions', '/version-info'): + with self.subTest(path=path): + resp = requests.get(f'{CLOSED_BASE_URL}{path}', timeout=15) + self.assertIn(resp.status_code, (401, 403)) + + def test_05_health_refuses_like_everything_else(self): + """Health is not special. It is closed until somebody opens it. + + The route a hardening change is most tempted to leave open, pinned so + the temptation shows up as a red test. `auth.public_routes` is the way + to open it, and TestConfiguredPublicRoute holds that end. + """ + resp = requests.get(f'{CLOSED_BASE_URL}/health', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + f'GET /health answered {resp.status_code} with no credential and an ' + 'empty auth.public_routes' + ) + + def test_06_the_full_health_document_names_entities(self): + """Why the anonymous body has to be cut down when a route is opened. + + With a credential the same route returns discovery state and entity + cache counts. That is a legitimate operator surface, and it is exactly + what an anonymous caller must not receive - so if this ever stops being + true, the narrowing in TestConfiguredPublicRoute has become pointless + and should be revisited before it becomes dead weight. + """ + body = requests.get( + f'{CLOSED_BASE_URL}/health', headers=self.auth, timeout=15 + ).json() + self.assertIn('discovery', body) + self.assertIn('x-medkit-entity-cache', body) + self.assertNotIn( + 'x-medkit-reduced', body, + 'an authenticated caller was served the cut-down body' + ) + + def test_07_a_valid_credential_gets_through(self): + """Otherwise the sweeps above would pass on a gateway that serves nobody.""" + resp = requests.get(f'{CLOSED_BASE_URL}/areas', headers=self.auth, timeout=15) + self.assertEqual(resp.status_code, 200) + + +class TestConfiguredPublicRoute(GatewayTestCase): + """`auth.public_routes` opens exactly what it names, and nothing near it. + + The gateway under this class runs `require_auth_for: all` with one entry, + `GET /api/v1/health`. Everything here is about the edge of that entry: a + setting that opened the route it names AND its neighbours would pass a test + that only checked the route it names. + """ + + BASE_URL = PUBLIC_BASE_URL + + @classmethod + def setUpClass(cls): + super().setUpClass() + token = requests.post( + f'{PUBLIC_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=15, + ).json()['access_token'] + cls.auth = {'Authorization': f'Bearer {token}'} + + def test_01_the_named_route_answers_without_a_credential(self): + """The knob does something. Without this the rest proves only refusal.""" + resp = requests.get(f'{PUBLIC_BASE_URL}/health', timeout=15) + self.assertEqual( + resp.status_code, 200, + 'auth.public_routes named GET /api/v1/health and it still refused' + ) + + def test_02_the_route_next_door_is_untouched(self): + """An entry opens one route, not the surface around it. + + The failure this catches is a prefix or wildcard match creeping into + the comparison: `/health` opening `/healthz`, or worse, one entry + opening every GET. + """ + for path in ('/', '/areas', '/components', '/apps', '/version-info'): + with self.subTest(path=path): + resp = requests.get(f'{PUBLIC_BASE_URL}{path}', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + f'{path} answered {resp.status_code} on a gateway whose only ' + 'public route is GET /api/v1/health' + ) + + def test_03_the_method_is_part_of_the_entry(self): + """An entry names a method, and the method is part of the match. + + cpp-httplib dispatches HEAD into the GET handler table, so a comparison + that dropped the method would hand the status document to an anonymous + HEAD. The write methods have no handler here and answer the same either + way, so HEAD is the one that can show the difference. + """ + head = requests.head(f'{PUBLIC_BASE_URL}/health', timeout=15) + self.assertIn( + head.status_code, (401, 403), + f'HEAD /health answered {head.status_code} for an entry that named GET' + ) + + def test_04_the_anonymous_body_is_liveness_and_says_so(self): + """Opening the route must not publish the entity inventory. + + An allowlist, not a denylist: listing the fields known to leak today + would pass the day a new section is added, and a probe needs no more + than "am I alive". + """ + body = requests.get(f'{PUBLIC_BASE_URL}/health', timeout=15).json() + self.assertEqual( + set(body), + {'status', 'timestamp', 'warnings', 'warning_schema_version', + 'x-medkit-reduced'}, + f'an anonymous /health returned more than liveness: {body}' + ) + self.assertEqual(body['status'], 'healthy') + # The array is the leak vector: a linking warning reads like + # "App 'engine_ecu' cannot bind to '/nav/controller'", naming an entity + # and a ROS node FQN. + self.assertEqual(body['warnings'], []) + # And the empty array must not read as "nothing is wrong". A monitor + # that cannot tell withheld from clean would clear a real warning. + self.assertIs( + body['x-medkit-reduced'], True, + 'the cut-down body did not say it was cut down, so an empty ' + 'warnings array reads as a clean bill of health' + ) + + def test_05_a_credential_still_gets_the_whole_document(self): + """Opening a route for probes must not cost the operator surface.""" + body = requests.get( + f'{PUBLIC_BASE_URL}/health', headers=self.auth, timeout=15 + ).json() + self.assertIn('discovery', body) + self.assertNotIn('x-medkit-reduced', body) + + def test_06_a_forged_credential_is_an_anonymous_caller(self): + """A token this gateway never issued must not unlock the full body.""" + body = requests.get( + f'{PUBLIC_BASE_URL}/health', + headers={'Authorization': 'Bearer not.a.real.token'}, + timeout=15, + ).json() + self.assertIs(body.get('x-medkit-reduced'), True, body) + self.assertNotIn('discovery', body) + + +class TestWriteModeHealthIsNotCutDown(GatewayTestCase): + """The reduction follows `auth.public_routes`, not `auth.enabled`. + + Under `require_auth_for: "write"` every GET is answered without a + credential because reads are open at that level, and no operator singled + out a route. Cutting the body there withheld the discovery, entity-cache + and linking sections from a configuration nobody hardened, while + docs/config/server.rst promises the cut only on a route named in + `auth.public_routes`. + + Two conditions have to be separable for this to mean anything, so this + gateway holds one of them (authentication on, anonymous caller) and NOT the + other (no public_routes entry). + """ + + BASE_URL = WRITE_BASE_URL + + def test_01_authentication_really_is_on(self): + """Without this the class passes against a gateway with auth off. + + A write refused for want of a credential is what proves the gateway is + authenticating at all, which is the premise every assertion below + rests on. + """ + resp = requests.post( + f'{WRITE_BASE_URL}/apps/{PROBE_ID}/operations/{PROBE_ID}/executions', + json={}, + timeout=15, + ) + self.assertIn( + resp.status_code, (401, 403), + f'a write answered {resp.status_code} on a gateway running ' + f'auth.enabled true, so authentication is not on and nothing below ' + f'is about the reduction' + ) + + def test_02_an_anonymous_health_carries_the_sections(self): + body = requests.get(f'{WRITE_BASE_URL}/health', timeout=15).json() + self.assertNotIn( + 'x-medkit-reduced', body, + f'/health was cut down under require_auth_for "write", where no ' + f'route was named in auth.public_routes: {body}' + ) + self.assertIn( + 'discovery', body, + f'the discovery section was withheld from an anonymous caller on a ' + f'gateway with no public_routes entry: {body}' + ) + self.assertIn('x-medkit-entity-cache', body, body) + + def test_03_a_reduced_body_is_still_reachable_where_it_is_configured(self): + """The control, on the gateway that DOES name a route. + + Without it, test_02 would pass just as well against a gateway that had + stopped reducing anything at all, and the whole `public_routes` + behaviour would be untested by this pair. + """ + body = requests.get(f'{PUBLIC_BASE_URL}/health', timeout=15).json() + self.assertIs(body.get('x-medkit-reduced'), True, body) + + +class TestNothingAnswersBeforeAuth(GatewayTestCase): + """What the CORS preflight may and may not do without a credential. + + Preflight is answered anonymously on purpose, and it is the second named + exemption after /auth/*. A browser never puts Authorization on a preflight + - asking permission before sending the real request is the whole point of + the mechanism - so demanding one would not harden anything, it would make + browser clients impossible. The control below is what pins that. + + What must hold instead: the preflight discloses only CORS policy, and the + REAL request that follows is still refused without a credential. + + This gateway enables CORS for a real origin, which the rest of the file + deliberately does not, because that is the configuration in which any of + this is reachable at all. + """ + + BASE_URL = CORS_BASE_URL + + def _preflight(self, extra=None): + headers = {'Origin': ALLOWED_ORIGIN, 'Access-Control-Request-Method': 'GET'} + headers.update(extra or {}) + return requests.options(f'{CORS_BASE_URL}/apps', headers=headers, timeout=15) + + def test_01_an_anonymous_preflight_is_answered(self): + """The exemption, stated as a test so it stays explicit. + + A browser cannot put a credential on a preflight - asking permission + before sending the real request is the whole point of the mechanism - + so a 401 or 403 here means no browser client can reach this gateway at + all. + """ + resp = self._preflight() + self.assertEqual( + resp.status_code, 204, + f'an anonymous preflight got {resp.status_code}; a browser cannot ' + 'authenticate a preflight, so refusing it makes browser clients ' + 'impossible' + ) + self.assertEqual(resp.headers.get('Access-Control-Allow-Origin'), ALLOWED_ORIGIN) + + def test_02_the_preflight_discloses_only_cors_policy(self): + """Why the exemption is safe: there is nothing in the response. + + If a preflight ever grew a body, the exemption would start leaking and + this fails, so it cannot pass unnoticed. + """ + resp = self._preflight() + self.assertEqual( + resp.content, b'', + f'the preflight returned a body: {resp.content[:200]!r}' + ) + + def test_03_a_preflight_from_an_unknown_origin_is_refused(self): + """The exemption is scoped to origins the operator configured.""" + resp = requests.options( + f'{CORS_BASE_URL}/apps', + headers={'Origin': 'https://not-configured.example', + 'Access-Control-Request-Method': 'GET'}, + timeout=15, + ) + self.assertEqual(resp.status_code, 403) + + def test_04_the_real_request_after_a_preflight_still_needs_a_credential(self): + """The property that actually matters. + + A preflight being answered must not carry any implication for the GET + that follows it, which is where the data is. + """ + resp = requests.get( + f'{CORS_BASE_URL}/apps', headers={'Origin': ALLOWED_ORIGIN}, timeout=15 + ) + self.assertIn( + resp.status_code, (401, 403), + f'a cross-origin GET got {resp.status_code} with no credential' + ) + + def test_04b_a_plain_options_without_the_preflight_header_is_refused(self): + """The exemption is for preflights, not for the OPTIONS method. + + A browser preflight always carries Access-Control-Request-Method. An + OPTIONS without it is an ordinary request that any client could send, + and it has no reason to skip the credential check. The helper above + always sends both headers, so this boundary needs its own case. + """ + resp = requests.options( + f'{CORS_BASE_URL}/apps', + headers={'Origin': ALLOWED_ORIGIN}, + timeout=15, + ) + self.assertIn( + resp.status_code, (401, 403), + f'a plain OPTIONS with no Access-Control-Request-Method got ' + f'{resp.status_code}; the preflight exemption is too wide' + ) + + def test_05_an_authenticated_cross_origin_request_works(self): + """The mirror: CORS is live and a credentialed browser call succeeds.""" + headers = {'Origin': ALLOWED_ORIGIN} + headers.update(self.cors_auth) + resp = requests.get(f'{CORS_BASE_URL}/apps', headers=headers, timeout=15) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.headers.get('Access-Control-Allow-Origin'), ALLOWED_ORIGIN) + + @classmethod + def setUpClass(cls): + super().setUpClass() + resp = requests.post( + f'{CORS_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ) + assert resp.status_code == 200, f'token request failed: {resp.status_code}' + cls.cors_auth = {'Authorization': f'Bearer {resp.json()["access_token"]}'} + + +class TestLimiterSpeaksOnlyToCallersItMay(GatewayTestCase): + """Who hears the limiter, and what it tells them. + + Three answers on one gateway, and the difference between them is the + contract: + + - no Authorization header on a protected route: 401, whatever the + allowance. `process` short-circuits on the missing header before it + extracts anything, so the refusal is free and 429 would disclose limiter + state to a caller holding nothing at all. + - an Authorization header with the allowance gone: a bare 429 - no + `X-RateLimit-*`, no `Retry-After` - and the token inside is never + verified. Nothing about this caller has been checked, so the allowance + and the reset time stay behind the check they would otherwise precede. + - a credential the gateway accepted: the full limiter state, which is what + a client needs to pace itself. + + The limit here is deliberately tiny so the exhausted state is reachable in + a test at all. + """ + + BASE_URL = RL_BASE_URL + + # The anonymous refusal from test_01, taken while the allowance is still + # there. test_03 compares the exhausted refusal against it, and by the time + # test_03 runs the bucket is long gone - a refusal it fetched for itself + # would be a second exhausted one, and comparing two exhausted responses + # says nothing about whether they drifted apart. + unexhausted_refusal = None + + # A token this gateway accepts, taken in test_01 while the allowance was + # there. test_07 needs one and cannot mint it: by then /auth/authorize is + # over the limit like everything else. + accepted_bearer = None + + def test_01_the_limiter_state_reaches_only_a_credentialed_caller(self): + """X-RateLimit-* says how much allowance is left and when it resets. + + Reporting it to a caller holding no credential is the disclosure that + answering 429 before authentication would have made, arriving by + another door. A caller who authenticates is entitled to it, and that + half is checked first - both because the allowance is still there, and + because without it this would pass against a gateway that had simply + stopped emitting the headers. + """ + token = requests.post( + f'{RL_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ) + self.assertEqual(token.status_code, 200, token.text) + authed = requests.get( + f'{RL_BASE_URL}/areas', + headers={'Authorization': f'Bearer {token.json()["access_token"]}'}, + timeout=15, + ) + self.assertEqual(authed.status_code, 200, authed.text) + self.assertNotEqual( + [h for h in authed.headers if h.lower().startswith('x-ratelimit')], [], + f'a credentialed caller was told nothing about the limiter: ' + f'{dict(authed.headers)}' + ) + + anonymous = requests.get(f'{RL_BASE_URL}/apps', timeout=15) + self.assertIn(anonymous.status_code, (401, 403), anonymous.text) + leaked = [h for h in anonymous.headers if h.lower().startswith('x-ratelimit')] + self.assertEqual( + leaked, [], + f'an uncredentialed caller was told the limiter state: {leaked}' + ) + + # The allowance the credentialed request above reported is what makes + # this refusal an unexhausted one. test_03 compares against it, and a + # stash taken past the limit would make that comparison say nothing. + remaining = int(authed.headers.get('X-RateLimit-Remaining', '0')) + self.assertGreater( + remaining, 0, + f'the bucket was already spent when test_01 ran, so the refusal it ' + f'stashes is not an unexhausted one (remaining={remaining})' + ) + type(self).unexhausted_refusal = anonymous + type(self).accepted_bearer = token.json()['access_token'] + + def test_02_an_exhausted_anonymous_caller_still_gets_401(self): + responses = [] + # Comfortably past a limit of 5/minute. + for _ in range(12): + responses.append(requests.get(f'{RL_BASE_URL}/apps', timeout=15)) + seen = [r.status_code for r in responses] + + self.assertNotIn( + 429, seen, + f'an anonymous caller was rate-limited where a refusal is due: {seen}' + ) + self.assertTrue( + all(code in (401, 403) for code in seen), + f'expected only 401/403 for an uncredentialed caller, got {seen}' + ) + + def test_03_the_exhausted_refusal_is_shaped_like_every_other_401(self): + """Status alone is not the contract; the body and the challenge are. + + The refusal an exhausted anonymous caller gets is produced on a + different line from the one an unexhausted caller gets, so nothing + stops the two drifting apart. A client that reads the error document to + tell a missing credential from an expired one gets nothing to read from + a bare 401 - and a 401 that is visibly a different shape is itself the + disclosure that the limiter, not the credential, decided it. + + The unexhausted half comes from test_01, which took it while the + allowance was still there. Fetching one here would get a second + exhausted refusal: 5 per minute refills one every 12 seconds and the + tests above spent 15. + """ + fresh = type(self).unexhausted_refusal + self.assertIsNotNone( + fresh, + 'test_01 did not stash an unexhausted refusal, so this compares ' + 'nothing' + ) + exhausted = None + for _ in range(8): + exhausted = requests.get(f'{RL_BASE_URL}/apps', timeout=15) + + self.assertEqual( + fresh.json().keys(), exhausted.json().keys(), + f'the two refusals are different documents: {fresh.json()} vs ' + f'{exhausted.json()}' + ) + for label, resp in (('first', fresh), ('exhausted', exhausted)): + with self.subTest(response=label): + self.assertIn(resp.status_code, (401, 403), resp.text) + self.assertIn( + 'WWW-Authenticate', resp.headers, + f'the {label} refusal carries no challenge header: ' + f'{dict(resp.headers)}' + ) + body = resp.json() + # The shape AuthMiddleware produces: an OAuth-style error and + # a human-readable description, not the SOVD `error_code` + # envelope the handlers use for their own refusals. + self.assertTrue( + body.get('error'), + f'the {label} refusal carries no error: {body}' + ) + self.assertTrue( + body.get('error_description'), + f'the {label} refusal carries no error_description: {body}' + ) + + def test_04_an_exhausted_caller_with_a_header_gets_a_bare_429(self): + """A credential the gateway never has to verify, and a 429 that says nothing. + + Verifying a signature is the expensive half of the auth path - under + RS256 it reads a key file - and an over-limit caller is one the gateway + has already decided to refuse. So a request carrying an Authorization + header on a protected route while the allowance is gone answers 429 and + the token in it is never looked at. + + The header here is deliberately not a token. A gateway that verified + first would answer 401 with a decode error; 429 is only reachable by + the limiter having spoken first. + + And the 429 carries no limiter state. Nothing about this caller has + been checked - presenting a header is not presenting a credential - + so `X-RateLimit-*` and `Retry-After` would tell an unverified caller + the allowance, the reset time and the retry delay, which is the + disclosure answering before authentication was meant to avoid. + """ + # The tests above spent well past the 5/minute allowance; one more + # burst removes any doubt about which side of the limit this sits on. + for _ in range(8): + requests.get(f'{RL_BASE_URL}/apps', timeout=15) + + refused = requests.get( + f'{RL_BASE_URL}/apps', + headers={'Authorization': 'Bearer this-is-not-a-token'}, + timeout=15, + ) + self.assertEqual( + refused.status_code, 429, + f'an exhausted caller presenting a header got {refused.status_code} ' + f'where 429 is due, so the gateway verified a token it had already ' + f'decided to refuse. Body: {refused.text[:300]}' + ) + + leaked = [h for h in refused.headers + if h.lower().startswith('x-ratelimit') or h.lower() == 'retry-after'] + self.assertEqual( + leaked, [], + f'the pre-validation 429 carried limiter state to a caller nothing ' + f'has verified: {leaked}' + ) + self.assertEqual( + refused.json().get('parameters'), {}, + f'the bare 429 body carried limiter state: {refused.text[:300]}' + ) + + def test_06_a_non_preflight_options_is_metered_like_any_other_request(self): + """OPTIONS is not a free method; a PREFLIGHT is an exempt request. + + A real preflight carries Access-Control-Request-Method and is answered + before the limiter is reached. An OPTIONS without it is an ordinary + request to a route: it runs the token verifier, so it has to spend + allowance, or a flood of them costs a signature check each for free. + """ + for _ in range(10): + requests.get(f'{RL_BASE_URL}/apps', timeout=15) + + resp = requests.options( + f'{RL_BASE_URL}/apps', + headers={'Authorization': 'Bearer this-is-not-a-token'}, + timeout=15, + ) + self.assertEqual( + resp.status_code, 429, + f'an exhausted OPTIONS carrying a header answered {resp.status_code}; ' + f'the limiter did not meter it. Body: {resp.text[:300]}' + ) + leaked = [h for h in resp.headers + if h.lower().startswith('x-ratelimit') or h.lower() == 'retry-after'] + self.assertEqual(leaked, [], f'the bare 429 carried limiter state: {leaked}') + + def test_07_a_valid_credential_over_the_limit_gets_the_same_bare_429(self): + """A token the gateway WOULD accept, over the limit. + + The refusal is the limiter's, so it lands the same way whether the + credential is good or garbage - and a client holding a valid token + therefore gets no Retry-After on a protected route. That is the + consequence a closed-profile client has to be built around, and this + is where it is pinned. + """ + # Minted by test_01, while the allowance was still there. Obtaining one + # here would spend allowance on a call that is not the subject, and by + # this point in the class /auth/authorize is over the limit itself. + bearer = type(self).accepted_bearer + self.assertIsNotNone( + bearer, + 'test_01 did not stash a token, so this cannot test the ' + 'credentialed path' + ) + + for _ in range(10): + requests.get(f'{RL_BASE_URL}/apps', timeout=15) + + resp = requests.get( + f'{RL_BASE_URL}/areas', + headers={'Authorization': f'Bearer {bearer}'}, + timeout=15, + ) + self.assertEqual( + resp.status_code, 429, + f'an over-limit caller holding a VALID token answered {resp.status_code}' + ) + leaked = [h for h in resp.headers + if h.lower().startswith('x-ratelimit') or h.lower() == 'retry-after'] + self.assertEqual( + leaked, [], + f'a valid credential over the limit was told the limiter state: {leaked}' + ) + + def test_05_the_bare_429_needs_only_a_header_not_a_bearer(self): + """The test is the header's PRESENCE; its value is never parsed here. + + A gateway that reached this path by looking for "Bearer " would answer + 401 to the scheme below, and the comment in rest_server would be + describing something the code does not do. + """ + for _ in range(8): + requests.get(f'{RL_BASE_URL}/apps', timeout=15) + + for header in ('Basic dXNlcjpwYXNz', 'not-even-a-scheme'): + with self.subTest(authorization=header): + resp = requests.get( + f'{RL_BASE_URL}/apps', + headers={'Authorization': header}, + timeout=15, + ) + self.assertEqual( + resp.status_code, 429, + f'an exhausted caller sending {header!r} got ' + f'{resp.status_code}; the limiter path keys on the header ' + f'being there, not on its scheme' + ) + + +@launch_testing.post_shutdown_test() +class TestClosedByDefaultShutdown(unittest.TestCase): + """Gateway exits cleanly.""" + + def test_exit_codes(self, proc_info, gateway_node, cors_gateway, rl_gateway, + public_route_gateway, write_mode_gateway): + for proc in (gateway_node, cors_gateway, rl_gateway, public_route_gateway, + write_mode_gateway): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=proc + ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_env_auth_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_env_auth_contract.test.py new file mode 100644 index 000000000..c28d4aa0d --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_env_auth_contract.test.py @@ -0,0 +1,975 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The gateway node reads the three auth environment variables itself. + +Where the rule lives decides which deployments it covers. A container +entrypoint can only apply it to what the entrypoint runs, and a launch file +only to launches that include it - so `docker run ros2 launch ...`, +`docker run bash`, and a plain `ros2 run ros2_medkit_gateway +gateway_node` each ran on whatever rule reached them. The node reads the +variables where it reads its parameters, which covers every path by +construction, and this is the test of that: the gateways below are started with +no entrypoint and no launch file, only an environment. + +Each case pairs the environment against a params file that says the OPPOSITE, +because a rule that only agrees with the file proves nothing about precedence. + +THE RULES + +C1 MEDKIT_JWT_SECRET, against a file with `auth.enabled: false`: the gateway + refuses an anonymous read. +C2 Every route, writes and reads alike. `require_auth_for` has to become + "all": at + the file's "write" an anonymous GET answers 200 with authentication + switched on, and the entity tree, the fault history and the operation list + are the disclosure. +C3 MEDKIT_CLIENTS reaches the gateway: the credential in it obtains a token + from POST /auth/authorize, and that token reads. Without this a gateway + closed to everyone including its operator would pass C1 and C2. +C4 MEDKIT_AUTH_DISABLED=1 on top of the same secret, against a file with + `auth.enabled: true`: the gateway answers an anonymous read. + +@verifies REQ_INTEROP_086, REQ_INTEROP_087 +""" + +import os +import select +import shutil +import signal +import subprocess +import tempfile +import time +import unittest + +import launch +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase +from ros2_medkit_test_utils.launch_helpers import create_gateway_node + +CLOSED_PORT = get_test_port(0) +DISABLED_PORT = get_test_port(1) +# For the gateways below that are expected to refuse to start. They never bind +# it; a port of their own keeps a case that unexpectedly DOES come up from +# colliding with the two gateways this file launches. +REFUSE_PORT = get_test_port(2) +FILE_CLOSED_PORT = get_test_port(3) + +# How long `ros2 param` may spend discovering a gateway over DDS before a call +# against it is treated as a real failure. +PARAM_DISCOVERY_TIMEOUT = 30 +CLOSED_BASE_URL = f'http://127.0.0.1:{CLOSED_PORT}{API_BASE_PATH}' +DISABLED_BASE_URL = f'http://127.0.0.1:{DISABLED_PORT}{API_BASE_PATH}' +FILE_CLOSED_BASE_URL = f'http://127.0.0.1:{FILE_CLOSED_PORT}{API_BASE_PATH}' + +# Never valid as a credential - it is the secret half of a two-field entry the +# gateway must refuse - and distinctive enough to grep a whole log for. +MALFORMED_CLIENT_SECRET = 's3cret_that_must_not_be_logged' +# The secret of an entry written `id:role:secret`, which parses as a role +# nobody has; a warning that quoted the role field would print it. +SWAPPED_FILE_SECRET = 's3cret_swapped_in_the_file_zz' +SWAPPED_ENV_SECRET = 's3cret_swapped_in_the_env_zz' +# What this gateway would present to its peers; served through the parameter +# services as a sentinel and never as itself. +PEER_AUTH_HEADER = 'Bearer zz_peer_token_that_must_not_be_served' +FILE_JWT_SECRET = 'a_file_supplied_secret_of_at_least_32_characters' + +JWT_SECRET = 'an_environment_supplied_secret_of_at_least_32_chars' +CLIENT_ID = 'envclient' +CLIENT_SECRET = 'env_client_secret' + +# Written once at import time, so both gateways load a real file from disk +# - the file, and not inline parameters, is the thing the environment has to +# outrank. +_PARAMS_DIR = tempfile.mkdtemp(prefix='medkit_env_auth_') + +# The node names the launch below gives its gateways. A params file is keyed by +# the node it is for: rcl applies an entry only to the node whose name heads +# it, so a file written under any other name is read and applies to nothing, +# and a test built on it measures the defaults. +ENV_CLOSED_NODE = 'gateway_env_closed' +ENV_DISABLED_NODE = 'gateway_env_disabled' +FILE_CLOSED_NODE = 'gateway_file_closed' + + +# A key the environment never touches and the inline parameters never set, with +# a different value per file. Every other key in these files is overwritten by +# the environment or equals the default, so this is the one that shows the +# file reached its gateway at all. +CLOSED_FILE_TOKEN_EXPIRY = 1234 +DISABLED_FILE_TOKEN_EXPIRY = 2345 + + +def _write_params(name, node_name, auth_enabled, token_expiry): + """Write a params file stating a posture, so the environment has one to override.""" + path = os.path.join(_PARAMS_DIR, name) + with open(path, 'w', encoding='utf-8') as handle: + handle.write( + f'{node_name}:\n' + ' ros__parameters:\n' + ' auth:\n' + f' enabled: {"true" if auth_enabled else "false"}\n' + ' require_auth_for: "write"\n' + ' jwt_secret: "a_file_supplied_secret_of_at_least_32_characters"\n' + f' clients: ["{CLIENT_ID}:a_file_supplied_client_secret:viewer"]\n' + f' token_expiry_seconds: {token_expiry}\n' + ) + return path + + +AUTH_OFF_PARAMS = _write_params('auth-off.yaml', ENV_CLOSED_NODE, auth_enabled=False, + token_expiry=CLOSED_FILE_TOKEN_EXPIRY) +AUTH_ON_PARAMS = _write_params('auth-on.yaml', ENV_DISABLED_NODE, auth_enabled=True, + token_expiry=DISABLED_FILE_TOKEN_EXPIRY) + + +def _write_file_closed_params(): + """Write a file that closes the gateway on its own, with one bad client. + + Everything here comes from the file and nothing from the environment, which + is the case the sentinel and the malformed-entry warning are about. + """ + path = os.path.join(_PARAMS_DIR, 'file-closed.yaml') + with open(path, 'w', encoding='utf-8') as handle: + handle.write( + f'{FILE_CLOSED_NODE}:\n' + ' ros__parameters:\n' + ' auth:\n' + ' enabled: true\n' + ' require_auth_for: "all"\n' + f' jwt_secret: "{FILE_JWT_SECRET}"\n' + ' clients:\n' + f' - "{CLIENT_ID}:{CLIENT_SECRET}:admin"\n' + f' - "twofield:{MALFORMED_CLIENT_SECRET}"\n' + f' - "swapped:admin:{SWAPPED_FILE_SECRET}"\n' + ) + return path + + +FILE_CLOSED_PARAMS = _write_file_closed_params() + + +@pytest.mark.launch_test +def generate_test_description(): + """Two gateways, differing only in what their environments say.""" + closed = create_gateway_node( + port=CLOSED_PORT, + name=ENV_CLOSED_NODE, + params_file=AUTH_OFF_PARAMS, + # `auth.public_routes: [""]` is the empty-sequence idiom, and it is + # written here on purpose: a gateway that read the blank entry as a + # route would refuse to start, and one that opened something on it + # would answer /health anonymously below. + extra_params={'server.host': '127.0.0.1', 'auth.public_routes': ['']}, + extra_env={ + 'MEDKIT_JWT_SECRET': JWT_SECRET, + 'MEDKIT_CLIENTS': f'{CLIENT_ID}:{CLIENT_SECRET}:admin,' + f'envswapped:admin:{SWAPPED_ENV_SECRET}', + }, + ) + + disabled = create_gateway_node( + port=DISABLED_PORT, + name=ENV_DISABLED_NODE, + params_file=AUTH_ON_PARAMS, + extra_params={'server.host': '127.0.0.1', + 'aggregation.peer_auth_header': PEER_AUTH_HEADER}, + extra_env={ + 'MEDKIT_JWT_SECRET': JWT_SECRET, + 'MEDKIT_CLIENTS': f'{CLIENT_ID}:{CLIENT_SECRET}:admin', + 'MEDKIT_AUTH_DISABLED': '1', + }, + ) + + file_closed = create_gateway_node( + port=FILE_CLOSED_PORT, + name=FILE_CLOSED_NODE, + params_file=FILE_CLOSED_PARAMS, + extra_params={'server.host': '127.0.0.1'}, + ) + + return launch.LaunchDescription([ + closed, + disabled, + file_closed, + launch_testing.actions.ReadyToTest(), + ]), {'closed': closed, 'disabled': disabled, 'file_closed': file_closed} + + +class TestEnvironmentClosesTheNode(GatewayTestCase): + """MEDKIT_JWT_SECRET closes a gateway whose params file says auth is off.""" + + BASE_URL = CLOSED_BASE_URL + + def test_00_the_file_reached_this_gateway(self): + """The premise of every case below: the file was applied at all. + + Every auth key the file sets is overwritten by the environment, so a + file that never reached the node would leave C1-C4 passing against the + defaults. The expiry is the one key the environment leaves alone. + """ + rc, output = _ros2_param('get', f'/{ENV_CLOSED_NODE}', 'auth.token_expiry_seconds') + self.assertEqual(rc, 0, output) + self.assertIn( + str(CLOSED_FILE_TOKEN_EXPIRY), output, + f'the params file did not reach {ENV_CLOSED_NODE}: {output}') + + def test_01_an_anonymous_read_is_refused(self): + """C1. The params file says `auth.enabled: false` and is overruled.""" + resp = requests.get(f'{CLOSED_BASE_URL}/areas', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + f'MEDKIT_JWT_SECRET was in the environment and an anonymous GET /areas ' + f'answered {resp.status_code}. Body: {resp.text[:300]}' + ) + + def test_02_every_route_is_closed_not_only_the_writes(self): + """C2. `require_auth_for` became "all", against a file saying "write". + + A read is what "closed" has to mean here: at "write" every one of these + answers 200 with authentication switched on, which is the posture an + operator would be told was closed. + """ + for path in ('/areas', '/components', '/apps', '/health', '/'): + with self.subTest(path=path): + resp = requests.get(f'{CLOSED_BASE_URL}{path}', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + f'an anonymous GET {path} answered {resp.status_code}; ' + f'require_auth_for is still the file\'s "write"' + ) + + def test_03_the_environment_credential_issues_a_working_token(self): + """C3. The mirror: a gateway closed to everyone would pass C1 and C2. + + The file names the same client id with a different secret and the + `viewer` role, so this also pins WHICH credential reached the gateway: + the environment's secret authenticates, and the role that comes back is + the environment's `admin`. + """ + token = requests.post( + f'{CLOSED_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ) + self.assertEqual(token.status_code, 200, token.text) + self.assertEqual( + token.json().get('scope'), 'admin', + f"the token came back with the file's role, so auth.clients was not " + f'replaced: {token.text[:300]}' + ) + + resp = requests.get( + f'{CLOSED_BASE_URL}/areas', + headers={'Authorization': f'Bearer {token.json()["access_token"]}'}, + timeout=15, + ) + self.assertEqual(resp.status_code, 200, resp.text) + + def test_04_the_file_credential_is_gone(self): + """MEDKIT_CLIENTS replaces auth.clients; it does not add to it. + + Leaving the file's credentials standing under a secret they were not + issued against is the surprise: an operator who closed a container with + a new secret would still be handing out tokens to whoever knew the old + file. + """ + token = requests.post( + f'{CLOSED_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': 'a_file_supplied_client_secret', + }, + timeout=30, + ) + self.assertNotEqual( + token.status_code, 200, + "the params file's client secret still obtains a token" + ) + + +class TestAuthDisabledWinsOverEverything(GatewayTestCase): + """MEDKIT_AUTH_DISABLED=1 beats the secret and a file that asks for auth.""" + + BASE_URL = DISABLED_BASE_URL + + def test_00_the_file_reached_this_gateway(self): + """The premise: the file was applied, so C4 overrules something.""" + rc, output = _ros2_param('get', f'/{ENV_DISABLED_NODE}', 'auth.token_expiry_seconds') + self.assertEqual(rc, 0, output) + self.assertIn( + str(DISABLED_FILE_TOKEN_EXPIRY), output, + f'the params file did not reach {ENV_DISABLED_NODE}: {output}') + + def test_01_an_anonymous_read_is_answered(self): + """C4. Both the file (`auth.enabled: true`) and MEDKIT_JWT_SECRET lose.""" + resp = requests.get(f'{DISABLED_BASE_URL}/areas', timeout=15) + self.assertEqual( + resp.status_code, 200, + f'MEDKIT_AUTH_DISABLED=1 was set and an anonymous GET /areas answered ' + f'{resp.status_code}. Body: {resp.text[:300]}' + ) + + def test_02_nothing_asks_for_a_credential(self): + resp = requests.get(f'{DISABLED_BASE_URL}/health', timeout=15) + self.assertEqual(resp.status_code, 200, resp.text) + self.assertNotIn( + 'x-medkit-reduced', resp.json(), + 'the body was cut down on a gateway running without authentication' + ) + + def test_03_no_secret_is_served_with_authentication_off(self): + """The file's secrets are unused here and still not served. + + A gateway running open carries the secrets of the file that asked for + auth, and the parameter services answer anyone on the domain; a peer + sharing that file signs with the same secret. + """ + rc, secret = _ros2_param('get', f'/{ENV_DISABLED_NODE}', 'auth.jwt_secret') + self.assertEqual(rc, 0, secret) + self.assertNotIn( + 'a_file_supplied_secret_of_at_least_32_characters', secret, + 'the file signing secret is served by an open gateway') + rc, clients = _ros2_param('get', f'/{ENV_DISABLED_NODE}', 'auth.clients') + self.assertEqual(rc, 0, clients) + self.assertNotIn( + 'a_file_supplied_client_secret', clients, + 'a file client secret is served by an open gateway') + + def test_04_the_peer_auth_header_is_never_served(self): + """What this gateway presents to its peers is a bearer, and stays here.""" + rc, header = _ros2_param('get', f'/{ENV_DISABLED_NODE}', 'aggregation.peer_auth_header') + self.assertEqual(rc, 0, header) + self.assertNotIn( + 'zz_peer_token', header, + 'aggregation.peer_auth_header is served as itself') + self.assertIn('', header, header) + + def test_05_the_peer_auth_header_cannot_be_set_at_runtime(self): + """Read once at construction, like auth.*, so a set would change nothing.""" + _, output = _ros2_param( + 'set', f'/{ENV_DISABLED_NODE}', 'aggregation.peer_auth_header', 'Bearer other') + self.assertIn( + 'read at start', output, + f'a runtime set of aggregation.peer_auth_header was not refused: {output}') + + +def _ros2_param(*args): + """Run `ros2 param ...` against this test's domain and return (rc, output). + + Retries while the CLI reports the node missing. `ros2 param` builds its own + participant and has to discover the gateway's parameter services over DDS, + which is not instantaneous - the HTTP port answering says nothing about + whether that has happened yet, so the first call in a class would otherwise + fail with "Node not found" while the node is plainly there. + """ + deadline = time.monotonic() + PARAM_DISCOVERY_TIMEOUT + output = '' + while True: + completed = subprocess.run( + ['ros2', 'param', *args], + capture_output=True, text=True, timeout=30, check=False, + ) + output = completed.stdout + completed.stderr + if 'Node not found' not in output or time.monotonic() >= deadline: + return completed.returncode, output + time.sleep(1) + + +class TestIntrospectionAgreesWithEnforcement(GatewayTestCase): + """`ros2 param get` answers what the gateway enforces, and refuses writes. + + The environment decides the posture, and the parameters carried whatever + the params file said - so on a gateway refusing every request, + `auth.enabled` read back `false`, which is the file's value and the + opposite of the truth. An operator reading the parameters to find out how a + container is running was told the wrong thing by the gateway itself. + + A write is refused, because the auth configuration is consumed once at + construction: a `param set` that reported success would change nothing and + say it had. + """ + + BASE_URL = CLOSED_BASE_URL + + def test_01_the_closed_gateway_reports_the_posture_it_serves(self): + rc, enabled = _ros2_param('get', '/gateway_env_closed', 'auth.enabled') + self.assertEqual(rc, 0, enabled) + self.assertIn( + 'True', enabled, + f'the params file says auth.enabled false and the gateway refuses ' + f'every anonymous read; introspection answered: {enabled}' + ) + + rc, level = _ros2_param('get', '/gateway_env_closed', 'auth.require_auth_for') + self.assertEqual(rc, 0, level) + self.assertIn( + 'all', level, + 'the environment forced require_auth_for to "all" and the ' + f'parameter still reports the value from the file: {level}' + ) + + def test_02_the_disabled_gateway_reports_the_posture_it_serves(self): + """The other shape: a file asking for auth, an environment refusing it.""" + rc, enabled = _ros2_param('get', '/gateway_env_disabled', 'auth.enabled') + self.assertEqual(rc, 0, enabled) + self.assertIn( + 'False', enabled, + 'MEDKIT_AUTH_DISABLED=1 turned authentication off and the ' + f'parameter still reports true from the file: {enabled}' + ) + + def test_03_no_secret_is_readable_through_the_parameters(self): + """A sentinel names the variable; the value stays out of reach. + + The parameter services answer anything that can reach the node, so a + secret written back here would be readable by a caller holding no HTTP + credential at all - which is a wider audience than the file it was + kept out of. + """ + rc, secret = _ros2_param('get', '/gateway_env_closed', 'auth.jwt_secret') + self.assertEqual(rc, 0, secret) + self.assertNotIn(JWT_SECRET, secret, 'the signing secret is readable through ros2 param') + self.assertIn('', secret, secret) + + rc, clients = _ros2_param('get', '/gateway_env_closed', 'auth.clients') + self.assertEqual(rc, 0, clients) + self.assertNotIn(CLIENT_SECRET, clients, 'a client secret is readable through ros2 param') + self.assertIn(CLIENT_ID, clients, f'the client id should still be visible: {clients}') + self.assertIn('', clients, clients) + + def test_04_an_auth_parameter_cannot_be_set_at_runtime(self): + """Refused, and the gateway goes on refusing anonymous reads.""" + _, output = _ros2_param( + 'set', '/gateway_env_closed', 'auth.enabled', 'false') + self.assertIn( + 'read at start', output, + f'setting auth.enabled did not report the reason it is refused: {output}' + ) + self.assertNotIn('Set parameter successful', output, output) + + resp = requests.get(f'{CLOSED_BASE_URL}/areas', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + f'after a refused param set the gateway answered {resp.status_code}' + ) + + def test_06_a_param_load_applies_what_it_may_and_refuses_the_rest(self): + """`ros2 param load` is how a whole file is pushed at a running node. + + It sets each parameter in turn, so the guard has to refuse the auth + entry and leave the rest applied - a file mixing the two must not + become an all-or-nothing gamble on which key came first. + """ + path = os.path.join(_PARAMS_DIR, 'runtime-load.yaml') + # Keyed on the absolute node name: `ros2 param load` matches the + # file's keys against the name it was given, slash included. + with open(path, 'w', encoding='utf-8') as handle: + handle.write( + f'/{ENV_CLOSED_NODE}:\n' + ' ros__parameters:\n' + ' refresh_interval_ms: 4500\n' + ' auth:\n' + ' enabled: false\n' + ) + + _, output = _ros2_param('load', '/gateway_env_closed', path) + self.assertIn( + 'read at start', output, + f'the auth entry in a loaded file was not refused: {output}') + + rc, refresh = _ros2_param('get', '/gateway_env_closed', 'refresh_interval_ms') + self.assertEqual(rc, 0, refresh) + self.assertIn( + '4500', refresh, + f'the non-auth entry was not applied: {refresh}') + + rc, enabled = _ros2_param('get', '/gateway_env_closed', 'auth.enabled') + self.assertEqual(rc, 0, enabled) + self.assertIn('True', enabled, f'auth.enabled was changed by the load: {enabled}') + + resp = requests.get(f'{CLOSED_BASE_URL}/areas', timeout=15) + self.assertIn(resp.status_code, (401, 403), resp.text) + + def test_07_an_atomic_batch_mixing_the_two_is_refused_whole(self): + """set_parameters_atomically is all-or-nothing, and auth.* refuses. + + The guard returns one result for the batch, so a batch carrying an + auth parameter must apply none of it - including the harmless one + beside it, which would otherwise be a way to have half a refused + request take effect. + """ + before = _ros2_param('get', '/gateway_env_closed', 'refresh_interval_ms')[1] + + script = ( + 'import rclpy\n' + 'from rclpy.node import Node\n' + 'from rclpy.parameter import Parameter\n' + 'from rcl_interfaces.srv import SetParametersAtomically\n' + 'rclpy.init()\n' + 'n = Node("batch_probe")\n' + 'c = n.create_client(SetParametersAtomically,\n' + ' "/gateway_env_closed/set_parameters_atomically")\n' + 'assert c.wait_for_service(timeout_sec=30.0), "no service"\n' + 'req = SetParametersAtomically.Request()\n' + 'req.parameters = [\n' + ' Parameter("refresh_interval_ms", Parameter.Type.INTEGER, 6100)\n' + ' .to_parameter_msg(),\n' + ' Parameter("auth.enabled", Parameter.Type.BOOL, False)\n' + ' .to_parameter_msg(),\n' + ']\n' + 'f = c.call_async(req)\n' + 'rclpy.spin_until_future_complete(n, f, timeout_sec=30.0)\n' + 'r = f.result()\n' + 'print("SUCCESSFUL", r.result.successful)\n' + 'print("REASON", r.result.reason)\n' + 'rclpy.shutdown()\n' + ) + completed = subprocess.run( + ['python3', '-c', script], + capture_output=True, text=True, timeout=90, check=False, + ) + output = completed.stdout + completed.stderr + self.assertIn('SUCCESSFUL False', output, f'the batch was accepted: {output[-600:]}') + self.assertIn('read at start', output, f'the batch gave no reason: {output[-600:]}') + + after = _ros2_param('get', '/gateway_env_closed', 'refresh_interval_ms')[1] + self.assertEqual( + before, after, + 'the harmless half of a refused atomic batch was applied') + self.assertNotIn('6100', after, after) + + def test_05_a_non_auth_parameter_is_untouched_by_the_guard(self): + """The guard names `auth.` and must not close the rest of the surface.""" + _, output = _ros2_param( + 'set', '/gateway_env_closed', 'refresh_interval_ms', '3000') + self.assertNotIn( + 'read at start', output, + f'the auth guard refused a parameter outside auth.*: {output}' + ) + # The absence of the refusal is not the claim; a gateway that refused + # every set for some other reason would satisfy it. The set has to work. + self.assertIn( + 'Set parameter successful', output, + f'setting a parameter outside auth.* did not succeed: {output}' + ) + + +class TestAMisconfiguredEnvironmentRefusesToStart(GatewayTestCase): + """Two environments with no working reading, each refused at startup. + + Both would otherwise produce a gateway that runs and serves nobody: a + client list where every entry was rejected leaves a gateway closed to its + own operator, and MEDKIT_JWT_SECRET under RS256 hands a shared secret to a + code path that wants a file path. Refusing is the same answer a missing + secret already gets, and for the same reason - a gateway nobody can use is + a misconfiguration, not a posture. + + Driven as subprocesses, because the subject is a process that must NOT come + up: a launch action that dies is a fixture failure, and the exit code is the + assertion here. + """ + + BASE_URL = CLOSED_BASE_URL + + @staticmethod + def _run_gateway(env_extra, extra_args=(), timeout=45): + """Start a gateway with this environment; return (rc, output). + + ``rc`` is None when the gateway announced itself ready, or was still + running at the timeout - either is what "it started" looks like here, + and the control case needs that to be distinguishable from a refusal. + The ready line ends the wait, so a control returns the moment the + gateway is up and does not sit out a timer on it. + """ + env = dict(os.environ) + env.update(env_extra) + command = [ + 'ros2', 'run', 'ros2_medkit_gateway', 'gateway_node', '--ros-args', + '-p', f'server.port:={REFUSE_PORT}', '-p', 'server.host:=127.0.0.1', + *extra_args, + ] + # Its own process group, and killed as a group on the way out. `ros2 + # run` execs the node as a CHILD, so killing the wrapper alone leaves a + # gateway running with PPID 1 holding this port and its DDS domain - and + # a later test drawing the same port then talks to it and fails looking + # like a regression. The control case here is a gateway that comes up + # and stays up, so this path is taken on every run. + process = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=env, start_new_session=True, + ) + chunks = [] + started = False + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + readable, _, _ = select.select([process.stdout], [], [], min(remaining, 0.5)) + if not readable: + continue + chunk = os.read(process.stdout.fileno(), 65536) + if not chunk: + break # end of output: the process group is gone + chunks.append(chunk) + # The line can straddle two reads, so the last two are searched. + if b'Medkit Gateway ready on' in b''.join(chunks[-2:]): + started = True + break + + def drain(): + rest, _ = process.communicate() + if rest: + chunks.append(rest) + return b''.join(chunks).decode(errors='replace') + + if started or process.poll() is None and deadline <= time.monotonic(): + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + return None, drain() + + process.wait(timeout=15) + return process.returncode, drain() + + def test_01_a_client_list_refused_in_full_stops_the_gateway(self): + rc, output = self._run_gateway({ + 'MEDKIT_JWT_SECRET': JWT_SECRET, + 'MEDKIT_CLIENTS': 'nocolons,also-bad,third:entry:wizard', + }) + self.assertNotEqual( + rc, 0, + f'a gateway whose every client entry was refused started anyway, ' + f'closed to everyone including its operator. Output: {output[-600:]}' + ) + self.assertIn('every MEDKIT_CLIENTS entry was refused', output, output[-600:]) + + def test_02_an_empty_client_list_starts_and_warns(self): + """The control. An EMPTY value is a decision, and must not be refused. + + Without this, test_01 would pass against a gateway that refused any + MEDKIT_CLIENTS it could not use a credential from, which is a different + and wrong rule. + """ + rc, output = self._run_gateway( + {'MEDKIT_JWT_SECRET': JWT_SECRET, 'MEDKIT_CLIENTS': ''}, + timeout=20, + ) + self.assertIsNone( + rc, + f'an empty MEDKIT_CLIENTS stopped the gateway; empty is a decision, ' + f'not a list that failed to parse. Output: {output[-600:]}' + ) + self.assertIn( + 'Medkit Gateway ready on', output, + f'the gateway neither refused nor came up: {output[-600:]}') + self.assertNotIn('every MEDKIT_CLIENTS entry was refused', output, output[-600:]) + self.assertIn('no client can obtain a token', output, output[-600:]) + + def test_03_a_secret_under_rs256_stops_the_gateway(self): + rc, output = self._run_gateway( + {'MEDKIT_JWT_SECRET': JWT_SECRET, + 'MEDKIT_CLIENTS': f'{CLIENT_ID}:{CLIENT_SECRET}:admin'}, + extra_args=('-p', 'auth.jwt_algorithm:=RS256'), + ) + self.assertNotEqual( + rc, 0, + f'MEDKIT_JWT_SECRET under RS256 started a gateway. Output: {output[-600:]}' + ) + self.assertIn('MEDKIT_JWT_SECRET is set and auth.jwt_algorithm is RS256', output, + output[-600:]) + self.assertNotIn( + JWT_SECRET, output, + 'the refusal echoed the secret it was refusing') + + def test_04_a_short_secret_names_the_variable_it_came_from(self): + """The message has to send the operator to the right knob. + + A FATAL naming `auth.jwt_secret` sends them to a params file the + gateway did not read, which is the whole point of the environment + contract being that the file loses. + """ + rc, output = self._run_gateway({ + 'MEDKIT_JWT_SECRET': 'too-short', + 'MEDKIT_CLIENTS': f'{CLIENT_ID}:{CLIENT_SECRET}:admin', + }) + self.assertNotEqual(rc, 0, output[-600:]) + self.assertNotIn('too-short', output, 'the refusal echoed the secret') + + # The startup WARN naming MEDKIT_JWT_SECRET is printed on every closed + # gateway, so finding that string anywhere proves nothing. The claim is + # about the REFUSAL line: it has to say the secret came from the + # environment, because that is where the operator has to go and fix it. + refusal = [line for line in output.splitlines() if 'Refusing to start' in line] + self.assertTrue(refusal, f'no refusal line in the output: {output[-600:]}') + self.assertIn( + 'MEDKIT_JWT_SECRET', refusal[0], + f'the refusal does not name the variable the secret came from: {refusal[0]}' + ) + + def test_05_a_refused_file_value_is_not_blamed_on_the_environment(self): + """The refusal names which values the environment supplied, and no more. + + A gateway closed by MEDKIT_JWT_SECRET still reads its expiries from the + parameters. The refusal line names the values the environment supplied + (the secret, the clients, or both) and says every other auth.* value + came from the parameters, so the validator's own message is what points + at the refused key. + """ + rc, output = self._run_gateway( + {'MEDKIT_JWT_SECRET': JWT_SECRET, + 'MEDKIT_CLIENTS': f'{CLIENT_ID}:{CLIENT_SECRET}:admin'}, + extra_args=('-p', 'auth.token_expiry_seconds:=0'), + ) + self.assertNotEqual(rc, 0, output[-600:]) + refusal = [line for line in output.splitlines() if 'Refusing to start' in line] + self.assertTrue(refusal, f'no refusal line in the output: {output[-600:]}') + self.assertIn( + 'Token expiry must be positive', refusal[0], + f'the refusal does not say which value was refused: {refusal[0]}') + self.assertIn( + 'came from the parameters', refusal[0], + f'the refusal does not send the operator to the parameters: {refusal[0]}') + self.assertNotIn( + 'MEDKIT_AUTH_DISABLED', refusal[0], + f'the refusal names a variable the value did not come from: {refusal[0]}') + + def test_06_a_refusal_names_only_the_variable_that_was_set(self): + """The secret without the clients: the clause names one variable.""" + rc, output = self._run_gateway( + {'MEDKIT_JWT_SECRET': JWT_SECRET}, + extra_args=('-p', 'auth.token_expiry_seconds:=0'), + ) + self.assertNotEqual(rc, 0, output[-600:]) + refusal = [line for line in output.splitlines() if 'Refusing to start' in line] + self.assertTrue(refusal, f'no refusal line in the output: {output[-600:]}') + self.assertIn( + 'The environment supplied auth.jwt_secret (MEDKIT_JWT_SECRET); every other ' + 'auth.* value came from the parameters.', + refusal[0], refusal[0]) + self.assertNotIn( + 'MEDKIT_CLIENTS', refusal[0], + f'the refusal names a variable that was not set: {refusal[0]}') + + +class TestAFileSuppliedSecretIsAlsoRedacted(GatewayTestCase): + """A secret is redacted wherever it came from and in every auth state. + + A `jwt_secret:=` launch argument or a value in a params file would reach + the parameter services like any other parameter, and those answer any + participant on the domain - a wider audience than the file the operator put + it in. The gateway takes these from its overrides once at construction, + declares the parameters with a sentinel, and the on-set guard makes them + immutable, so nothing downstream needs the value. + """ + + BASE_URL = FILE_CLOSED_BASE_URL + + def test_01_the_gateway_is_closed_by_its_file_alone(self): + """The premise: no MEDKIT_* variable is set for this gateway.""" + resp = requests.get(f'{FILE_CLOSED_BASE_URL}/areas', timeout=15) + self.assertIn(resp.status_code, (401, 403), resp.text) + + def test_02_the_file_secret_reads_back_as_a_sentinel(self): + rc, secret = _ros2_param('get', '/gateway_file_closed', 'auth.jwt_secret') + self.assertEqual(rc, 0, secret) + self.assertNotIn( + FILE_JWT_SECRET, secret, + 'a params-file signing secret is readable through ros2 param') + self.assertIn('', secret, secret) + + def test_03_the_file_client_secrets_read_back_as_sentinels(self): + rc, clients = _ros2_param('get', '/gateway_file_closed', 'auth.clients') + self.assertEqual(rc, 0, clients) + self.assertNotIn( + CLIENT_SECRET, clients, + 'a params-file client secret is readable through ros2 param') + self.assertIn(CLIENT_ID, clients, f'the client id should still show: {clients}') + self.assertIn('', clients, clients) + + def test_04_the_credential_still_works(self): + """Redaction is about what is readable, not about what is configured.""" + token = requests.post( + f'{FILE_CLOSED_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ) + self.assertEqual(token.status_code, 200, token.text) + resp = requests.get( + f'{FILE_CLOSED_BASE_URL}/areas', + headers={'Authorization': f'Bearer {token.json()["access_token"]}'}, + timeout=15, + ) + self.assertEqual(resp.status_code, 200, resp.text) + + def test_05_the_malformed_entry_did_not_register_a_client(self): + """The two-field entry is dropped, and the rest of the list stands. + + test_04 above shows the good entry registered; this shows the bad one + did not become a client under some other reading of its fields. + """ + token = requests.post( + f'{FILE_CLOSED_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': 'twofield', + 'client_secret': MALFORMED_CLIENT_SECRET, + }, + timeout=30, + ) + self.assertNotEqual( + token.status_code, 200, + 'a two-field auth.clients entry was registered as a client') + + +class TestPublicRoutesAreValidatedWhateverThePosture(GatewayTestCase): + """A malformed auth.public_routes stops the gateway with auth OFF too. + + A list validated only while authentication is on leaves a typo unnoticed + until somebody closes the gateway - the worst moment to discover that a + route they believe is reachable is not. The key is a configuration error + whenever it is set. + + `[""]` is the opposite case and must not be refused: it is how a ROS 2 YAML + file writes an empty sequence, and both shipped profiles use that idiom. + """ + + BASE_URL = CLOSED_BASE_URL + + def test_01_a_malformed_entry_stops_a_gateway_with_auth_off(self): + rc, output = TestAMisconfiguredEnvironmentRefusesToStart._run_gateway( + {}, extra_args=('-p', 'auth.enabled:=false', + '-p', 'auth.public_routes:=["nonsense"]')) + self.assertNotEqual( + rc, 0, + f'a malformed auth.public_routes started a gateway because auth was ' + f'off. Output: {output[-600:]}' + ) + self.assertIn('auth.public_routes entry "nonsense" is invalid', output, output[-600:]) + + def test_02_a_blank_entry_is_accepted_with_auth_off(self): + """The control: the empty-sequence idiom is not a typo.""" + rc, output = TestAMisconfiguredEnvironmentRefusesToStart._run_gateway( + {}, extra_args=('-p', 'auth.enabled:=false', + '-p', 'auth.public_routes:=[""]'), + timeout=20) + self.assertIsNone( + rc, + f'auth.public_routes: [""] stopped the gateway; that is how a ROS 2 ' + f'YAML file writes an empty sequence. Output: {output[-600:]}' + ) + self.assertIn( + 'Medkit Gateway ready on', output, + f'the gateway neither refused nor came up: {output[-600:]}') + self.assertNotIn('auth.public_routes entry', output, output[-600:]) + + def test_03_a_blank_entry_exempts_nothing(self): + """And it opens no route. + + The closed gateway in this launch carries `auth.public_routes: [""]`, + so it came up with the blank entry and still refuses /health. + """ + resp = requests.get(f'{CLOSED_BASE_URL}/health', timeout=15) + self.assertIn(resp.status_code, (401, 403), resp.text) + + +@launch_testing.post_shutdown_test() +class TestEnvAuthContractShutdown(unittest.TestCase): + """Both gateways exit cleanly.""" + + def test_exit_codes(self, proc_info, closed, disabled, file_closed): + for proc in (closed, disabled, file_closed): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=proc + ) + + def test_no_secret_reached_the_log(self, proc_output, closed, disabled, file_closed): + """Nothing the gateway printed carries a secret it was given. + + Container logs are shipped, aggregated and kept, so a secret echoed + once at startup outlives the process and reaches an audience nobody + chose. Checked post-shutdown, over the whole captured output rather + than a line somebody expected to be the risky one. + + Concatenated with no separator: proc_output yields raw stream chunks + and a chunk boundary can fall mid-line, so joining with a newline + splices one into the text being searched. + """ + for proc in (closed, disabled, file_closed): + text = ''.join( + output.text.decode(errors='replace') for output in proc_output[proc] + ) + self.assertNotIn( + JWT_SECRET, text, + 'MEDKIT_JWT_SECRET was echoed into the gateway log' + ) + self.assertNotIn( + CLIENT_SECRET, text, + 'a MEDKIT_CLIENTS client secret was echoed into the gateway log' + ) + self.assertNotIn( + FILE_JWT_SECRET, text, + 'a params-file jwt_secret was echoed into the gateway log' + ) + # The malformed entry is the interesting one: a warning that quoted + # the entry to show what was wrong with it would publish the secret + # inside it. + self.assertNotIn( + MALFORMED_CLIENT_SECRET, text, + 'the secret inside a malformed auth.clients entry was echoed ' + 'into the gateway log' + ) + # A swapped entry puts the secret in the role field; a warning that + # quoted the role it could not recognise would print it. + self.assertNotIn( + SWAPPED_FILE_SECRET, text, + 'the role field of a swapped auth.clients entry was echoed into the log') + self.assertNotIn( + SWAPPED_ENV_SECRET, text, + 'the role field of a swapped MEDKIT_CLIENTS entry was echoed into the log') + self.assertNotIn( + PEER_AUTH_HEADER, text, + 'the peer auth header was echoed into the gateway log') + # The blank entry is not an exemption and gets no line of its own; + # the two spaces are what an empty entry leaves in that message. + self.assertNotIn( + 'auth.public_routes: is answered', text, + 'a blank auth.public_routes entry was logged as an exemption') + + @classmethod + def tearDownClass(cls): + """Drop the params files, which exist only for this run.""" + shutil.rmtree(_PARAMS_DIR, ignore_errors=True) diff --git a/src/ros2_medkit_integration_tests/test/features/test_env_closes_the_gateway.test.py b/src/ros2_medkit_integration_tests/test/features/test_env_closes_the_gateway.test.py new file mode 100644 index 000000000..fe0910f4f --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_env_closes_the_gateway.test.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""MEDKIT_JWT_SECRET closes a gateway started through gateway.launch.py. + +The rule itself lives in the gateway node and is specified in +test_env_auth_contract. What this file covers is the launch path: the variables +have to REACH a node that `gateway.launch.py` starts, carrying whatever +arguments the launch file passes. + +That is not idle. The container image is where it matters - `docker run +ros2 launch ros2_medkit_gateway bringup.launch.py` execs a command, and the +node is started behind it, so the entrypoint's own `-p` arguments never reach +the gateway and the environment is the only channel the variable has - and the launch file +writes `auth.*` parameter overrides of its own. A file that asserted +`auth.enabled` from its config and dropped the environment on the floor would +produce exactly the failure worth a test: the operator is told the container is +closed, and it serves the entity tree, the fault history and every operation to +anyone who reaches the port. + +Driven through `gateway.launch.py` with no launch arguments naming auth, and +against the open profile it defaults to, so anything that closes this gateway +came from the environment. + +@verifies REQ_INTEROP_086, REQ_INTEROP_087 +""" + +import os +import unittest + +from ament_index_python.packages import get_package_share_directory +import launch +from launch.actions import IncludeLaunchDescription +from launch.launch_description_sources import PythonLaunchDescriptionSource +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase + +PORT = get_test_port() +BASE_URL = f'http://127.0.0.1:{PORT}{API_BASE_PATH}' + +JWT_SECRET = 'an_environment_supplied_secret_of_at_least_32_chars' +CLIENT_ID = 'envclosed' +CLIENT_SECRET = 'env_closed_client_secret' + + +@pytest.mark.launch_test +def generate_test_description(): + """Include gateway.launch.py with the closing variables in its environment. + + No launch argument names auth or TLS. The config file it defaults to is the + open profile, so anything that closes this gateway came from the + environment - which is what the case is about. + """ + launch_file = os.path.join( + get_package_share_directory('ros2_medkit_gateway'), 'launch', 'gateway.launch.py') + + # SetEnvironmentVariable, because gateway.launch.py reads os.environ when + # the launch description is evaluated and that happens in this process. + # Confining it to this file is what launch_testing already does: each test + # file runs in a process of its own, so the variable reaches this gateway + # and no other. + return launch.LaunchDescription([ + launch.actions.SetEnvironmentVariable('MEDKIT_JWT_SECRET', JWT_SECRET), + launch.actions.SetEnvironmentVariable( + 'MEDKIT_CLIENTS', f'{CLIENT_ID}:{CLIENT_SECRET}:admin'), + IncludeLaunchDescription( + PythonLaunchDescriptionSource(launch_file), + launch_arguments={ + 'server_port': str(PORT), + 'server_host': '127.0.0.1', + }.items(), + ), + launch_testing.actions.ReadyToTest(), + ]), {} + + +class TestEnvClosesTheGateway(GatewayTestCase): + """The environment variable alone is enough to close the gateway.""" + + BASE_URL = BASE_URL + + def test_01_an_anonymous_read_is_refused(self): + """The claim the image documentation makes, checked on the launch path. + + ``/areas`` and not ``/health``: a read is what "closed" has to mean + here. Under ``require_auth_for: "write"`` - the open profile's value, + and what the gateway keeps if only ``auth.enabled`` is asserted - this + request answers 200 with authentication switched on. + """ + resp = requests.get(f'{BASE_URL}/areas', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + f'MEDKIT_JWT_SECRET was in the environment and an anonymous GET ' + f'/areas answered {resp.status_code}. Body: {resp.text[:300]}' + ) + + def test_02_health_is_refused_too(self): + """No route is exempt but the auth routes, health included.""" + resp = requests.get(f'{BASE_URL}/health', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + f'an anonymous GET /health answered {resp.status_code}' + ) + + def test_03_the_environment_credential_issues_a_working_token(self): + """The mirror: a gateway that refused everyone would pass the two above. + + It also pins MEDKIT_CLIENTS reaching the gateway - without it the + container is closed to its operator as well as to everyone else. + """ + token = requests.post( + f'{BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ) + self.assertEqual(token.status_code, 200, token.text) + access = token.json()['access_token'] + resp = requests.get( + f'{BASE_URL}/areas', + headers={'Authorization': f'Bearer {access}'}, + timeout=15, + ) + self.assertEqual(resp.status_code, 200, resp.text) + + +@launch_testing.post_shutdown_test() +class TestEnvClosesTheGatewayShutdown(unittest.TestCase): + """Every process exits cleanly. + + Swept without naming one: the gateway is created inside the included launch + file, so this file holds no handle to it. + """ + + def test_exit_codes(self, proc_info): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES) diff --git a/src/ros2_medkit_integration_tests/test/features/test_forward_auth_across_gateways.test.py b/src/ros2_medkit_integration_tests/test/features/test_forward_auth_across_gateways.test.py new file mode 100644 index 000000000..003a6b60c --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_forward_auth_across_gateways.test.py @@ -0,0 +1,549 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A peer accepts a token the aggregator minted, which is what forward_auth needs. + +``aggregation.forward_auth`` puts the caller's own ``Authorization`` header on +the request the aggregator makes to a peer, and docs/config/aggregation.rst +describes the deployment it is for: peers that are trusted and **share the same +JWT configuration**. The token therefore arrives at a gateway that never issued +it. + +Every access token names the refresh record it was minted from, and those +records live in the issuing process's memory. A gateway that read a missing +record as "invalid" refused every forwarded token, so forward_auth could not +work against any peer that authenticated. The record store is a denylist +instead: a record held and marked revoked refuses, and a record that was never +there says nothing. + +THE RULES + +C1 A token minted by the aggregator is accepted by the peer on a direct read. + The cross-instance claim in its simplest form. +C2 With forward_auth on, a read the caller aims at a peer-owned app is carried + to the peer by the caller's own token and answered. The aggregator's own + credential cannot stand in for it: the documentation says a caller's token + wins wherever both apply, so a peer that refuses the forwarded token + refuses this request. +C3 A token whose signature does not verify is refused by the peer. Without it + C1 and C2 would pass just as well against a peer that ignored the header, + and would say nothing about the credential. +C4 The peer refuses an anonymous read, so it is closed at all. + +``aggregation.peer_auth_header`` carries a SEPARATE, hand-minted token, so the +aggregator's own connections - the health check and the entity fetch behind +merging - succeed whatever happens to the forwarded one. Without it the peer +answers 401 to the health check, is recorded as offline, and C2 would fail for +a reason that has nothing to do with forwarding. That token deliberately omits +the ``refresh_token_id`` claim, which is what distinguishes it from a token +``/auth/authorize`` issues and is exactly the claim this whole case is about. + +@verifies REQ_INTEROP_086, REQ_INTEROP_087 +""" + +import base64 +import hashlib +import hmac +import json +import time +import unittest + +import launch +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + DISCOVERY_INTERVAL, + DISCOVERY_TIMEOUT, + get_test_domain_id, + get_test_port, + get_time_scale, +) +from ros2_medkit_test_utils.launch_helpers import ( + create_demo_nodes, + create_gateway_node, +) + +AGG_PORT = get_test_port(0) +PEER_PORT = get_test_port(1) +AGG_BASE_URL = f'http://127.0.0.1:{AGG_PORT}{API_BASE_PATH}' +PEER_BASE_URL = f'http://127.0.0.1:{PEER_PORT}{API_BASE_PATH}' + +# Separate DDS domains, so the aggregator can only learn the peer's entities +# over HTTP - which is the path a credential travels. +AGG_DOMAIN_ID = get_test_domain_id(0) +PEER_DOMAIN_ID = get_test_domain_id(1) + +# The shared JWT configuration: same secret, same issuer, same client. This is +# what the aggregation documentation means by peers that share a configuration. +JWT_SECRET = 'forward_auth_cross_gateway_secret_key_0123456789' +JWT_ISSUER = 'ros2_medkit_gateway' +CLIENT_ID = 'aggregator' +CLIENT_SECRET = 'aggregator_client_secret' + +PEER_NODES = ['pressure_sensor', 'actuator'] +PEER_APP = 'pressure_sensor' +# An app id no gateway in this launch has, for the requests whose subject is the +# role check that runs before any entity lookup. +ABSENT_APP = 'an_app_no_gateway_here_has' + +TIMEOUT = DISCOVERY_TIMEOUT * get_time_scale() + + +def _b64(raw): + """base64url without padding, which is what JWT uses.""" + return base64.urlsafe_b64encode(raw).rstrip(b'=') + + +def _mint_peer_credential(lifetime_sec=3600, subject=CLIENT_ID): + """Sign an HS256 access token the peer accepts, for the aggregator's own use. + + Minted here, because it is needed as a LAUNCH parameter and at that point + no gateway is running to issue one. Built on + hmac and hashlib from the standard library so a missing JWT package on one + CI image cannot fail this for a reason unrelated to what it asserts. + + No ``refresh_token_id`` claim: nothing issued this token, so there is no + record it could name. That is what keeps this credential independent of the + behaviour under test. + """ + header = {'alg': 'HS256', 'typ': 'access'} + now = int(time.time()) + payload = { + 'iss': JWT_ISSUER, + 'sub': subject, + 'iat': now, + 'exp': now + lifetime_sec, + 'jti': 'forward-auth-peer-credential', + 'role': 'admin', + } + signing_input = b'.'.join([ + _b64(json.dumps(header, separators=(',', ':')).encode()), + _b64(json.dumps(payload, separators=(',', ':')).encode()), + ]) + signature = hmac.new(JWT_SECRET.encode(), signing_input, hashlib.sha256).digest() + return (signing_input + b'.' + _b64(signature)).decode() + + +def _auth_params(role='admin'): + """Build the shared JWT configuration with this gateway's own client table. + + `role` is what THIS gateway grants the shared client id. The two gateways + are given different values on purpose: the role a token grants has to come + from the table of the gateway answering the request, so a client that is + admin on the aggregator and viewer on the peer may only read on the peer. + """ + return { + 'server.host': '127.0.0.1', + 'auth.enabled': True, + 'auth.require_auth_for': 'all', + 'auth.issuer': JWT_ISSUER, + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:{role}'], + } + + +@pytest.mark.launch_test +def generate_test_description(): + """Launch an aggregator and a peer, both closed, sharing one JWT configuration.""" + peer_env = {'ROS_DOMAIN_ID': str(PEER_DOMAIN_ID)} + + aggregator = create_gateway_node( + port=AGG_PORT, + name='forward_auth_aggregator', + extra_params={ + **_auth_params(), + 'aggregation.enabled': True, + 'aggregation.timeout_ms': 5000, + 'aggregation.announce': False, + 'aggregation.discover': False, + 'aggregation.forward_auth': True, + 'aggregation.peer_auth_header': f'Bearer {_mint_peer_credential()}', + 'aggregation.peer_urls': [f'http://127.0.0.1:{PEER_PORT}'], + 'aggregation.peer_names': ['closed_peer'], + }, + extra_env={'ROS_DOMAIN_ID': str(AGG_DOMAIN_ID)}, + ) + + peer = create_gateway_node( + port=PEER_PORT, + name='forward_auth_peer', + extra_params=_auth_params(role='viewer'), + extra_env=peer_env, + ) + + peer_demo_nodes = create_demo_nodes( + PEER_NODES, lidar_faulty=False, extra_env=peer_env) + + return launch.LaunchDescription([ + aggregator, + peer, + launch.actions.TimerAction(period=2.0, actions=peer_demo_nodes), + launch_testing.actions.ReadyToTest(), + ]), {'aggregator': aggregator, 'peer': peer} + + +def _wait_until_answering(url): + """Any HTTP answer, a refusal included, means the process is listening.""" + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + try: + resp = requests.get(f'{url}/health', timeout=2) + if resp.status_code in (200, 401, 403): + return + except requests.RequestException: + pass + time.sleep(DISCOVERY_INTERVAL) + raise AssertionError(f'{url} never answered within {TIMEOUT}s') + + +def _token_from(url): + resp = requests.post( + f'{url}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ) + assert resp.status_code == 200, f'{url} issued no token: {resp.status_code} {resp.text}' + return resp.json()['access_token'] + + +class TestForwardAuthAcrossGateways(unittest.TestCase): + """A shared JWT configuration makes one gateway's token good at the other.""" + + @classmethod + def setUpClass(cls): + _wait_until_answering(AGG_BASE_URL) + _wait_until_answering(PEER_BASE_URL) + cls.agg_token = _token_from(AGG_BASE_URL) + cls.agg_auth = {'Authorization': f'Bearer {cls.agg_token}'} + + def _wait_for_merged_app(self): + """Block until the aggregator lists the peer's app, or fail saying so.""" + deadline = time.monotonic() + TIMEOUT + seen = set() + while time.monotonic() < deadline: + resp = requests.get( + f'{AGG_BASE_URL}/apps', headers=self.agg_auth, timeout=15) + self.assertEqual(resp.status_code, 200, resp.text) + seen = {item.get('id') for item in resp.json().get('items', [])} + if PEER_APP in seen: + return + time.sleep(DISCOVERY_INTERVAL) + self.fail( + f'the aggregator never merged {PEER_APP}; it saw {sorted(seen)}. ' + f'Merging runs on aggregation.peer_auth_header, so this is a ' + f'problem with the fixture, and says nothing about forwarding.' + ) + + def test_01_the_peer_accepts_a_token_the_aggregator_minted(self): + """C1. The peer holds no refresh record for this token and never will. + + It was issued by another process; the record that names it lives in + that process's memory. All the peer can check is the signature, the + expiry and the client, and all three are good because the two gateways + share the configuration. + """ + resp = requests.get( + f'{PEER_BASE_URL}/areas', headers=self.agg_auth, timeout=15) + self.assertEqual( + resp.status_code, 200, + f'the peer answered {resp.status_code} to a token minted by a gateway ' + f'sharing its secret, issuer and client. Body: {resp.text[:300]}' + ) + + def test_02_a_forwarded_read_is_carried_by_the_callers_token(self): + """C2. The same acceptance, through the path forward_auth actually uses. + + ``/apps//data`` is owned by the peer, so the aggregator + forwards it. The caller sent a credential, and the documented rule is + that a caller's own token wins wherever both apply - so what reaches + the peer is the token obtained above, not the aggregator's. + """ + self._wait_for_merged_app() + resp = requests.get( + f'{AGG_BASE_URL}/apps/{PEER_APP}/data', + headers=self.agg_auth, + timeout=15, + ) + self.assertEqual( + resp.status_code, 200, + f'a forwarded read answered {resp.status_code}; the peer refused the ' + f'token the aggregator issued and forwarded. Body: {resp.text[:300]}' + ) + + def test_03_the_peer_refuses_a_token_that_does_not_verify(self): + """C3. The control: the peer is checking, not waving tokens through. + + One byte of the signature is changed, and only that. Reversing the + whole signature would also pass, but a one-byte edit is the minimum + difference that must be caught: it keeps the length, the alphabet and + the padding intact, so nothing but the cryptography can reject it. The + payload still names a client the peer knows and an expiry in the + future. + """ + good = _token_from(PEER_BASE_URL) + header, payload, signature = good.split('.') + + alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + original = signature[0] + replacement = alphabet[(alphabet.index(original) + 1) % len(alphabet)] + forged = '.'.join([header, payload, replacement + signature[1:]]) + self.assertEqual(len(forged), len(good), 'the forgery changed the token length') + self.assertNotEqual(forged, good) + + resp = requests.get( + f'{PEER_BASE_URL}/areas', + headers={'Authorization': f'Bearer {forged}'}, + timeout=15, + ) + self.assertIn( + resp.status_code, (401, 403), + f'the peer answered {resp.status_code} to a token with one byte of its ' + f'signature changed, so the acceptances above say nothing about the ' + f'credential' + ) + + def test_04_the_peer_refuses_an_anonymous_read(self): + """C4. The other half of the control: this peer is closed at all.""" + resp = requests.get(f'{PEER_BASE_URL}/areas', timeout=15) + self.assertIn(resp.status_code, (401, 403), resp.text) + + def test_05_a_token_naming_a_client_the_peer_lacks_is_refused(self): + """Sharing a secret is not sharing a client list. + + The signature verifies and the expiry is in the future, so the only + thing that can refuse this is the peer checking `sub` against its own + clients. Without that check a gateway would accept any `sub` an issuer + cared to sign, and "the client list is the grant" would not hold. + """ + stranger = _mint_peer_credential(subject='a_client_no_gateway_here_lists') + resp = requests.get( + f'{PEER_BASE_URL}/areas', + headers={'Authorization': f'Bearer {stranger}'}, + timeout=15, + ) + self.assertIn( + resp.status_code, (401, 403), + f'the peer answered {resp.status_code} to a correctly signed token ' + f'naming a client it does not have' + ) + + def test_06_a_refresh_token_is_not_an_access_token(self): + """The asymmetry the denylist model rests on. + + Access tokens are judged on signature, expiry and client, so they cross + gateways and survive restarts. Refresh tokens are judged against a + record the issuing process holds in memory, so they do neither - and + presented as an access token they are refused on their type alone. + """ + token = requests.post( + f'{PEER_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ).json() + refresh = token.get('refresh_token') + self.assertTrue(refresh, f'the peer issued no refresh token: {token}') + + resp = requests.get( + f'{PEER_BASE_URL}/areas', + headers={'Authorization': f'Bearer {refresh}'}, + timeout=15, + ) + self.assertIn( + resp.status_code, (401, 403), + f'a refresh token read /areas ({resp.status_code}); the two token ' + f'types must not be interchangeable' + ) + + # The aggregator never issued it either, and cannot refresh with it. + refreshed = requests.post( + f'{AGG_BASE_URL}/auth/token', + json={'grant_type': 'refresh_token', 'refresh_token': refresh}, + timeout=30, + ) + self.assertNotEqual( + refreshed.status_code, 200, + 'a gateway that never issued this refresh token exchanged it for an ' + 'access token; refresh records do not cross gateways' + ) + + def test_07_revoking_on_the_peer_refuses_the_token_there(self): + """Revocation works on a gateway that never issued the token. + + Under a shared configuration the peer holds no record of anything the + aggregator minted. Since the records are read as a denylist, a revoke + that found nothing to mark would be a silent no-op on exactly the + gateway an operator is trying to lock down. + + Revocation is per gateway: the aggregator is not told, and goes on + accepting the token it issued. + """ + token = requests.post( + f'{AGG_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ).json() + access = token['access_token'] + refresh = token['refresh_token'] + auth = {'Authorization': f'Bearer {access}'} + + self.assertEqual( + requests.get(f'{PEER_BASE_URL}/areas', headers=auth, timeout=15).status_code, + 200, 'the peer refused the token before it was revoked') + + revoked = requests.post( + f'{PEER_BASE_URL}/auth/revoke', + json={'token': refresh}, + headers=auth, + timeout=30, + ) + self.assertEqual(revoked.status_code, 200, revoked.text) + + self.assertIn( + requests.get(f'{PEER_BASE_URL}/areas', headers=auth, timeout=15).status_code, + (401, 403), + 'the peer went on accepting a token revoked on it') + + self.assertEqual( + requests.get(f'{AGG_BASE_URL}/areas', headers=auth, timeout=15).status_code, + 200, + 'revoking on the peer reached the aggregator, which shares no record ' + 'store with it') + + +class TestTheRoleComesFromTheAnsweringGateway(unittest.TestCase): + """Sharing a signing configuration does not share the grants. + + The aggregator lists the shared client as `admin` and the peer lists it as + `viewer`. The token is the aggregator's, and its `role` claim says admin - + signed, so it cannot be edited in flight. What decides on the peer is the + peer's own table. + + Without this the grant would travel with the token, and a deployment that + trusted a peer's signing key would also be trusting whatever role every + other gateway sharing that key chose to hand out. + """ + + @classmethod + def setUpClass(cls): + _wait_until_answering(AGG_BASE_URL) + _wait_until_answering(PEER_BASE_URL) + cls.token = _token_from(AGG_BASE_URL) + cls.auth = {'Authorization': f'Bearer {cls.token}'} + + def test_01_the_token_claims_admin(self): + """The premise: the claim says admin, so the peer has to overrule it.""" + payload = self.token.split('.')[1] + payload += '=' * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + self.assertEqual( + claims.get('role'), 'admin', + f'the aggregator did not mint an admin token: {claims}') + + def test_02_a_write_is_refused_on_the_peer(self): + """A viewer on the peer may not execute an operation.""" + resp = requests.post( + f'{PEER_BASE_URL}/apps/{PEER_APP}/operations/probe/executions', + headers=self.auth, + json={}, + timeout=15, + ) + self.assertEqual( + resp.status_code, 403, + f'the peer answered {resp.status_code} to a write from a client its ' + f'own table lists as viewer. Body: {resp.text[:300]}' + ) + + def test_03_the_same_call_is_not_role_refused_on_the_issuer(self): + """The control: admin there, so the refusal above is about the ROLE. + + The role is decided on the method and the path before any entity is + looked up, so the request names an app no gateway has: on the + aggregator that answers 404, which is what "the role passed" looks like + on an app that is not there. The peer's own app is no use here - the + aggregator has merged it and forwards a call on it to the peer, so the + answer would be the peer's 403 relayed, and the control would measure + the peer twice. + """ + resp = requests.post( + f'{AGG_BASE_URL}/apps/{ABSENT_APP}/operations/probe/executions', + headers=self.auth, + json={}, + timeout=15, + ) + self.assertNotEqual( + resp.status_code, 403, + f'the aggregator refused its own admin client on role grounds: ' + f'{resp.text[:300]}' + ) + + def test_03b_the_identical_request_is_role_refused_on_the_peer(self): + """Same path, same token, other table: the only variable is the grant.""" + resp = requests.post( + f'{PEER_BASE_URL}/apps/{ABSENT_APP}/operations/probe/executions', + headers=self.auth, + json={}, + timeout=15, + ) + self.assertEqual( + resp.status_code, 403, + f'the peer answered {resp.status_code} where its own table lists the ' + f'client as viewer. Body: {resp.text[:300]}' + ) + + def test_04_a_read_still_works_on_the_peer(self): + """A viewer may read, so the peer refuses the method and not the token.""" + resp = requests.get(f'{PEER_BASE_URL}/areas', headers=self.auth, timeout=15) + self.assertEqual(resp.status_code, 200, resp.text) + + def test_05_a_client_absent_from_the_peers_table_is_refused(self): + """A shared key is not a shared client list.""" + stranger = _mint_peer_credential(subject='a_client_no_gateway_here_lists') + resp = requests.get( + f'{PEER_BASE_URL}/areas', + headers={'Authorization': f'Bearer {stranger}'}, + timeout=15, + ) + self.assertIn( + resp.status_code, (401, 403), + f'the peer answered {resp.status_code} to a correctly signed token ' + f'naming a client it does not have') + + +@launch_testing.post_shutdown_test() +class TestForwardAuthAcrossGatewaysShutdown(unittest.TestCase): + """Both gateways exit cleanly.""" + + def test_exit_codes(self, proc_info, aggregator, peer): + for proc in (aggregator, peer): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=proc + ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_open_default_profile.test.py b/src/ros2_medkit_integration_tests/test/features/test_open_default_profile.test.py new file mode 100644 index 000000000..a19e0a397 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_open_default_profile.test.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The default profile answers a caller who holds no credential. + +``config/gateway_params.yaml`` is what every existing launch, every existing +config file and the web UI get when they name no profile of their own, and none +of them sends an ``Authorization`` header. Closing that file is therefore a +breaking change for all three at once, and it is a one-word edit. + +The mirror of ``test_secure_profile``: that file pins the closed profile +closed, this one pins the open profile open, and between them a flip in either +direction has to be deliberate. Nothing else in the suite would notice - every +other test supplies the auth parameters it needs inline, so both files could +say anything and stay green. + +What this does NOT assert is that leaving it open is correct. It asserts that +the file has not changed underneath a deployment that already trusts it. + +@verifies REQ_INTEROP_086 +""" + +import os +import unittest + +from ament_index_python.packages import get_package_share_directory +import launch +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase +from ros2_medkit_test_utils.launch_helpers import create_gateway_node +import yaml + +PORT = get_test_port() +BASE_URL = f'http://127.0.0.1:{PORT}{API_BASE_PATH}' + +DEFAULT_PARAMS = os.path.join( + get_package_share_directory('ros2_medkit_gateway'), + 'config', 'gateway_params.yaml' +) + + +@pytest.mark.launch_test +def generate_test_description(): + """Launch the gateway from the default params file, overriding only the port.""" + gateway_node = create_gateway_node( + port=PORT, + params_file=DEFAULT_PARAMS, + # Nothing about auth or TLS is set here: whatever the file says is + # exactly what this test is about. The host is narrowed because a test + # has no business binding every interface on the machine it runs on. + extra_params={'server.host': '127.0.0.1'}, + ) + + return launch.LaunchDescription([ + gateway_node, + launch_testing.actions.ReadyToTest(), + ]), {'gateway_node': gateway_node} + + +class TestOpenDefaultProfile(GatewayTestCase): + """The default profile serves an uncredentialed caller.""" + + BASE_URL = BASE_URL + + def test_01_an_anonymous_read_of_areas_succeeds(self): + """A GET with no Authorization header is answered, not refused. + + ``/areas``, and not ``/health``: health has its own anonymous + handling (a reduced body on a route opened through + ``auth.public_routes``), so it can answer 200 for a reason that has + nothing to do with the profile. An entity collection has no such path - + a 200 here means the request was authorised. + """ + resp = requests.get(f'{BASE_URL}/areas', timeout=15) + self.assertEqual( + resp.status_code, 200, + f'the default profile answered an anonymous GET /areas with ' + f'{resp.status_code}; every existing launch and the web UI send no ' + f'credential. Body: {resp.text[:300]}' + ) + + def test_02_an_anonymous_read_of_health_succeeds_in_full(self): + """Health answers, and answers whole. + + ``x-medkit-reduced`` marks the cut-down body an anonymous caller gets + when authentication is on. Its absence is what separates "auth is off" + from "auth is on and this route was opened". + """ + resp = requests.get(f'{BASE_URL}/health', timeout=15) + self.assertEqual(resp.status_code, 200, resp.text) + body = resp.json() + self.assertEqual(body.get('status'), 'healthy', body) + self.assertNotIn( + 'x-medkit-reduced', body, + 'health came back marked reduced, which means authentication is on ' + f'in the default profile: {body}' + ) + + def test_03_the_file_under_test_is_the_open_one(self): + """Guard against this test drifting off the file it names. + + Read by key path through a YAML parse. The three values are the whole + of the profile's posture, and each is one word away from its opposite. + """ + with open(DEFAULT_PARAMS, encoding='utf-8') as handle: + document = yaml.safe_load(handle) + params = document['ros2_medkit_gateway']['ros__parameters'] + auth = params['auth'] + self.assertIs( + auth['enabled'], False, + f"the default profile sets auth.enabled to {auth['enabled']!r}" + ) + self.assertEqual( + auth['require_auth_for'], 'write', + 'the default profile sets auth.require_auth_for to ' + f'{auth["require_auth_for"]!r}' + ) + self.assertIs( + params['server']['tls']['enabled'], False, + 'the default profile sets server.tls.enabled to ' + f"{params['server']['tls']['enabled']!r}" + ) + + +@launch_testing.post_shutdown_test() +class TestOpenDefaultProfileShutdown(unittest.TestCase): + """Gateway exits cleanly.""" + + def test_exit_codes(self, proc_info, gateway_node): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=gateway_node + ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py index aa9755188..8e1482614 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -148,6 +148,16 @@ class TestOpenApiContract(GatewayTestCase): MIN_EXPECTED_APPS = 2 REQUIRED_APPS = {'calibration', 'temp_sensor'} + # The app entities appearing is not enough for this file. A node is listed + # in the ROS graph before its service endpoints have propagated, so a + # discovery sweep can build the App with an empty service list, and the + # cache-derived operation items in `/docs` are built from exactly that + # list. Until one service is in the cache every operations sub-document + # publishes only projections, and the comparison over `operations` in + # `test_a_scoped_item_says_what_its_templated_sibling_says` has nothing to + # compare. Waiting for the capability the assertion reads is what makes + # the file independent of how fast the runner propagates a service. + REQUIRED_OPERATIONS = {'/apps/calibration': 'calibrate'} _spec = None diff --git a/src/ros2_medkit_integration_tests/test/features/test_secure_profile.test.py b/src/ros2_medkit_integration_tests/test/features/test_secure_profile.test.py new file mode 100644 index 000000000..f8b395a79 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_secure_profile.test.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Boot the gateway from the SECURE profile and check that it is closed. + +``config/gateway_params.yaml`` is the open profile and +``config/gateway_params.secure.yaml`` is the closed one. Every other test in +this suite builds its parameters inline, which is fine for testing behaviour +but means nothing loads either file - so the secure profile could be edited +back to ``auth.enabled: false`` and the whole suite would stay green, because +each test supplies the values it needs itself. + +This file closes that gap for the secure profile. It launches with the +installed copy of that file, overriding only the port, the signing secret and +the client - the three things a real deployment must supply and the file +deliberately leaves empty - and then checks that what it ships is closed. +``test_open_default_profile`` is the mirror for the other file. + +TLS is turned off here and only here. The secure file has it on, which is +correct, but a certificate is a deployment artefact and generating one would +test the certificate, where the posture is the subject. ``test_tls_protocol_floor`` +covers TLS itself against real handshakes. + +@verifies REQ_INTEROP_086, REQ_INTEROP_087 +""" + +import os +import socket +import time +import unittest + +from ament_index_python.packages import get_package_share_directory +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.coverage import get_coverage_env +import yaml + +PORT = get_test_port() +BASE_URL = f'http://127.0.0.1:{PORT}{API_BASE_PATH}' + +SECURE_PARAMS = os.path.join( + get_package_share_directory('ros2_medkit_gateway'), + 'config', 'gateway_params.secure.yaml' +) + +JWT_SECRET = 'secure_profile_integration_secret_key_01234567890' +CLIENT_ID = 'secure' +CLIENT_SECRET = 'secure_client_secret' + + +@pytest.mark.launch_test +def generate_test_description(): + """Launch the gateway with the secure params file, plus the required secrets.""" + gateway_node = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='gateway_node', + name='ros2_medkit_gateway', + output='screen', + parameters=[ + SECURE_PARAMS, + { + 'server.host': '127.0.0.1', + 'server.port': PORT, + 'refresh_interval_ms': 1000, + # A certificate is a deployment artefact, not part of the + # posture under test here. + 'server.tls.enabled': False, + # What the secure file leaves empty on purpose. + 'auth.jwt_secret': JWT_SECRET, + 'auth.clients': [f'{CLIENT_ID}:{CLIENT_SECRET}:admin'], + }, + ], + additional_env=dict(get_coverage_env()), + ) + + return launch.LaunchDescription([ + gateway_node, + launch_testing.actions.ReadyToTest(), + ]), {'gateway_node': gateway_node} + + +def _wait_listening(port, timeout=90.0): + """Block until the gateway accepts a connection. + + launch_testing starts the tests when the process is spawned, not when it is + serving. Without this the first request is refused by a gateway that simply + has not opened its socket yet, which looks nothing like the posture this + file is about. + + The timeout is generous because this gateway loads the full secure profile, + which does more work at startup than the inline parameter sets the rest of + the suite uses. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(('127.0.0.1', port), timeout=2): + return + except OSError: + time.sleep(0.25) + raise AssertionError(f'gateway on port {port} never started listening within {timeout}s') + + +class TestSecureProfile(unittest.TestCase): + """What config/gateway_params.secure.yaml actually produces.""" + + @classmethod + def setUpClass(cls): + _wait_listening(PORT) + resp = requests.post( + f'{BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + }, + timeout=30, + ) + assert resp.status_code == 200, f'token request failed: {resp.status_code} {resp.text}' + cls.auth = {'Authorization': f'Bearer {resp.json()["access_token"]}'} + + def test_01_the_secure_file_turns_authentication_on(self): + """Reverting auth.enabled in the secure file must fail here. + + No other test would notice: they all pass auth.enabled themselves. + """ + resp = requests.get(f'{BASE_URL}/areas', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + 'the secure profile served /areas to an anonymous caller' + ) + + def test_02_the_secure_file_covers_reads_not_just_writes(self): + """Pins require_auth_for: "all" as the secure profile's value. + + Under "write" every one of these answers 200 without a credential. + """ + for path in ('/', '/areas', '/components', '/apps', '/functions', '/version-info'): + with self.subTest(path=path): + resp = requests.get(f'{BASE_URL}{path}', timeout=15) + self.assertIn(resp.status_code, (401, 403)) + + def test_03_health_refuses_in_the_secure_file_too(self): + """The secure file opens nothing, health included. + + `auth.public_routes` is absent from the secure profile, so the file + that a closed deployment starts from leaves no route reachable without + a credential. An operator who wants a probe route adds the entry + themselves - that path is covered in test_closed_by_default. + + Pinned here separately from the sweep above because health is the route + a hardening change is most tempted to leave open, and a secure profile + that quietly did so would still pass every other test in this class. + """ + resp = requests.get(f'{BASE_URL}/health', timeout=15) + self.assertIn( + resp.status_code, (401, 403), + f'the secure profile answered GET /health with {resp.status_code} ' + 'to a caller holding no credential' + ) + + def test_04_a_configured_client_still_works(self): + """The mirror: a gateway that refused everyone would pass the rest.""" + resp = requests.get(f'{BASE_URL}/areas', headers=self.auth, timeout=15) + self.assertEqual(resp.status_code, 200) + + def test_05_the_secure_file_is_the_one_under_test(self): + """Guard against this test silently drifting off the real file. + + If the installed config stops declaring the values this file exists to + check, the assertions above would still pass for the wrong reason. + + Read through a YAML parse and addressed by key path. A substring search + cannot do this job: ``enabled: true`` occurs under several different + parents in this file, so a text guard for it stays green with + ``auth.enabled: false`` - the one drift this test exists to catch. The + parse also settles ``public_routes`` for free, because a commented + example is not a key and a key written in flow style still is one. + """ + with open(SECURE_PARAMS, encoding='utf-8') as handle: + document = yaml.safe_load(handle) + params = document['ros2_medkit_gateway']['ros__parameters'] + auth = params['auth'] + self.assertIs( + auth['enabled'], True, + f"the secure profile sets auth.enabled to {auth['enabled']!r}" + ) + self.assertEqual( + auth['require_auth_for'], 'all', + 'the secure profile sets auth.require_auth_for to ' + f'{auth["require_auth_for"]!r}' + ) + self.assertIs( + params['server']['tls']['enabled'], True, + 'the secure profile sets server.tls.enabled to ' + f"{params['server']['tls']['enabled']!r}" + ) + # No route is opened by the file itself. An entry here would be a + # public route in every deployment that uses this profile, which is + # exactly what it exists to stop. + self.assertNotIn( + 'public_routes', auth, + 'the secure profile declares public routes: ' + f'{auth.get("public_routes")!r}' + ) + + +@launch_testing.post_shutdown_test() +class TestSecureProfileShutdown(unittest.TestCase): + """Gateway exits cleanly.""" + + def test_exit_codes(self, proc_info, gateway_node): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=gateway_node + ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_tls_protocol_floor.test.py b/src/ros2_medkit_integration_tests/test/features/test_tls_protocol_floor.test.py new file mode 100644 index 000000000..867ea7373 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_tls_protocol_floor.test.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check the TLS protocol floor and client-certificate verification. + +Both are driven by a real client against a real gateway. + +Both properties are about what happens during the TLS handshake, before any +HTTP request exists, so they cannot be observed from Python's requests or from +a unit test that checks a setter was called. Every assertion here comes from +``openssl s_client`` completing or failing a handshake at a pinned version. + +The client is run with ``-cipher ALL:@SECLEVEL=0``. Without it a modern +OpenSSL client refuses to OFFER TLS 1.0/1.1 on its own, and the test would pass +while proving nothing about the server: it has to be the server that says no. + +Two gateways run side by side, one with min_version 1.2 and one with 1.3, so +the floor is shown to MOVE with the setting, which a single reading cannot +separate from it happening to sit where +OpenSSL's own default put it. + +@verifies REQ_INTEROP_086 +""" + +import os +import re +import shutil +import socket +import subprocess +import tempfile +import time +import unittest + +import launch +import launch_testing +import launch_testing.actions +import pytest + +from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES, get_test_port +from ros2_medkit_test_utils.launch_helpers import create_gateway_node + +PORT_TLS12 = get_test_port(0) +PORT_TLS13 = get_test_port(1) +PORT_MTLS = get_test_port(2) + +_CERT_DIR = tempfile.mkdtemp(prefix='medkit_tls_floor_') + + +def _run(*args): + subprocess.run(args, check=True, capture_output=True) + + +def _make_ca(name): + """Build a CA key plus its self-signed certificate.""" + key = os.path.join(_CERT_DIR, f'{name}-ca-key.pem') + crt = os.path.join(_CERT_DIR, f'{name}-ca.pem') + _run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', crt, '-days', '1', + '-subj', f'/CN=medkit-test-{name}-ca') + return key, crt + + +def _make_leaf(name, ca_key, ca_crt, cn): + """Build a leaf key and certificate signed by the given CA.""" + key = os.path.join(_CERT_DIR, f'{name}-key.pem') + csr = os.path.join(_CERT_DIR, f'{name}.csr') + crt = os.path.join(_CERT_DIR, f'{name}.pem') + _run('openssl', 'req', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', csr, '-subj', f'/CN={cn}') + _run('openssl', 'x509', '-req', '-in', csr, '-CA', ca_crt, '-CAkey', ca_key, + '-CAcreateserial', '-out', crt, '-days', '1') + return key, crt + + +# The CA that signs the server certificate and the legitimate client. +CA_KEY, CA_CRT = _make_ca('trusted') +SRV_KEY, SRV_CRT = _make_leaf('server', CA_KEY, CA_CRT, 'localhost') +CLI_KEY, CLI_CRT = _make_leaf('client', CA_KEY, CA_CRT, 'medkit-test-client') + +# A second CA the gateway was never told about, for the certificate that is +# well-formed and correctly signed but by the wrong authority. +ROGUE_KEY, ROGUE_CRT = _make_ca('rogue') +ROGUE_CLI_KEY, ROGUE_CLI_CRT = _make_leaf('rogue-client', ROGUE_KEY, ROGUE_CRT, 'rogue') + + +def _tls_params(port, min_version, ca_file=''): + params = { + 'server.host': '127.0.0.1', + 'server.tls.enabled': True, + 'server.tls.cert_file': SRV_CRT, + 'server.tls.key_file': SRV_KEY, + 'server.tls.min_version': min_version, + # Auth off: this file is about the handshake, and a 401 would arrive + # long after the point under test has already been decided. + 'auth.enabled': False, + } + if ca_file: + params['server.tls.ca_file'] = ca_file + return params + + +@pytest.mark.launch_test +def generate_test_description(): + """Three gateways: floor at 1.2, floor at 1.3, and one demanding a client cert.""" + nodes = [ + create_gateway_node(port=PORT_TLS12, name='gateway_tls12', + extra_params=_tls_params(PORT_TLS12, '1.2')), + create_gateway_node(port=PORT_TLS13, name='gateway_tls13', + extra_params=_tls_params(PORT_TLS13, '1.3')), + create_gateway_node(port=PORT_MTLS, name='gateway_mtls', + extra_params=_tls_params(PORT_MTLS, '1.2', ca_file=CA_CRT)), + ] + return launch.LaunchDescription(nodes + [launch_testing.actions.ReadyToTest()]), { + 'gateway_tls12': nodes[0], + 'gateway_tls13': nodes[1], + 'gateway_mtls': nodes[2], + } + + +def _handshake(port, version, client_cert=None, client_key=None, timeout=20): + """Attempt one handshake. True only when a cipher was actually agreed. + + `openssl s_client` exits 0 in cases where no session was established, and + it prints the protocol it ATTEMPTED whether or not the server accepted it. + "Cipher is (NONE)" is the reliable tell for a handshake that did not + complete, so that is what is read here; the exit status says less. + """ + cmd = ['openssl', 's_client', f'-{version}', + '-cipher', 'ALL:@SECLEVEL=0', + '-connect', f'127.0.0.1:{port}'] + if client_cert: + cmd += ['-cert', client_cert, '-key', client_key] + try: + proc = subprocess.run(cmd, input=b'', capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired: + return False + out = (proc.stdout + proc.stderr).decode(errors='replace') + + # "Cipher is " is not proof that the handshake completed. Under TLS + # 1.2 the cipher suite is agreed before the client certificate is examined, + # so a server that then rejects the certificate still leaves a cipher name + # in the output, followed by a fatal alert: a client with no certificate + # against this gateway prints "Cipher is ECDHE-RSA-AES256-GCM-SHA384" AND + # "sslv3 alert handshake failure", while curl on the same endpoint gets no + # HTTP response at all. + # + # The fatal alert is therefore the signal, and "Cipher is (NONE)" covers + # the case where the version itself was refused before any suite was + # picked. + if 'Cipher is (NONE)' in out: + return False + if re.search(r'alert (handshake failure|protocol version|certificate|unknown ca)', out): + return False + return 'Cipher is ' in out + + +def _free_port(): + """Return a port nothing is listening on, for the control server above.""" + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + return sock.getsockname()[1] + + +def _wait_listening(port, timeout=60.0): + """Block until the port accepts a TCP connection. + + launch_testing starts the tests as soon as the processes are spawned, not + when they are serving, and a gateway that is not listening yet refuses + every connection. That looks identical to "the server rejected this + handshake", so without this gate the refusal assertions pass for the wrong + reason and the acceptance assertions fail at random, differently each run. + + TCP only, deliberately. A TLS handshake cannot be the readiness probe here + because on the mutual-TLS gateway a probe without a client certificate is + supposed to fail. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(('127.0.0.1', port), timeout=2): + return + except OSError: + time.sleep(0.25) + raise AssertionError(f'gateway on port {port} never started listening within {timeout}s') + + +class TestTlsProtocolFloor(unittest.TestCase): + """The floor moves with min_version, and it is the server that enforces it.""" + + @classmethod + def setUpClass(cls): + _wait_listening(PORT_TLS12) + _wait_listening(PORT_TLS13) + + def test_01_floor_12_accepts_12_and_13(self): + """The mirror of the refusals below. + + Without this, a gateway that refused every version would pass the + whole file while serving nobody. + """ + self.assertTrue(_handshake(PORT_TLS12, 'tls1_2'), 'TLS 1.2 must be accepted at floor 1.2') + self.assertTrue(_handshake(PORT_TLS12, 'tls1_3'), 'TLS 1.3 must be accepted at floor 1.2') + + def test_01b_the_client_actually_offers_the_old_versions(self): + """Guard the negative assertions below against becoming vacuous. + + `_handshake` returns False both when the SERVER refuses and when the + client never put a ClientHello on the wire. A modern OpenSSL will not + offer TLS 1.0/1.1 unless `-cipher ALL:@SECLEVEL=0` persuades it, and on + a distro built `no-tls1 no-tls1_1`, or under a crypto policy pinning + MinProtocol, it cannot offer them at all. In either case test_02 below + would pass against a gateway happily serving TLS 1.0. + + So: stand up a plain `openssl s_server` that accepts everything, and + require the client to reach 1.0 and 1.1 against it. If it cannot, the + refusals in test_02 prove nothing, and this fails to say so. + """ + for version in ('tls1', 'tls1_1'): + with self.subTest(version=version): + port = _free_port() + server = subprocess.Popen( + ['openssl', 's_server', '-accept', str(port), '-quiet', + '-cert', SRV_CRT, '-key', SRV_KEY, + '-cipher', 'ALL:@SECLEVEL=0', f'-{version}'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + try: + _wait_listening(port, timeout=15) + self.assertTrue( + _handshake(port, version), + f'this client cannot offer {version} at all, so the ' + f'{version} refusals in test_02 would pass against a ' + 'gateway that accepts it' + ) + finally: + server.terminate() + server.wait(timeout=10) + + def test_02_floor_12_refuses_11_and_10(self): + """SOVD requires TLS 1.2 as the minimum, so 1.1 and 1.0 must not connect. + + The vendored cpp-httplib asks OpenSSL for a floor of TLS 1.1 + (SSL_CTX_set_min_proto_version(ctx_, TLS1_1_VERSION)), so without the + gateway setting its own floor this is the version that decides whether + we comply, and it is not a value this project chose. + """ + self.assertFalse(_handshake(PORT_TLS12, 'tls1_1'), 'TLS 1.1 must be refused at floor 1.2') + self.assertFalse(_handshake(PORT_TLS12, 'tls1'), 'TLS 1.0 must be refused at floor 1.2') + + def test_03_floor_13_refuses_12(self): + """The test that fails if min_version is inert. + + A gateway configured for 1.3 that still completes a 1.2 handshake has + read the value, logged it and ignored it - which is indistinguishable + from a correct one unless somebody tries the lower version. TLS 1.2 is + accepted by the OTHER gateway in this same launch, so a failure here + cannot be blamed on the client or on the certificate. + """ + self.assertFalse(_handshake(PORT_TLS13, 'tls1_2'), 'TLS 1.2 must be refused at floor 1.3') + self.assertFalse(_handshake(PORT_TLS13, 'tls1_1'), 'TLS 1.1 must be refused at floor 1.3') + + def test_04_floor_13_accepts_13(self): + self.assertTrue(_handshake(PORT_TLS13, 'tls1_3'), 'TLS 1.3 must be accepted at floor 1.3') + + +class TestMutualTls(unittest.TestCase): + """With ca_file set, a client certificate is required and verified.""" + + @classmethod + def setUpClass(cls): + _wait_listening(PORT_MTLS) + _wait_listening(PORT_TLS12) + + def test_05_no_client_certificate_is_refused(self): + """ca_file set means SSL_VERIFY_FAIL_IF_NO_PEER_CERT: no cert, no session.""" + self.assertFalse( + _handshake(PORT_MTLS, 'tls1_2'), + 'a client presenting no certificate must not complete the handshake' + ) + + def test_06_a_certificate_from_the_configured_ca_is_accepted(self): + self.assertTrue( + _handshake(PORT_MTLS, 'tls1_2', client_cert=CLI_CRT, client_key=CLI_KEY), + 'a client certificate signed by the configured CA must be accepted' + ) + + def test_07_a_certificate_from_another_ca_is_refused(self): + """Well-formed and correctly signed, but by an authority we never trusted. + + This separates "verification is on" from "any certificate will do", + which test_05 alone cannot. + """ + self.assertFalse( + _handshake(PORT_MTLS, 'tls1_2', client_cert=ROGUE_CLI_CRT, client_key=ROGUE_CLI_KEY), + 'a client certificate from an unconfigured CA must be refused' + ) + + def test_08_a_gateway_without_ca_file_does_not_demand_one(self): + """The default stays server-only TLS. + + SOVD authenticates with bearer tokens, so requiring a client + certificate by default would put us outside the spec. mTLS is opt-in + and this pins that it is. + """ + self.assertTrue( + _handshake(PORT_TLS12, 'tls1_2'), + 'a gateway with no ca_file must still serve a client that has no certificate' + ) + + +@launch_testing.post_shutdown_test() +class TestTlsFloorShutdown(unittest.TestCase): + """All three gateways exit cleanly.""" + + def test_exit_codes(self, proc_info, gateway_tls12, gateway_tls13, gateway_mtls): + for proc in (gateway_tls12, gateway_tls13, gateway_mtls): + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES, process=proc) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(_CERT_DIR, ignore_errors=True) From e11ec25ed0a9289d4d6ad03e66b2bbb1ab8af7c8 Mon Sep 17 00:00:00 2001 From: bburda Date: Sun, 13 Sep 2026 13:55:54 +0200 Subject: [PATCH 4/4] docs: describe the two profiles, the auth environment rule and the image 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- 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. --- docs/api/rest.rst | 25 ++- docs/config/aggregation.rst | 15 ++ docs/config/server.rst | 97 ++++++++- docs/tutorials/authentication.rst | 67 +++++- docs/tutorials/docker.rst | 206 +++++++++++++++++-- docs/tutorials/https.rst | 67 +++++- src/ros2_medkit_gateway/README.md | 4 +- src/ros2_medkit_gateway/design/hardening.rst | 59 ++++-- 8 files changed, 495 insertions(+), 45 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 5f55fb862..0784e6920 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -2869,16 +2869,37 @@ Endpoint limits can also be overridden with patterns: Response Headers ~~~~~~~~~~~~~~~~ -When rate limiting is enabled, the gateway includes the following HTTP response headers on every check: +When rate limiting is enabled, the gateway includes the following HTTP response +headers on answers to callers the limiter may speak to - a caller whose +credential it accepted, or any caller on a route that needs none: - ``X-RateLimit-Limit``: The effective RPM limit applied. - ``X-RateLimit-Remaining``: Number of requests remaining in the current minute window. - ``X-RateLimit-Reset``: Unix epoch time (in seconds) when the limit bucket resets. +They are withheld from a refusal the gateway makes before it has accepted +anybody. The allowance, the reset time and the retry delay are limiter state, +and a ``401`` for a missing credential or the ``429`` described below carries +none of it. + Rejection (429 Too Many Requests) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If a request exceeds the available tokens, it is rejected with an HTTP 429 status code and a ``Retry-After`` header indicating the number of seconds to wait before retrying. +If a request exceeds the available tokens, it is rejected with an HTTP 429 status code. On a route that needs no credential the answer also carries a ``Retry-After`` header with the number of seconds to wait before retrying; on a protected route it does not, whatever credential the caller holds. + +One refusal is narrower. On a protected route, a caller whose allowance is gone +and who presented an ``Authorization`` header is answered ``429`` **before** the +token is verified, so an over-limit caller costs the gateway no signature check. +Nothing about that caller has been verified at that point, so the answer carries +no ``Retry-After``, no ``X-RateLimit-*``, and an empty ``parameters`` object. A +caller presenting no header at all keeps the ordinary ``401``. + +The credential makes no difference here: the limiter refuses before it is +examined, so a client holding a perfectly good token gets the same bare +``429``. **On a protected route there is no retry hint**, which is what a +closed-profile client has to be built around. Pace from the +``X-RateLimit-Reset`` on the last answer that carried one - any ``2xx`` does. +On a protected route no ``Retry-After`` arrives. **Example Response:** diff --git a/docs/config/aggregation.rst b/docs/config/aggregation.rst index 80e46e46d..bd61a71e7 100644 --- a/docs/config/aggregation.rst +++ b/docs/config/aggregation.rst @@ -154,6 +154,21 @@ for peer communication. ``false`` (default), auth tokens are **never** sent to peers - this prevents token leakage to untrusted or mDNS-discovered peers. Only enable when all peers are trusted and share the same JWT configuration. + + Sharing the JWT configuration means the signing secret, the algorithm + and the issuer, and also ``auth.token_expiry_seconds`` and + ``auth.refresh_token_expiry_seconds``. A peer records a revocation for a + token it did not issue and holds it for that token's refresh expiry plus + its OWN access expiry, so a peer configured with a shorter access expiry + drops the record while the issuer's tokens are still inside theirs, and + the revocation lapses there. The refresh expiry matters the same way: a + peer keeps a foreign record for at most its own refresh lifetime, so an + issuer with a longer one can go on refreshing a token the peer has + already forgotten. + + The role a forwarded token grants is the peer's own: each gateway reads + ``sub`` against its ``auth.clients`` and applies the role listed there, + so the ``role`` claim in the token does not travel. * - ``aggregation.peer_auth_header`` - string - ``""`` diff --git a/docs/config/server.rst b/docs/config/server.rst index 0171b268a..fb6ec8986 100644 --- a/docs/config/server.rst +++ b/docs/config/server.rst @@ -129,7 +129,7 @@ TLS/HTTPS Configuration * - ``server.tls.ca_file`` - string - ``""`` - - Path to CA certificate file (reserved for mutual TLS). + - CA that signs CLIENT certificates. Setting it turns on mutual TLS and makes a client certificate **required**: a caller that presents none is rejected during the handshake, before any request is read, so every bearer-token client without a certificate goes off the air. Leave empty for ordinary server-only TLS. * - ``server.tls.min_version`` - string - ``"1.2"`` @@ -844,8 +844,11 @@ See :doc:`/api/rest` for rate limiting response headers and 429 behavior. Authentication -------------- -JWT-based authentication with Role-Based Access Control (RBAC). Disabled by -default for local development. +JWT-based authentication with Role-Based Access Control (RBAC). Off in +``config/gateway_params.yaml`` and on in ``config/gateway_params.secure.yaml``, +which also sets ``require_auth_for`` to ``all``. With authentication on and no +``jwt_secret`` the gateway refuses to start, because a half-protected gateway +is the one outcome the setting exists to prevent. .. list-table:: :header-rows: 1 @@ -862,11 +865,11 @@ default for local development. * - ``auth.jwt_secret`` - string - ``""`` - - JWT signing secret. For HS256: the shared secret string. For RS256: path to the private key file (PEM format). + - JWT signing secret. For HS256: the shared secret string. For RS256: path to the private key file (PEM format), read once at startup, so a rotated key takes effect at the next restart. Setting ``MEDKIT_JWT_SECRET`` while the algorithm is RS256 is refused at startup: the variable carries a secret and RS256 wants a path. * - ``auth.jwt_public_key`` - string - ``""`` - - Path to public key file for RS256. Required for RS256, optional for HS256. + - Path to public key file for RS256. Required for RS256, optional for HS256. Read once at startup - it verifies every token, so re-reading it per request would charge each one a file open - which means a rotated key takes effect at the next restart. * - ``auth.jwt_algorithm`` - string - ``"HS256"`` @@ -874,11 +877,19 @@ default for local development. * - ``auth.token_expiry_seconds`` - int - ``3600`` - - Access token validity period in seconds (1 hour). + - Access token validity period in seconds (1 hour). Under + ``aggregation.forward_auth``, a peer's value must be at least the issuing + gateway's: a revocation the peer records for a token it did not issue is + held for this long past that token's refresh expiry, so a shorter value + here lets the revocation lapse while the issuer's token still verifies. * - ``auth.refresh_token_expiry_seconds`` - int - ``86400`` - Refresh token validity period in seconds (24 hours). Must be >= ``token_expiry_seconds``. + Gateways sharing a signing configuration should share this value too: a + revocation recorded here for a token another gateway issued is kept no + longer than this value, so an issuer with a longer one can go on + refreshing a token this gateway has already forgotten. * - ``auth.require_auth_for`` - string - ``"write"`` @@ -891,6 +902,38 @@ default for local development. - string[] - ``[]`` - Pre-configured clients as ``"client_id:client_secret:role"`` strings. + * - ``auth.public_routes`` + - string[] + - ``[]`` + - Routes answered with no credential, each written ``"METHOD /path"``. Layers over ``require_auth_for`` and only ever removes a requirement. Matched exactly, no wildcards. Every entry is logged at ``WARN`` on startup, and a malformed entry stops the gateway - whatever ``auth.enabled`` says, because a list that cannot be read is a configuration error whenever it is set. A blank entry is skipped, so ``[""]`` (how a ROS 2 YAML file writes an empty sequence) means no routes. + +What a token is checked against +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +An access token is accepted when all three of these hold: the signature +verifies under the configured secret, the expiry is in the future, and the +client named in ``sub`` exists here and is enabled. The role it grants is the +one **this** gateway's ``auth.clients`` gives that client; the ``role`` claim in +the token says what the issuing gateway granted and is not consulted. + +The gateway also keeps a record of every refresh token it has issued, and that +record is a **denylist**: a record marked revoked refuses the access tokens +minted from it, and a token the gateway holds no record of is judged on the +three checks above. + +Two deployments depend on the second half. A gateway that has restarted holds +no records - they live in process memory - so an allowlist would log every +client out at every restart. And under ``aggregation.forward_auth`` a peer +receives tokens another gateway minted, which it will never have a record of; +see :doc:`/config/aggregation`. + +The cost, stated so nobody is surprised by it: **a revoked token is honoured +again after a restart**, for at most ``auth.token_expiry_seconds`` - the +longest a live access token can outlast the record that was lost. While the +process runs, a revocation holds for the full life of every token it withdraws. +Where a revocation must survive a restart, disable the client +(``auth.clients``): that check runs on every request and is read from +configuration, so it holds across a restart. .. note:: @@ -922,6 +965,48 @@ Example: token_expiry_seconds: 3600 clients: ["admin:REPLACE_WITH_STRONG_SECRET:admin", "viewer:REPLACE_WITH_STRONG_SECRET:viewer"] +Opening a route to uncredentialed callers +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Under ``require_auth_for: "all"`` - the secure profile's setting - the only +routes answered without a credential are ``/api/v1/auth/*``, because +authentication cannot bootstrap through a door that demands the credential it +hands out. Health is not special and is refused like everything else. + +When something that cannot hold a credential has to reach a route, name it: + +.. code-block:: yaml + + auth: + public_routes: ["GET /api/v1/health"] + +Matching is exact. ``GET /api/v1/health`` opens that method on that path and +nothing else - not ``HEAD``, not ``/api/v1/healthz``, not the subtree. Wildcards +are rejected, because accepting one and matching it literally would open +nothing while reading as though it opened a subtree. A malformed entry stops +the gateway, where dropping it silently would leave a route protected that the +operator believes is reachable. + +An anonymous caller on such a route gets a reduced body: ``GET /health`` answers +with five keys and no more: ``status``, ``timestamp``, ``warnings`` (always +empty here), ``warning_schema_version``, and ``x-medkit-reduced: true`` so a +monitor can tell a withheld answer from a clean one. A credential still returns +the whole document. + +The cut follows this list. Under ``require_auth_for: "write"`` every GET is +answered without a credential because of the requirement level, and no route +was singled out, so ``GET /health`` returns its full body there - discovery, +entity cache, linking and all. + +One case is wider than the list: with authentication enabled and the auth +manager unavailable, ``/health`` answers the reduced body whatever the route. +That path means the gateway cannot evaluate the question at all, and liveness +is what it says when the check itself is unavailable. + +Most liveness probes need no entry at all. A ``401`` already proves the process +is up and answering HTTP, so a probe that accepts ``200``, ``401`` and ``403`` +works against any auth configuration and leaves nothing open. Prefer that. + See :doc:`/tutorials/authentication` for a complete setup tutorial. Plugin Framework diff --git a/docs/tutorials/authentication.rst b/docs/tutorials/authentication.rst index 301ab65bd..53c5a5a06 100644 --- a/docs/tutorials/authentication.rst +++ b/docs/tutorials/authentication.rst @@ -11,8 +11,22 @@ Role-Based Access Control (RBAC) in ros2_medkit_gateway. Overview -------- -By default, the gateway runs without authentication for easy development. -For production deployments, you should enable authentication to: +By default, the gateway runs without authentication for easy development: +``config/gateway_params.yaml`` leaves ``auth.enabled`` false and +``require_auth_for`` at ``"write"``. ``config/gateway_params.secure.yaml`` is +the profile that turns it on, together with TLS and ``require_auth_for`` +``"all"``: + +.. code-block:: bash + + ros2 launch ros2_medkit_gateway gateway.launch.py \ + config_file:=$(ros2 pkg prefix --share ros2_medkit_gateway)/config/gateway_params.secure.yaml \ + jwt_secret:= \ + auth_clients:=::admin \ + cert_file:= key_file:= + +Turn authentication on for any deployment reachable beyond the machine it runs +on, to: - Control who can access the API - Limit write operations to authorized users @@ -205,6 +219,55 @@ Response: -H "Content-Type: application/json" \ -d '{"token": "dGhpcyBpcyBhIHJlZnJlc2g..."}' +What Survives a Restart +----------------------- + +**Access tokens survive.** They are judged on three things: the signature +verifies under the configured secret, the expiry is in the future, and the +client named in ``sub`` exists and is enabled here. None of that depends on the +process that issued the token, so a client keeps working across a gateway +restart until its token expires on its own. + +**Refresh tokens do not.** Exchanging one requires the record the issuing +gateway wrote when it minted the pair, and those records live in that process's +memory. After a restart, ``POST /auth/token`` with a refresh token answers +``invalid_grant`` and the client re-authenticates with its client id and secret. + +The refresh records are a **denylist**: a record held and marked revoked refuses +the access tokens minted from it, and a record the gateway does not hold says +nothing either way. Two consequences follow, and both are worth knowing: + +**A revoked token comes back after a restart**, for at most +``auth.token_expiry_seconds`` - the longest a live access token can outlast the +record that withdrew it. While the process runs, a revocation holds for the +whole life of every token the gateway ITSELF issued. + +For a token another gateway issued, the record is held until that token's +refresh expiry - which the token carries - plus this gateway's own +``auth.token_expiry_seconds``, the only access lifetime it knows. So gateways +that share a signing configuration must also share +``auth.token_expiry_seconds`` and ``auth.refresh_token_expiry_seconds``: where +a peer's access expiry is shorter than the issuer's, the peer drops the record +at a point the issuer's tokens can outlive, and the revocation lapses there +while the token still verifies; and a peer keeps such a record for at most its +own refresh lifetime, so an issuer's longer refresh expiry lets it go on +refreshing a token the peer has forgotten. Where a revocation has to survive a +restart, disable the client instead: ``auth.clients`` is read from +configuration and the check runs on every request. + +**Under** ``aggregation.forward_auth`` **revocation is per gateway.** Peers +share a JWT configuration, so a token the aggregator minted is accepted by the +peer, and ``POST /auth/revoke`` on the peer refuses it *there* - the peer writes +a revoked record for a token it never issued precisely so that works. It does +not reach the aggregator, which shares no record store with it. A token revoked +on one gateway stays valid on every other until it expires or the client is +disabled there too. + +The role a token grants is also per gateway: each one reads ``sub`` against its +own ``auth.clients`` and grants the role listed there, so the ``role`` claim in +the token does not travel. A client that is ``admin`` on the aggregator and +``viewer`` on a peer may only read on the peer. + Production Recommendations -------------------------- diff --git a/docs/tutorials/docker.rst b/docs/tutorials/docker.rst index 84c042e67..a0a1d3b4f 100644 --- a/docs/tutorials/docker.rst +++ b/docs/tutorials/docker.rst @@ -36,6 +36,12 @@ Images are available for all supported ROS 2 distributions: * - Lyrical - ``ghcr.io/selfpatch/ros2_medkit-lyrical:latest`` +Every push to ``main`` moves ``:latest`` and also publishes +``:main-`` - the same image under the short commit hash it was built +from, which is what to pin when ``:latest`` moving underneath a deployment is +not acceptable. Release tags carry the semver tags and ``:sha-``, which +name a multi-architecture manifest list; this one is amd64-only. + Each image includes the gateway and all open-core packages: - ``ros2_medkit_gateway`` - HTTP REST server @@ -64,13 +70,113 @@ Test the gateway: curl http://localhost:8080/api/v1/version-info # {"items":[{"version":"","vendor_info":{"name":"ros2_medkit",...}}]} +The image carries ``config/gateway_params.yaml``, the same file a source +install gets, so it answers without a credential like a source install does. +Publish the port only where that is acceptable. + +Running the container closed +---------------------------- + +Set ``MEDKIT_JWT_SECRET`` and the container runs with authentication on, +``require_auth_for`` ``all``, and the secret you gave it. ``MEDKIT_CLIENTS`` +carries the credentials a client exchanges for a token: + +.. code-block:: bash + + docker run -p 8080:8080 \ + -e MEDKIT_JWT_SECRET="$(head -c 32 /dev/urandom | base64)" \ + -e MEDKIT_CLIENTS="medkit:$(head -c 24 /dev/urandom | base64):admin" \ + ghcr.io/selfpatch/ros2_medkit-jazzy:latest + +The environment contract +^^^^^^^^^^^^^^^^^^^^^^^^ + +Three variables, read by the gateway itself when it reads its parameters. That +is why they hold on every way of starting it - the default ``docker run``, an +arguments-only override, ``docker run ros2 launch ros2_medkit_gateway +bringup.launch.py``, a source install started with ``ros2 run`` - and every +other path, including those a container entrypoint never sees. + +``MEDKIT_AUTH_DISABLED`` + Set to exactly ``1``, authentication is off and every route is readable by + anyone who can reach the port. It wins over everything: the params file, a + launch argument, and ``MEDKIT_JWT_SECRET``. No other value means anything - + ``true``, ``yes`` and ``0`` all leave it unset in effect. + + This is not a container-only switch. The gateway reads it wherever it runs, so + the variable **opens any gateway**, including a source install started from + ``gateway_params.secure.yaml``. Treat the ability to set it on a gateway's + environment as equivalent to the ability to turn its authentication off, + because it is. + +``MEDKIT_JWT_SECRET`` + Non-empty, and with ``MEDKIT_AUTH_DISABLED`` not set to ``1``: authentication + is **on**, ``auth.require_auth_for`` is **``all``**, and this is the signing + secret. It overrides ``auth.enabled``, ``auth.require_auth_for`` and + ``auth.jwt_secret`` from any params file. ``"write"`` would leave every read + open, and the entity tree, the fault history and the operation list are the + disclosure. + +``MEDKIT_CLIENTS`` + The credentials that can be exchanged for a token, read only in the case + above. Entries are separated by **commas**; each is written + ``id:secret:role``. The id is everything before the first colon and the role + everything after the last, so **a secret may contain colons while an id and a + role may not, and no field may contain a comma**. The role is one of + ``viewer``, ``operator``, ``configurator``, ``admin``. Surrounding spaces and + tabs are dropped from each entry, so ``a:b:admin, c:d:viewer`` works; a space + inside a field belongs to that field. + + A comma in a secret ends the entry there, so the rest of that secret is read + as the start of the next entry and both are refused - generate secrets from an + alphabet without commas, or the credential silently is not the one you set. + + An entry that does not parse, or that repeats an id an earlier entry claimed, + is dropped and named by its position in a ``WARN`` line; the rest are still + registered, and for a duplicated id the first entry stands. If **every** entry + is refused the gateway does not start, because a gateway closed to its own + operator is a misconfiguration, and never a posture somebody chose. + + When ``MEDKIT_CLIENTS`` is set it replaces ``auth.clients`` entirely - the + empty string included, which leaves no client able to obtain a token and warns + that it has. Unset it to keep the file's clients. + +Whatever the environment overrides, the gateway logs at ``WARN`` on startup, so +the posture a container is running is in its first few lines of output. + +The image also carries the closed profile - TLS, rate limiting and the rest - +which you can point ``--params-file`` at. It binds **8443**, not 8080: + +.. code-block:: bash + + CLIENT_SECRET="$(head -c 24 /dev/urandom | base64)" + docker run -p 8443:8443 \ + -v ./certs:/etc/ros2_medkit/certs:ro \ + -e MEDKIT_JWT_SECRET="$(head -c 32 /dev/urandom | base64)" \ + -e MEDKIT_CLIENTS="medkit:${CLIENT_SECRET}:admin" \ + ghcr.io/selfpatch/ros2_medkit-jazzy:latest \ + --ros-args --params-file \ + /home/medkit/ws/install/ros2_medkit_gateway/share/ros2_medkit_gateway/config/gateway_params.secure.yaml \ + -p server.tls.cert_file:=/etc/ros2_medkit/certs/cert.pem \ + -p server.tls.key_file:=/etc/ros2_medkit/certs/key.pem + +That profile enables TLS, so the container needs a certificate and key or it +refuses to start. The client logs in with ``CLIENT_SECRET``; the signing secret +is needed by nothing outside the container. + +It also needs a secret and a client, and **the mount point cannot supply them**: +the entrypoint puts ``/etc/ros2_medkit/params.yaml`` in front of your +arguments, so the secure profile named after it wins and its empty +``jwt_secret`` stops the gateway. Supply them from the environment as above, or +name a second ``--params-file`` **after** the secure one on the command line. + Custom Configuration -------------------- -The default configuration listens on ``0.0.0.0:8080``. CORS is enabled for the -default web UI origins (``http://localhost:3000`` and ``http://localhost:5173``) -so the web UI works out of the box; add your own UI origin(s) as needed (see -`CORS for Web UI`_ below). To use a custom configuration, mount a params file: +The container listens on ``0.0.0.0:8080`` and refreshes discovery every 2 s - +the two values the image sets on top of the packaged config. CORS is off, so +a browser UI on another origin needs its origin named (see `CORS for Web UI`_ +below). To use a custom configuration, mount a params file: .. code-block:: bash @@ -93,12 +199,37 @@ Example ``my_params.yaml``: discovery: mode: "runtime_only" -You can also pass ROS arguments directly: +The mounted file is the **last** of three layers and wins over both below it: + +1. ``/etc/ros2_medkit/base.yaml`` - the packaged ``gateway_params.yaml``, the + same file a source install gets +2. ``/etc/ros2_medkit/container.yaml`` - ``server.host: "0.0.0.0"`` and + ``refresh_interval_ms: 2000`` +3. ``/etc/ros2_medkit/params.yaml`` - the mount point + +So a file naming only the keys you care about keeps everything else, and the +keys it does name take effect - ``server.host`` and ``refresh_interval_ms`` +included. The image passes neither key as a ``-p`` argument. In this container +the entrypoint's three files come first and your arguments after them, and +rclcpp applies the merged node entries in order of first appearance, so a +``-p`` you pass wins over the files; had the image put those two keys in front +of the files as ``-p`` arguments, a mounted file could set neither. + +You can also pass ROS arguments directly. The entrypoint puts the three layers +in front of whatever you pass, so an override changes the key it names and +nothing else - the container still binds ``0.0.0.0`` here: .. code-block:: bash docker run -p 9090:9090 ghcr.io/selfpatch/ros2_medkit-jazzy:latest \ - --ros-args --params-file /etc/ros2_medkit/params.yaml -p server.port:=9090 + --ros-args -p server.port:=9090 + +The three layers are keyed on the node's default name, ``ros2_medkit_gateway``, +and apply to that name only. A container that renames the node +(``--ros-args -r __node:=other``) gets none of them: it binds the packaged +``127.0.0.1``, refreshes at the packaged cadence, and a mounted file keyed on +the old name is inert. A renamed node needs a params file keyed on the new +name for every key it relies on. External Plugins ---------------- @@ -144,7 +275,21 @@ Build for a specific ROS 2 distribution: Docker Compose -------------- -Example ``docker-compose.yml`` with the gateway and web UI: +Example ``docker-compose.yml`` with the gateway and web UI. The gateway mounts a +params file for one reason: the image names no CORS origin, and the browser +loads the UI from ``http://localhost:3000`` while it calls the gateway on +``http://localhost:8080``. Those are different origins, so without the gateway +naming the UI's origin the browser blocks every call and the UI shows an empty +tree with no error a user can act on. + +``gateway-cors.yaml``, next to the compose file: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + cors: + allowed_origins: ["http://localhost:3000"] .. code-block:: yaml @@ -153,10 +298,20 @@ Example ``docker-compose.yml`` with the gateway and web UI: image: ghcr.io/selfpatch/ros2_medkit-jazzy:latest ports: - "8080:8080" + volumes: + - ./gateway-cors.yaml:/etc/ros2_medkit/params.yaml:ro environment: - ROS_DOMAIN_ID=42 healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"] + # Any HTTP answer proves the process is up, 401 included. `curl -f` + # exits non-zero on the refusal a closed container gives an + # uncredentialed probe, and reports a healthy container as sick. + test: + - CMD-SHELL + - >- + code=$$(curl -s -o /dev/null -w '%{http_code}' + http://localhost:8080/api/v1/health) && case "$$code" in + 200|401|403) exit 0 ;; *) exit 1 ;; esac interval: 10s timeout: 5s retries: 3 @@ -213,10 +368,12 @@ For containers to discover each other's ROS 2 nodes, use the same ``ROS_DOMAIN_I CORS for Web UI --------------- -The image enables CORS for the default web UI origins (``http://localhost:3000`` -and ``http://localhost:5173``). A wildcard is deliberately not used: with auth -disabled and write methods enabled it would let any site drive cross-origin -writes. Add your own UI origin(s): +The image names no CORS origin. A published image that allowed +``http://localhost:3000`` would be making a development machine's choice for +every deployment, so the origin a browser UI is served from is named by the +deployment that runs it. A wildcard is the wrong answer here: with auth off and +write methods enabled it would let any site drive cross-origin writes. Add your +own UI origin(s): .. code-block:: yaml @@ -230,17 +387,38 @@ writes. Add your own UI origin(s): Health Checks ------------- -The gateway exposes a health endpoint at ``/api/v1/health``: +The gateway exposes a health endpoint at ``/api/v1/health``. A container left +at the image default answers it without a credential; one running closed +refuses it, so a probe that has to work in both cases reads the status code +while accepting any HTTP answer as proof the process is up: .. code-block:: yaml healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"] + # 401 means the gateway is up and refused an uncredentialed probe, which + # is exactly what a liveness check wants to know. + test: + - CMD-SHELL + - >- + code=$$(curl -s -o /dev/null -w '%{http_code}' + http://localhost:8080/api/v1/health) && case "$$code" in + 200|401|403) exit 0 ;; *) exit 1 ;; esac interval: 10s timeout: 5s retries: 3 start_period: 15s +If a closed container has to answer a probe that cannot be changed - a load +balancer that only accepts 200, say - open the route explicitly instead: + +.. code-block:: yaml + + auth: + public_routes: ["GET /api/v1/health"] + +An anonymous caller then gets liveness only, marked ``x-medkit-reduced``. See +:doc:`/config/server` for what that setting does and does not open. + Production Considerations ------------------------- diff --git a/docs/tutorials/https.rst b/docs/tutorials/https.rst index 5604fbd8a..71a2c27e3 100644 --- a/docs/tutorials/https.rst +++ b/docs/tutorials/https.rst @@ -100,10 +100,73 @@ Configuration Options - Path to PEM-encoded private key * - ``server.tls.ca_file`` - ``""`` - - CA certificate (for future mutual TLS) + - CA that signs client certificates. Setting it turns on mutual TLS and + makes a client certificate **required**; leave empty for server-only TLS * - ``server.tls.min_version`` - ``"1.2"`` - - Minimum TLS version: ``"1.2"`` or ``"1.3"`` + - Minimum TLS version: ``"1.2"`` or ``"1.3"``. Enforced on the server's own + SSL context, so it is the floor regardless of what the local OpenSSL + policy would otherwise allow. Any other value is rejected at startup + +Defaults +-------- + +TLS is **off** in the shipped ``gateway_params.yaml`` and **on** in +``gateway_params.secure.yaml``, which leaves ``cert_file`` and ``key_file`` for +the deployment to fill in. A gateway with TLS enabled and no certificate +refuses to start, because a fallback to plaintext would serve in the clear +under a configuration that asked for encryption. Turning it on means +supplying one: + +.. code-block:: bash + + # turn it on for one run, with a certificate + ros2 launch ros2_medkit_gateway gateway.launch.py tls_enabled:=true \ + cert_file:=/path/to/cert.pem key_file:=/path/to/key.pem + + # or run the secure profile, which has TLS on already + ros2 launch ros2_medkit_gateway gateway.launch.py \ + config_file:=$(ros2 pkg prefix --share ros2_medkit_gateway)/config/gateway_params.secure.yaml \ + cert_file:=/path/to/cert.pem key_file:=/path/to/key.pem + +For a first run on a developer machine, ``scripts/generate_dev_certs.sh`` +writes a self-signed certificate and key. Browsers and ``curl`` will refuse it +until you pass the CA explicitly, which is the correct behaviour for a +certificate nothing has vouched for, not a problem to work around in +production. + +Mutual TLS +---------- + +Set ``ca_file`` to the CA that signs your client certificates and the gateway +requires one from **every** client: + +.. code-block:: yaml + + server: + tls: + enabled: true + cert_file: "/etc/ros2_medkit/certs/server.pem" + key_file: "/etc/ros2_medkit/certs/server-key.pem" + ca_file: "/etc/ros2_medkit/certs/client-ca.pem" + +This is all or nothing per gateway. A client that presents no certificate is +rejected during the handshake, before any request is read, and there is no +"verify it only if offered" setting. A client whose certificate is signed by +any other CA is rejected the same way. + +.. code-block:: bash + + # without a client certificate: no response, the handshake never completes + curl --cacert ca.pem https://localhost:8443/api/v1/areas + + # with one signed by ca_file + curl --cacert ca.pem --cert client.pem --key client-key.pem \ + https://localhost:8443/api/v1/areas + +Mutual TLS is transport-level and sits alongside token authentication rather +than replacing it. SOVD authenticates with bearer tokens, so leave ``ca_file`` +empty unless every client on that network can be issued a certificate. Using with curl --------------- diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index 5c0f55990..7d34b9a0b 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -1507,10 +1507,10 @@ TLS (Transport Layer Security) enables encrypted HTTPS communication. TLS is **d | `server.tls.enabled` | bool | `false` | Enable/disable TLS. When enabled, server uses HTTPS instead of HTTP. | | `server.tls.cert_file` | string | (required if enabled) | Path to PEM-encoded certificate file. | | `server.tls.key_file` | string | (required if enabled) | Path to PEM-encoded private key file. | -| `server.tls.ca_file` | string | `""` | Optional CA certificate (reserved for future mutual TLS support). | +| `server.tls.ca_file` | string | `""` | CA that signs CLIENT certificates. Setting it enables mutual TLS and REQUIRES a client certificate from every caller. | | `server.tls.min_version` | string | `"1.2"` | Minimum TLS version: `"1.2"` (compatible) or `"1.3"` (more secure). | -> **Note:** Mutual TLS (client certificate verification) is planned for a future release. +> **Note:** Mutual TLS is available: set `server.tls.ca_file`. **Roles and Permissions:** diff --git a/src/ros2_medkit_gateway/design/hardening.rst b/src/ros2_medkit_gateway/design/hardening.rst index 16311c0f3..69d479d81 100644 --- a/src/ros2_medkit_gateway/design/hardening.rst +++ b/src/ros2_medkit_gateway/design/hardening.rst @@ -35,6 +35,36 @@ Control Default Secure profile ``locking`` on operations none lock required before mutation ================================ ============== =========================================== +Two things the secure profile brings with it are worth stating plainly. + +**The gateway refuses to start without a signing secret.** With +``auth.enabled`` true and ``auth.jwt_secret`` empty it exits with "JWT secret is +required when authentication is enabled" (and HS256 additionally requires at +least 32 characters). That is the intended failure. A gateway that will not boot +is a deployment problem someone fixes in a minute; a gateway that booted +half-protected is one nobody notices. + +**Under ``require_auth_for: "all"`` only ``/api/v1/auth/*`` is exempt, and +health is not.** Auth is exempt because authentication cannot bootstrap through +a door that already demands the credential it exists to hand out. Everything +else, ``GET /api/v1/health`` included, needs a credential, so a container +supervisor, load balancer or browser UI that probes health without one gets +401. A probe that accepts 200, 401 and 403 works against either profile and +leaves nothing open; a probe that cannot be changed gets the route named for +it:: + + auth: + public_routes: ["GET /api/v1/health"] + +``auth.public_routes`` is empty in both profiles, which is what makes the +exemption a deployment decision, and never a property of the artefact. The +match is on method and path exactly, so the entry above opens +``GET /api/v1/health`` and neither ``HEAD`` nor ``/api/v1/health/detail``. An +anonymous caller on a route opened that way gets liveness only - ``status``, +``timestamp``, an empty ``warnings``, ``warning_schema_version`` and +``x-medkit-reduced: true`` - because the full body names entities and ROS +nodes. + Credential and certificate provisioning ---------------------------------------- @@ -48,25 +78,20 @@ Credential and certificate provisioning (HS256) or provision an RS256 key pair. Inject it at deploy time from a secret store or environment variable - do not commit it to source control. + .. note:: + + The gateway does not publish the secret as a parameter value. It declares + ``auth.jwt_secret`` with a placeholder that names the source + (```` or ````), so ``ros2 param get`` + and ``/parameter_events`` do not carry it. ``auth.clients`` and + ``aggregation.peer_auth_header`` get the same treatment. + .. warning:: - ``auth.jwt_secret`` is a plain readable ROS 2 parameter. Beyond source - control it is exposed on two planes: - - - **DDS control plane.** Any peer on the ROS 2 graph can read it with - ``ros2 param get / auth.jwt_secret`` - the parameter is - declared readable and the DDS domain is unauthenticated by default. - - **Process table.** Passing it inline (``-p auth.jwt_secret:=...`` as in - the launch example above) also leaks the value via ``ps`` and - ``/proc//cmdline``. - - Injecting from an environment variable / params file instead of an inline - ``-p`` closes the process-table leak, but the value still lands in a - readable parameter, so it remains exposed on the DDS plane. To close that, - lock down the control plane: ROS 2 security (SROS2) with an access-control - policy that denies parameter reads to untrusted participants, or a - dedicated / firewalled ``ROS_DOMAIN_ID`` (optionally with - ``ROS_LOCALHOST_ONLY=1``) that no untrusted peer can join. + Passing the secret inline (``-p auth.jwt_secret:=...`` as in the launch + example above) leaks it through ``ps`` and ``/proc//cmdline``. Inject + it from ``MEDKIT_JWT_SECRET`` or from a params file that only the gateway's + user can read. 3. **Role-scoped clients.** Create the minimum set of clients in ``auth.clients`` (``client_id:client_secret:role``). Roles, least to most