autonomy ros2 keystore permissions

Generate + sign DDS permissions for an existing SROS 2 enclave (#938 Phase 3-C / 3-C.2)

Synopsis

Synthesize a SROS 2 policy XML from operator-supplied publish/subscribe
topic lists, then dispatch `ros2 security create_permission` which converts it
to a DDS permissions.xml and signs it with the keystore's CA (writing
permissions.p7s next to it). Wraps:
  ROS_DOMAIN_ID=<domain> ros2 security create_permission \
    <keystore-root> <enclave-name> <generated-policy.xml>

With MULTIPLE --domain values (3-C.2): the command first dispatches sros2
for the primary domain, then merges additional <allow_rule> blocks (one
per extra domain) into the same permissions.xml, and re-signs via
openssl smime against the keystore's permissions CA. The output is a
single permissions.xml + .p7s pair the bridge's enclave can use across
all its DDS domains in one process. The S/MIME multipart format the
re-sign produces is structurally equivalent to sros2's signing output
— same MIME headers (multipart/signed, application/x-pkcs7-signature,
sha-256), and verifies against the keystore CA via openssl smime
-verify in both directions. Per-signing bytes (boundary string, ECC
signature nonce) naturally differ from sros2's output by design.

Why this exists: the default permissions.xml minted by `ros2 security create_enclave`
is hard-coded to DDS domain 0 and grants ZERO operator-supplied topics. The
bridge spawned under SROS 2 + Strategy=Enforce would fail-closed at rclcpp init
without operator-generated permissions on its actual domain. This command is
what produces those permissions.

Operator runs this once per enclave. Examples:

  - Workload enclave on a single domain (single-context launched node):
      autonomy ros2 keystore permissions /demo_robot/arm_controller \
        --keystore /var/lib/autonomyops/ks --domain 99 \
        --publish /cmd_vel,/cmd_vel/*

  - Bridge enclave covering BOTH the agent + real domains (the
    governed_ros2_bridge opens one rclcpp::Context per domain; both
    use the same enclave override, so the permissions.xml must cover
    both). The bridge SUBSCRIBES on agent for EVERY bridged topic
    (it has to observe denies to drop them) and REPUBLISHES on real
    only the allow-set (topics the policy may forward). So the
    --publish and --subscribe lists are typically ASYMMETRIC:
      * --subscribe = every topic the bundle bridges, allow OR deny
      * --publish   = only the topics policy may forward on real
                      (a subset of the bridged topics, plus any
                      wildcard fan-outs the workload uses)
    Strongly prefer --from-bundle here — it reads the bundle's
    ros2_topics:{publish,subscribe} block (manifest schema v1.4)
    and gets the asymmetry right automatically. Explicit-flag form
    mirroring the canonical ros2-bridge-demo bundle:
      autonomy ros2 keystore permissions /governed_ros2_bridge_real \
        --keystore /var/lib/autonomyops/ks --domain 42 --domain 99 \
        --publish   /cmd_vel,/cmd_vel/* \
        --subscribe /cmd_vel,/disable_safety
    (/cmd_vel is on both lists because it's an allow-topic the
    bridge both intercepts and republishes; /disable_safety is
    subscribe-only because the bundle's policy denies it — without
    the subscribe grant the bridge can't even observe the inbound
    publish to deny it; /cmd_vel/* is publish-only because it's a
    wildcard fan-out for workloads that emit per-controller topics
    like /cmd_vel/left, /cmd_vel/right.)

Topic name rules: must start with '/', use only [a-zA-Z0-9_/*-]. The '*'
wildcard is allowed (used by the demo for /cmd_vel/*).

DDS-internal topics — ros_discovery_info, the rt/* action/service
boilerplate — are added automatically by 'ros2 security create_permission'
to the generated permissions.xml; do NOT pass them via --subscribe or
--publish (they would fail the validator's leading-slash rule, since
they are raw DDS partition names, not ROS topics). The operator-
supplied topics here are ROS-namespaced topics only.

What this command does NOT do (deferred):
  - Auto-extract from the policy bundle's tool.ros2.* Rego rules (would
    need either AST introspection or evaluation against a topic corpus
    — a bundle-metadata block declaring SROS 2 ACLs is the natural
    follow-up). Until then, operators supply explicit lists matching
    their bundle's Rego rules. Filed as #938 3-C.1.

Prereq: ros-humble-ros-base on the host AND openssl on PATH (for the
multi-domain re-signing path). Both are present in the standard ROS
2 install. Single-domain invocations don't need openssl (sros2 does
the signing).

Usage

autonomy ros2 keystore permissions <enclave> [flags]

Options

      --domain id           id DDS domain ID the generated permissions apply to (0..232). Repeat the flag to cover MULTIPLE domains in one permissions.xml — e.g. for the governed_ros2_bridge enclave which runs on agent + real domains in one process: --domain 42 --domain 99. Multi-domain re-signs the merged XML via openssl smime against the keystore CA (#938 3-C.2). At least one --domain is required.
      --from-bundle path    path to a bundle (.tar OR directory containing manifest.json) whose ros2_topics:{publish,subscribe} block declares the topic surface this enclave is granted. Mutually exclusive with --publish/--subscribe — the bundle IS the source of truth for the operator-declared surface, so mixing would silently widen it. Requires manifest schema_version >= 1.4 with a non-empty ros2_topics block. Use this to keep the SROS 2 permissions in sync with the bundle's declared ROS 2 topic surface without re-typing the lists. (#938 3-C.1)
      --keystore dir        dir path to the keystore root (created by 'autonomy ros2 keystore init')
      --publish strings     Comma-separated topics the enclave is granted PUBLISH access to. Repeat the flag or comma-separate. Topic names must start with '/' and use only [a-zA-Z0-9_/*-]. At least one of --publish or --subscribe must be non-empty.
      --subscribe strings   Comma-separated topics the enclave is granted SUBSCRIBE access to. Same shape rules as --publish. At least one of --publish or --subscribe must be non-empty.

See also