NATS bridge
The basil-nats-bridge binary lets NATS request/reply callers reach Basil's sealed invocation
service without making the bridge a trusted actor. It is a courier: it moves raw tagged COSE
bytes between NATS and InvocationService.Invoke, and Basil does the identity, policy, decryption,
operation execution, response signing, and response encryption.
Use this path when a workload can publish to NATS but should still authorize as the subject proved by its sealed COSE request. The bridge process is only the local Unix-socket presenter. It must not decrypt request or response bodies, rewrite subjects, delegate, impersonate, or fabricate operation results.
Binary and config
basil-nats-bridge is a separate Rust binary from the basil agent. Run it as its own service user
and point it at both NATS and the local Basil Unix socket:
[nats]
url = "nats://127.0.0.1:4222"
creds = "/run/credentials/basil-nats-bridge/nats.creds"
[basil]
socket = "/run/basil/basil.sock"
[bridge]
request-subject = "basil.invocation.v1"
queue-group = "basil-nats-bridge"
max-message-bytes = 1048576| Field | Meaning |
|---|---|
nats.url | NATS server URL. |
nats.creds | Optional NATS credentials file. Omit it for unauthenticated local NATS deployments. |
basil.socket | Basil agent Unix socket used for InvocationService.Invoke. |
bridge.request-subject | NATS subject that accepts sealed invocation request bytes. |
bridge.queue-group | Optional NATS queue group for multiple bridge workers. |
bridge.max-message-bytes | Required maximum accepted NATS payload size, in bytes. Oversized requests get a bridge error. |
The NATS request payload is the complete tagged request COSE_Sign1. The NATS reply payload is the
complete tagged response COSE_Sign1 when Basil returns a protected response. NATS inboxes provide
transport correlation, but callers must still verify the signed response, check the response claims,
and decrypt the body before trusting any status or result.
Message flow
- A caller builds a sealed invocation request with the sealed invocation helper or the fixture-compatible COSE wire rules.
- The caller publishes raw tagged request
COSE_Sign1bytes tobridge.request-subjectusing NATS request/reply. - The bridge checks only transport shape: reply subject and payload size.
- The bridge wraps the bytes as
SealedRequest { message }and forwards them to Basil overInvocationService.Invokeon the configured Unix socket. - Basil authenticates the sealed actor from the COSE
signature-keyproof, authorizes the operation-specific policy grants, executes the operation, and returnsSealedResponse { message, response_subject }. - The bridge publishes
SealedResponse.messagebytes unchanged toSealedResponse.response_subjectwhen present, or otherwise to the NATS reply subject. - The caller verifies the broker response signature, checks request binding, decrypts with its selected response key, and reads the protected response body.
Opaque payloads
The bridge never sees Sign, minting, or response plaintext. The operation body is inside the
embedded COSE_Encrypt payload and remains encrypted.
NATS request payload: <tagged COSE_Sign1 request bytes>
NATS reply payload: <tagged COSE_Sign1 response bytes>
The bridge does not inspect COSE protected headers, claims, content types, signatures, ciphertexts, or request/response correlation claims. It is a byte courier between NATS and Basil's local invocation gRPC service.
Policy grant
The bridge process itself needs no policy grant. There is no transport-level op:invoke action
in the policy language (a policy naming one fails to load). Basil authorizes the actor inside each
sealed message, never the process that delivered it: the actor proof (the request's signature-key
subject) must verify, the actor needs op:decrypt on the request-encryption key, and the actor
needs the operation-specific grant for the inner request. The bridge's Unix identity is recorded in
the audit log as the presenter for context, but it holds no data-plane authority.
{
"schemaVersion": 2,
"subjects": {
"content.publisher": {
"allOf": [
{
"kind": "signature-key",
"algorithm": "nats-nkey",
"public": "UANATS_PUBLIC_NKEY"
}
]
}
},
"rules": [
{
"id": "publisher-can-use-invocation-signing",
"subjects": ["content.publisher"],
"action": ["op:decrypt", "op:sign"],
"target": ["broker.request_encryption.2026q3", "publisher.signing.2026q3"]
}
]
}
The first rule authorizes the presenter to reach Invoke. The second rule is the actor's real
authority: Basil resolves it from the sealed COSE proof and applies it to the requested operation and
target. A bridge uid/gid grant cannot make an unsigned or invalid message authorize.
Audit semantics
Bridged audit records deliberately separate actor and presenter:
| Field | Bridged meaning |
|---|---|
actor_kind / actor_id | The sealed invocation subject proved by the message, such as content.publisher. |
authenticated_by | The actor proof summary, such as a signature-key proof. |
presenter_kind / presenter_id | The bridge process attested by SO_PEERCRED, such as svc-nats-bridge(9100). |
generation, op, target_id, decision, reason | The policy generation, operation target, and PDP outcome for the actor. |
This makes incident review explicit: the bridge delivered the request, but Basil authorized the sealed actor. If actor proof fails, Basil emits a denied audit record without treating the bridge as the actor.
Bridge error headers
When Basil returns sealed response bytes, the bridge forwards them unchanged and does not add bridge error headers. When the bridge cannot obtain a sealed Basil response, it replies with an empty payload and these NATS headers:
| Header | Meaning |
|---|---|
Basil-Bridge-Error | Stable bridge-level token. |
Basil-Bridge-Message | Operator-facing detail suitable for logs. |
Basil-Bridge-Retryable | true only when retrying the same request may succeed. |
Stable error tokens are MALFORMED_REQUEST, MESSAGE_TOO_LARGE, BASIL_UNAVAILABLE,
BASIL_REJECTED, TIMEOUT, and INTERNAL.
| Token | Typical cause |
|---|---|
MALFORMED_REQUEST | Missing NATS reply subject. |
MESSAGE_TOO_LARGE | Payload exceeds bridge.max-message-bytes. |
BASIL_UNAVAILABLE | The Unix socket cannot be reached or Basil is not serving. |
BASIL_REJECTED | Basil rejected the request before producing a sealed response. |
TIMEOUT | Basil did not respond before the bridge deadline. |
INTERNAL | Unexpected bridge-side failure. |
Do not treat a bridge error as a denied operation result. It means there is no trusted sealed Basil
response. Retry only when Basil-Bridge-Retryable: true and your message id and expiry strategy
still satisfy replay and TTL rules.
Current boundaries
The bridge has no delegation, no impersonation, no metadata auth shortcut, and no migration mode for legacy unsigned requests. All successful operation results stay inside signed and encrypted COSE responses.
The Go sealedinvocation package (github.com/openbasil/basil-go/sealedinvocation) ships a
fixture-compatible BuildRequest/OpenResponse helper, so Go callers can build and open sealed COSE
invocations broker-free rather than hand-assembling bytes. The bridge itself stays a byte courier: it
never builds or opens the COSE messages it carries.
Where to go next
- Sealed invocations: the COSE profile and response verification contract.
- The policy:
signature-keysubjects and the actor grants behind sealed invocations. - Audit logs: actor-vs-presenter fields for bridged requests.
- NATS integration: NATS identity minting, JWT signing, validation, and xkey boxes.