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/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/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" 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/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/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/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 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(); 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)