> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theauth.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Robotics and ROS 2 authorization

> Turn a robot's theAuth permissions into SROS2 and DDS Security files, and gate hazardous commands at runtime with approval and step up checks.

A robot is an agent in theAuth. Its permissions say which topics it may publish and subscribe to, which services it may call or answer, and which actions it may send or execute. theAuth uses that one list in two places:

1. **The DDS layer.** `theauth ros2 export` writes the files SROS2 and DDS Security read, so the middleware refuses what the policy refuses.
2. **A gate node.** For things DDS cannot express (human approval, step up, rate limits, time windows, argument checks) a node asks theAuth before it publishes the command.

theAuth is not a safety system and nothing here makes a machine compliant with ISO 10218, ISO/TS 15066 or IEC 62443. An emergency stop must be a hardware or safety controller function that works with the network down. See [Safety stops](#safety-stops).

## Resource names

| Resource | Meaning | Actions |
| - | - | - |
| `ros2:/cmd_vel` | topic | `publish`, `subscribe` |
| `ros2:srv:/reset` | service | `call` (the client), `reply` (the server) |
| `ros2:action:/navigate_to_pose` | action | `call` (the client), `execute` (the server) |
| `ros2:*` or `*` | every topic, service and action | any |
| `ros2:srv:*`, `ros2:action:*` | every service, every action | any |

The action `*` means every verb of that kind. Names must be absolute (`/scan`, not `scan` or `~/scan`) and made of letters, digits and underscores.

With the default matching, a `*` works only as a whole colon segment. `ros2:/fleet/*` is not a pattern: theAuth's policy engine would never match it at runtime, so the exporter lists it as skipped instead of writing a DDS rule the runtime check disagrees with. List exact names, or turn on [path matching](/permissions#path-matching-opt-in) (`resourceMatching: "path"` on the server, `--resource-matching path` on the export):

* `ros2:/fleet/**` exports as the DDS glob `rt/fleet/*`.
* `ros2:/fleet/*` (exactly one level) is skipped. A DDS permissions topic is an fnmatch pattern whose `*` also crosses `/`, so the grant would reach `/fleet/a/b` while theAuth denies it. This was checked against Fast DDS, see [the glob result](#verified-on-ros-2-humble). Use `**`, list exact names, or pass `--single-level widen` to export the wider DDS grant on purpose. The runtime gate still enforces one level.

```typescript theme={"dark"}
import { createTheAuth } from '@glinr/theauth';
import { ros2Action, ros2Service, ros2Topic } from '@glinr/theauth/robotics';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: './theauth.db' },
  agents: { enabled: true },
});

const robot = await theauth.agent.create({
  ownerId: 'user-1',
  name: 'robot1-nav',
  type: 'autonomous',
  permissions: [
    { resource: ros2Topic('/scan'), actions: ['subscribe'] },
    { resource: ros2Topic('/odom'), actions: ['publish'] },
    { resource: ros2Service('/reset_costmap'), actions: ['call'] },
    { resource: ros2Action('/navigate_to_pose'), actions: ['execute'] },
    // Hazardous: a person approves each use.
    {
      resource: ros2Topic('/cmd_vel'),
      actions: ['publish'],
      constraints: { requireApproval: true },
    },
  ],
});
```

## Export to SROS2 and DDS Security

```bash theme={"dark"}
theauth ros2 export \
  --robot <agent-id> --server https://auth.example.com/api/auth \
  --enclave /fleet/robot1/nav --out ./robot1-nav
```

You need to be logged in as an admin (`theauth login`). To work offline, pass `--permissions perms.json` (an array of permissions, or an object with a `permissions` array) instead of `--robot`.

Four files are written:

| File | What it is |
| - | - |
| `policy.xml` | SROS2 policy format 0.2.0. Input for `ros2 security create_permission`. |
| `permissions.xml` | DDS Security 1.1 permissions grant, unsigned. |
| `governance.xml` | DDS Security 1.1 domain governance, unsigned. Encrypted discovery and data, unauthenticated participants refused. |
| `theauth-export.json` | Rules written, permissions gated, permissions skipped and why. |

The output is deterministic: sorted, no timestamps except the validity window you pass. Re-export after a policy change and `git diff` shows what moved.

### How permissions map to DDS

The mapping is the one SROS2 uses in its `permissions.xsl`. A topic `/cmd_vel` becomes the DDS topic `rt/cmd_vel`. A service client publishes `rq/<name>Request` and subscribes to `rr/<name>Reply`, and the server does the reverse. An action client publishes the three `_action` request topics and subscribes to the three replies plus `feedback` and `status`. The grant ends in `<default>DENY</default>`, so anything not listed is refused.

DDS Security evaluates rules in document order and the first match wins. The exporter writes allow rules only, so order never changes the outcome.

### The node baseline

A ROS 2 node opens a few endpoints of its own before your code runs: it publishes `/rosout`, publishes and subscribes `/parameter_events`, and answers six parameter services under its own name (`describe_parameters`, `get_parameter_types`, `get_parameters`, `list_parameters`, `set_parameters`, `set_parameters_atomically`). SROS2's `create_permission` does not add these, and under `Enforce` a node missing any of them fails at startup (see [what was verified](#verified-on-ros-2-humble)). So the exporter adds them by default, for the node named by `--node` (default: the last token of the enclave path) in the namespace given by `--namespace` (default `/`).

The name must be the real ROS node name, because the parameter services are named after it. If the last enclave token is not a valid node name (for example `robot-1`, since node names cannot contain a hyphen) and you did not pass `--node`, the baseline is skipped and `theauth-export.json` says so. Pass `--no-node-baseline` to leave it out. Whether a node started with rosout logging and parameter services turned off still needs them was not tried.

### Gated permissions

A permission with any constraint (`requireApproval`, `requireAcr`, `maxCallsPerHour`, `timeWindow`, `ipAllowlist`, `allowedArgPatterns`) cannot be written into a static DDS grant. Exporting it as a plain allow would drop the constraint without a word. So by default the exporter leaves it out and reports it under `gated`. The robot's own enclave then cannot publish `/cmd_vel` at all.

That is the intended shape. Give the grant to the gate node instead, whose enclave is exported with `--gated include`, and let nothing else publish to the actuation topic:

```bash theme={"dark"}
# Planner and drivers: gated permissions left out (default).
theauth ros2 export --robot <planner-agent> --enclave /fleet/robot1/planner --out ./planner

# The gate: gated permissions granted, because the gate is where they are enforced.
theauth ros2 export --robot <gate-agent> --enclave /fleet/robot1/gate --gated include --out ./gate
```

`--gated include` on a normal participant is a weaker setup: anything holding that enclave's key can publish without the runtime check. Use it only for the gate.

### Sign and install

theAuth never sees your permissions CA key, so signing is yours. The usual route uses the ROS tooling, which also validates the policy against the schema:

```bash theme={"dark"}
export ROS_SECURITY_KEYSTORE=~/keystore
ros2 security create_keystore "$ROS_SECURITY_KEYSTORE"          # once
ros2 security create_enclave "$ROS_SECURITY_KEYSTORE" /fleet/robot1/nav
ros2 security create_permission "$ROS_SECURITY_KEYSTORE" /fleet/robot1/nav ./robot1-nav/policy.xml
```

Run `create_enclave` first: `create_permission` reads the enclave certificate to set the validity window. The result is `permissions.xml` and `permissions.p7s` in the enclave directory. Note that this regenerates `permissions.xml` from `policy.xml`. The exported `permissions.xml` is the same content for a DDS stack without the ROS tooling, and it uses the validity window you gave to `theauth ros2 export` (`--not-before`, `--not-after`, default one year). Keep that window inside the enclave certificate's.

To sign `permissions.xml` and `governance.xml` yourself, with OpenSSL (detached S/MIME, SHA-256, the same form `ros2 security` writes):

```bash theme={"dark"}
openssl smime -sign -text -in permissions.xml -out permissions.p7s \
  -signer permissions_ca.cert.pem -inkey permissions_ca.key.pem -md sha256
openssl smime -sign -text -in governance.xml -out governance.p7s \
  -signer permissions_ca.cert.pem -inkey permissions_ca.key.pem -md sha256

# Check a signature:
openssl smime -verify -in permissions.p7s -CAfile permissions_ca.cert.pem -out /dev/null
```

Then run the robot with security enforced:

```bash theme={"dark"}
export ROS_SECURITY_ENABLE=true
export ROS_SECURITY_STRATEGY=Enforce
export ROS_SECURITY_KEYSTORE=~/keystore
ros2 run my_pkg nav_node --ros-args --enclave /fleet/robot1/nav
```

Without `Enforce`, ROS 2 falls back to permissive mode and an unlisted participant still starts. Each process needs its own enclave, because DDS Security grants are per participant.

The subject in `permissions.xml` has to equal the enclave certificate's subject exactly, otherwise DDS ignores the grant. `ros2 security create_enclave` issues `CN=<enclave path>`, which is the default here. Pass `--subject` if yours differs.

## Gate hazardous commands at runtime

The gate sits between whatever decides to move and the topic that moves the machine. It calls `POST /authorize/token` with the robot's agent token and publishes only on an explicit `allowed: true`.

```typescript theme={"dark"}
import { guardRobotCommand, ros2Topic } from '@glinr/theauth/robotics';

const options = {
  baseUrl: 'https://auth.example.com/api/auth',
  token: () => process.env.THEAUTH_ROBOT_TOKEN ?? '',
  timeoutMs: 1000,
  // Exact resources that run without asking. Software stop commands only.
  safetyStops: [ros2Topic('/e_stop')],
};

const result = await guardRobotCommand(
  options,
  { action: 'publish', resource: ros2Topic('/cmd_vel') },
  () => publishVelocity(0.4),
);

if (!result.ok) {
  // result.code: no_permission, approval_required, step_up_required, guard_error, ...
  // result.approvalRequired is true when a person (or a step up) would let it through.
  logRefusal(result.code, result.reason);
}
```

It fails closed. A timeout, a refused connection, a 500, a body that is not JSON, or anything other than `allowed: true` returns `{ ok: false }` and the command does not run. Errors thrown by your own command function are not caught.

When the answer is `approval_required`, create the approval as described in [Approvals](/approval), and pass the granted id as `approvalId` on the retry.

### Two person rule for dangerous commands

Some commands should need two people, for example releasing a brake, moving an arm near a person or driving above a speed limit. Open the approval with a quorum, and the gate keeps answering `approval_pending` until two different operators have approved.

```typescript theme={"dark"}
const req = await theauth.approval.request({
  agentId: robot.id,
  userId: fleetOwnerId,
  action: "publish",
  resource: "ros2:/arm/cmd_vel",
  arguments: { linear: 0.4 },
  requiredApprovals: 2,
});

await theauth.approval.approve(req.id, "operator-a"); // still pending
await theauth.approval.approve(req.id, "operator-b"); // approved

// The gate passes the id on the retry. It works once, for these arguments only.
const result = await theauth.authorize(robot.id, {
  action: "publish",
  resource: "ros2:/arm/cmd_vel",
  arguments: { linear: 0.4 },
  approvalId: req.id,
});
```

The person who opened the request cannot be one of the two, and the same operator cannot approve twice. A single denial cancels it, and if the second operator does not act before `ttl` runs out, the request expires and the first approval is discarded. Keep `ttl` short for robot commands so an old approval cannot be reused later. See [Approvals](/approval) for the full rules.

### rclpy: a gate node

This node listens on `/cmd_vel_request`, asks theAuth, and republishes to `/cmd_vel`. Give the planner an enclave that may publish `/cmd_vel_request` only, and give this node the only grant for `/cmd_vel`. It uses only the Python standard library for HTTP.

```python theme={"dark"}
import json
import os
import time
import urllib.error
import urllib.request

import rclpy
from geometry_msgs.msg import Twist
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node

AUTHORIZE_URL = os.environ["THEAUTH_URL"].rstrip("/") + "/authorize/token"
TOKEN = os.environ["THEAUTH_ROBOT_TOKEN"]
TIMEOUT_S = float(os.environ.get("THEAUTH_TIMEOUT_S", "1.0"))
# How long an "allowed" answer is reused. 0 asks every time. A revoked robot keeps moving
# for up to this long, so keep it short.
ALLOW_TTL_S = float(os.environ.get("THEAUTH_ALLOW_TTL_S", "0.5"))


def authorize(action, resource):
    """Return (allowed, code). Any failure is a refusal."""
    body = json.dumps({"action": action, "resource": resource}).encode()
    request = urllib.request.Request(
        AUTHORIZE_URL,
        data=body,
        method="POST",
        headers={"content-type": "application/json", "authorization": "Bearer " + TOKEN},
    )
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response:
            data = json.load(response).get("data", {})
    except urllib.error.HTTPError as err:
        try:
            data = json.load(err).get("data", {})
        except ValueError:
            data = {}
    except Exception:
        return False, "guard_error"
    return data.get("allowed") is True, data.get("code", "guard_error")


def is_stop(msg):
    """A command that only asks for zero motion needs no permission."""
    return all(v == 0.0 for v in (
        msg.linear.x, msg.linear.y, msg.linear.z,
        msg.angular.x, msg.angular.y, msg.angular.z,
    ))


class CommandGate(Node):
    def __init__(self):
        super().__init__("command_gate")
        group = ReentrantCallbackGroup()
        self.pub = self.create_publisher(Twist, "/cmd_vel", 10)
        self.sub = self.create_subscription(
            Twist, "/cmd_vel_request", self.on_request, 10, callback_group=group
        )
        self.allowed_until = 0.0

    def on_request(self, msg):
        if is_stop(msg):
            self.pub.publish(msg)
            return
        now = time.monotonic()
        if now >= self.allowed_until:
            allowed, code = authorize("publish", "ros2:/cmd_vel")
            if not allowed:
                self.get_logger().warn("cmd_vel refused: " + code)
                return
            self.allowed_until = now + ALLOW_TTL_S
        self.pub.publish(msg)


def main():
    rclpy.init()
    node = CommandGate()
    executor = MultiThreadedExecutor()
    executor.add_node(node)
    executor.spin()


if __name__ == "__main__":
    main()
```

Put a watchdog on the actuation side too: if `/cmd_vel` stops arriving, the motor controller should drop to zero velocity. Then a dead gate stops the robot instead of leaving the last command running.

### rclcpp: the same idea, as a sketch

This is a sketch, not a tested package. `http_post_json` stands for whichever HTTP client you already link (libcurl, cpp-httplib). Run it off the executor thread, and treat every failure as a refusal.

```cpp theme={"dark"}
#include <chrono>
#include <geometry_msgs/msg/twist.hpp>
#include <rclcpp/rclcpp.hpp>

// Returns true only for HTTP 200 with {"data":{"allowed":true}}.
// Returns false on timeout, transport error, bad JSON, or any other status.
bool theauth_allows(const std::string &action, const std::string &resource);

class CommandGate : public rclcpp::Node {
 public:
  CommandGate() : Node("command_gate") {
    pub_ = create_publisher<geometry_msgs::msg::Twist>("/cmd_vel", 10);
    sub_ = create_subscription<geometry_msgs::msg::Twist>(
        "/cmd_vel_request", 10,
        [this](geometry_msgs::msg::Twist::ConstSharedPtr msg) { on_request(*msg); });
  }

 private:
  static bool is_stop(const geometry_msgs::msg::Twist &m) {
    return m.linear.x == 0 && m.linear.y == 0 && m.linear.z == 0 &&
           m.angular.x == 0 && m.angular.y == 0 && m.angular.z == 0;
  }

  void on_request(const geometry_msgs::msg::Twist &msg) {
    if (!is_stop(msg) && !theauth_allows("publish", "ros2:/cmd_vel")) {
      RCLCPP_WARN(get_logger(), "cmd_vel refused");
      return;
    }
    pub_->publish(msg);
  }

  rclcpp::Publisher<geometry_msgs::msg::Twist>::SharedPtr pub_;
  rclcpp::Subscription<geometry_msgs::msg::Twist>::SharedPtr sub_;
};
```

## Safety stops

An emergency stop must not wait for a network, a DDS graph, a process or theAuth. That belongs in hardware or a certified safety controller, under the standards for your machine (ISO 13850 covers emergency stop principles).

`safetyStops` in `guardRobotCommand` exists for a narrower reason: a software stop command should not become slower, or refusable, because the auth server is down. Listed resources run immediately with no request. Matching is exact on purpose. `ros2:*`, a prefix, or a typo will not match, so the bypass cannot widen by accident. Keep the list to commands that only stop motion. Pass `onSafetyStop` to record that a bypass happened. It is never awaited and errors in it are swallowed.

Revocation shortens how long a stolen robot token or enclave key keeps working. It does not stop a robot that is already moving.

## Compare two policies

```bash theme={"dark"}
theauth ros2 diff before.json after.json
```

Prints the DDS topics that would be added or removed and the permissions that became or stopped being gated. Exit code 0 means no change, 2 means the exported policy differs, 1 is an error. Use it in review before rolling a policy change to a fleet.

## Verified on ROS 2 Humble

Everything in this section was run, in Docker, on 2026-10-18. The setup: image `ros:humble` (digest `sha256:566b87e6dfc4...`, aarch64), `ros-humble-sros2` 0.10.9, `ros-humble-rmw-fastrtps-cpp` 6.2.10, Fast DDS 2.6.12, `rclpy` publisher and subscriber scripts on `std_msgs/String`, one enclave per process, `ROS_SECURITY_ENABLE=true ROS_SECURITY_STRATEGY=Enforce`. The exporter ran on the host and the XML was mounted into the container.

What this does not cover: only Humble and only `rmw_fastrtps_cpp` (no Jazzy, no Cyclone DDS, no Connext, no Zenoh). Only `rclpy` nodes, no `rclcpp`. Only topics. Services and actions were not exercised end to end, apart from the parameter services every node serves. Certificate expiry and key revocation were not tried. The gate node of this page was not run: the "gate" below is just an enclave exported with `--gated include`.

### Export, then build the keystore

```bash theme={"dark"}
# Host. perms: /chatter publish plus a gated /cmd_vel (requireApproval) for the talker.
theauth ros2 export --permissions talker.json --enclave /w11/talker --out out-talker
#   9 rules exported, 1 gated, 0 skipped.
#     gated  ros2:/cmd_vel [publish] left out: requireApproval
theauth ros2 export --permissions listener.json --enclave /w11/listener --out out-listener
theauth ros2 export --permissions driver.json   --enclave /w11/driver   --out out-driver   # subscribes /cmd_vel
theauth ros2 export --permissions gate.json     --enclave /w11/gate     --out out-gate --gated include

# Container.
ros2 security create_keystore "$ROS_SECURITY_KEYSTORE"
for e in talker listener gate driver; do
  ros2 security create_enclave   "$ROS_SECURITY_KEYSTORE" /w11/$e
  ros2 security create_permission "$ROS_SECURITY_KEYSTORE" /w11/$e /work/out-$e/policy.xml
done
export ROS_SECURITY_ENABLE=true ROS_SECURITY_STRATEGY=Enforce
```

`create_permission` accepted every exported `policy.xml`.

### What flowed and what was refused

Each row starts the subscriber, waits three seconds, then runs the publisher for twelve seconds at 2 Hz.

| Publisher enclave and topic | Subscriber enclave and topic | Result |
| - | - | - |
| talker, `/chatter` | listener, `/chatter` | Both created. The listener received 13 messages. |
| talker, `/secret` (not in any policy) | listener, `/secret` | Publisher refused: `create_publisher() could not create data writer`. Subscriber refused with `rt/secret topic not found in allow rule`, and the `rclpy` process then crashed with a segmentation fault instead of raising. |
| talker, `/cmd_vel` (gated, left out) | driver, `/cmd_vel` | Publisher refused. The driver subscribed and received 0 messages. |
| gate (exported with `--gated include`), `/cmd_vel` | driver, `/cmd_vel` | Both created. The driver received 13 messages. |
| listener, `/chatter` (only has subscribe) | listener, `/chatter` | Publisher refused. |

The refusal text from Fast DDS, as printed:

```text theme={"dark"}
[SECURITY Error] rt/secret topic not found in allow rule. (./src/cpp/security/accesscontrol/Permissions.cpp:1245) -> Function check_create_datareader
```

Two more checks. With security switched off (`ROS_SECURITY_ENABLE=false`) the same `/secret` publisher and subscriber exchanged messages (14 and 15 received in two runs), so the refusals above come from the permissions and not from the test scripts. And a publisher with security off talking to an `Enforce` subscriber on `/chatter` delivered 0 messages: the governance's refusal of unauthenticated participants held.

### Signing your own XML

The exported `permissions.xml` and `governance.xml` were also signed with the OpenSSL commands from [Sign and install](#sign-and-install) (using `public/permissions_ca.cert.pem` and `private/permissions_ca.key.pem` from the keystore), copied over the enclave's `permissions.*` and `governance.*`, and `openssl smime -verify` printed `Verification successful` for all four. The talker and listener then behaved as in the first two table rows. `openssl x509 -subject` printed `subject=CN = /w11/talker`, and DDS accepted the exporter's `CN=/w11/talker` as equal.

### The node baseline is needed

With only the application topics in `policy.xml`, a node under `Enforce` does not start. Observed in order, each one fixed by adding that item:

```text theme={"dark"}
rt/rosout topic not found in allow rule ... check_create_datawriter
rq/talker/describe_parametersRequest topic not found in allow rule ... check_create_datareader
rt/parameter_events topic not found in allow rule ... check_create_datawriter
```

That is why [the node baseline](#the-node-baseline) exists. Exporting `/rosout`, six parameter services and `/parameter_events` made the first table row work.

### Globs

Enclave `globss` was exported from `ros2:/fleet/**` with `--resource-matching path` (DDS topic `rt/fleet/*`). Enclave `glob1` was exported from `ros2:/fleet/*` with `--resource-matching path --single-level widen`, which writes the same `rt/fleet/*`. Both enclaves, creating publishers:

```text theme={"dark"}
/fleet        refused
/fleet/a      allowed
/fleet/a/b    allowed
/fleet/a/b/c  allowed
/other        refused
```

So in Fast DDS the `*` of a permissions topic does cross `/`, as this page says: the one level `/fleet/*` cannot be written as a DDS glob, and `--single-level widen` really does grant the deeper names. A glob does not match its own prefix (`/fleet`).

## What is not covered

* **Zenoh.** There is no Zenoh `access_control` exporter. rmw\_zenoh's key expressions carry more than the topic name, and a config that looks right but matches nothing is worse than none. The `zenoh_security_tools` package from the rmw\_zenoh repository generates Zenoh access control from an SROS2 policy file, so feed it the exported `policy.xml`.
* **micro-ROS.** A microcontroller does not run a full DDS participant. Check eProsima's documentation for how the Micro XRCE-DDS agent and its client link are secured, and treat the agent as the trust boundary.
* **Delegated permissions.** `--robot` exports the robot's own permissions and the live permissions delegated to it (`GET /agents/:id?include=delegated`).
* **Certificate lifetimes.** DDS Security validity windows are absolute dates. Pass `--enclave-cert <pem>` and the grant window defaults to the certificate's own window; a `--not-before` or `--not-after` outside it is refused. Re-export and re-sign on a schedule shorter than the window.
* **Per node policy inside one process.** DDS Security grants apply per participant, and ROS 2 uses one participant per process, so two composed nodes share one grant.

## Sources

* OMG DDS Security 1.1 (formal/18-04-01), permissions and governance documents.
* SROS2 source and schemas, `github.com/ros2/sros2` (`permissions.xsl`, `policy.xsd`, default governance).
* ROS 2 design article, "ROS 2 DDS-Security integration".
* Zenoh manual, access control.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.