Security

Ursula does not terminate TLS, authenticate clients, or restrict admin endpoints. Treat the listening port as fully trusted. Run it on a private network behind a reverse proxy that owns TLS termination and request authentication.

The current v0.x security model is deliberately narrow. Ursula is built to slot behind your existing edge layer, not to be one.

What Ursula does

  • Quorum-acknowledged writes. An append is acknowledged only after a majority of voters has replicated it.
  • Per-group backpressure. When a group's hot ring exceeds storage.cold.max_hot_size_per_group, appends to that group return 503 with Retry-After until cold flush catches up. Per-group, not global or per-client.
  • Stream-level isolation. Streams hash to disjoint Raft groups and disjoint owner cores. A hot stream on one group cannot starve writes on a different group on a different core.

What Ursula does not do

Handle the following outside Ursula:

  • TLS / HTTPS. The public listener serves plain HTTP. No built-in rustls.
  • Inter-node encryption. Peer gRPC (Raft heartbeats, append-entries, snapshots, and leader-read checks) runs over h2c. Peers must share a private network. Non-leader HTTP writes return a 307 redirect to the current group leader rather than being forwarded over gRPC.
  • API authentication on nodes. Ursula nodes themselves accept any caller with network reach. Bearer-token validation is available as an opt-in feature of the gateway (see below); node listeners must stay on a private network either way.
  • Authorization / multi-tenancy on nodes. Nodes enforce no per-user, per-bucket, or per-scope ACLs. The gateway's opt-in access control provides a bucket-level tenant boundary; anything finer stays upstream.
  • Admin endpoint isolation. /__ursula/metrics, /__ursula/flush-cold/*, /__ursula/raft/*, and the public stream endpoints share the same listener with no auth gate.
  • Per-client rate limiting. A single noisy client can saturate a core's mailbox or a group's hot ring.
  • Health/readiness endpoints. GET /__ursula/ready is unauthenticated on the client plane and reports only readiness plus WAL free-space guard state. /__ursula/metrics contains substantially more operational detail; keep the node listener private.
  • At-rest encryption beyond the cold tier. Cold-tier S3 writes (including Raft snapshots) request SSE-S3 by default (storage.cold.s3.server_side_encryption, switchable to aws-kms or none). The hot ring is in memory; WAL and Raft log directories live on disk in plaintext — use full-disk encryption at the host level. Per-tenant KMS keys and client-side encryption are out of scope.

CORS is permissive (Access-Control-Allow-Origin: *). Restrict at the proxy for browser traffic.

Tenant offboarding has a first-class erasure path: the admin-plane bucket purge endpoint removes a tenant's streams, bucket, quota, and cold objects idempotently, leaving other tenants untouched. It deliberately retains aggregate monotonic usage counters so asynchronous accounting cannot miss committed work; see the operations note for the remaining identifier-erasure limitation.

Gateway access control (opt-in)

A shared or internet-facing deployment can enable OAuth resource-server checks on ursula gateway. The feature is off by default; without the flags the gateway keeps its original trusted pass-through behavior.

ursula gateway \
  --upstream http://ursula-0:4437 \
  --auth-issuer https://issuer.example \
  --auth-audience https://streams.example \
  --auth-policy /etc/ursula/policy.toml
  • Authentication. Bearer credentials are validated as RFC 9068 JWT access tokens: the header must declare typ: at+jwt (OIDC ID tokens are rejected), the signature must verify against the issuer's JWKS, and iss, aud, sub, client_id, iat, exp, and jti must all be present and valid. The JWKS location comes from --auth-jwks-url or RFC 8414 metadata discovery; keys are cached by kid and refetched on rotation.
  • Tenant boundary. The bucket is the top-level namespace and logical tenant boundary. The policy file declares each bucket's owners (issuer-qualified subjects) and whether anonymous reads are allowed:
[[bucket]]
id = "tenant-a"
public_read = true
owners = [{ issuer = "https://issuer.example", subject = "user-1" }]
  • Concealment. Unknown buckets, private buckets probed by strangers, and write attempts without ownership all answer the same 404 a missing resource would, so a private stream's existence is not observable.
  • Credential termination. The gateway strips Authorization before forwarding; upstream nodes never see end-user credentials and must remain on a private network.
  • Anonymous public reads. public_read grants exactly the read-only actions (read, head, tail, snapshot read) to unauthenticated callers — never writes, deletes, or bucket administration.
  • Subscriptions do not outlive their credential. A live tail is one long-lived request, so a single admission check at connection time would turn a short-lived token into an unbounded read. The gateway ends an SSE subscription at the credential's exp with a final event: credential-expired frame, so a client can tell expiry from end-of-stream and re-authenticate. Anonymous reads on public_read buckets have no credential and so no deadline. Note that this bounds duration, not revocation: a token revoked before its exp keeps an open subscription until then.

An access-controlled gateway can additionally enable per-tenant admission limits and usage accounting:

ursula gateway ... \
  --quota-policy /etc/ursula/quotas.toml \
  --usage-log /var/log/ursula/usage.jsonl
  • Quotas (--quota-policy): per-bucket request rate (429 with Retry-After), concurrent live-read connections, and request body size. Limits are gateway-process-local; a horizontally scaled deployment multiplies effective limits by replica count. Ursula's own 503 backpressure semantics are unchanged. Data-plane quotas (stream count, retained bytes) are enforced inside Ursula as per-group backstops: PUT /__ursula/quota/{bucket} replicates max_streams / max_retained_bytes records to every Raft group, and each group rejects creates/appends that would exceed the limits against its local counters with 429 (no Retry-After: these are capacity caps, not rate limits). Because a bucket's streams hash across groups, the cluster-wide bound is limit x group_count - an abuse backstop; exact tenant-level enforcement belongs to the gateway, which reads aggregated /__ursula/usage.
  • Usage (--usage-log): per-tenant request, ingress, and egress byte counters aggregated by (bucket, principal, action class) and appended as sequence-numbered JSONL batches on --usage-flush-secs intervals. --usage-chunk-bytes adds a chunks counter alongside them: each append is counted as ceil(bytes / unit), never below one, summed per request. It exists because that sum cannot be recovered afterwards — two appends of 5 KiB and 25 KiB and two of 10 KiB and 20 KiB agree on both request count and byte total while owing four units and three — so a deployment that charges per write unit has to be handed the sum rather than the ingredients. The unit size is a pricing choice and Ursula does not pick one; 10 KB and 25 KB are both in use. A batched append costs proportionally less than the same records sent individually, which is the intended incentive: batching is cheaper to serve. A failing sink delays reporting (batches queue and merge) but never blocks requests or drops counts. Egress is counted from actually streamed bytes, including SSE bodies. Committed-truth counters (append bytes surviving retries, retained bytes) come from Ursula's replicated state and are a separate, complementary ledger.

Cross-origin reads (opt-in)

public_read is only nominally public until the origin answers CORS: without it, browser JavaScript cannot read a public stream cross-origin. Allowed origins are deployment policy, so nothing is sent unless you list them.

ursula gateway ... \
  --cors-allowed-origin https://app.example.com \
  --cors-allowed-origin https://studio.example.com

Pass * instead to allow any origin.

Three properties are deliberate:

  • Credentials are never allowed. Ursula authenticates from an Authorization header the caller sets explicitly, which CORS does not treat as credentials, and no cookies are involved. So * grants no ambient access — a cross-origin page must still present its own bearer token.
  • Access-Control-Expose-Headers: *. A read carries its continuation in response headers (stream-next-offset, stream-record-next, stream-cursor), and a browser cannot see those without exposure — an unexposed client can read one page and never advance. The wildcard is only honoured while credentials stay disallowed, which is the second reason they are.
  • Preflight never consults the resource. OPTIONS arrives without Authorization, so a per-bucket answer would tell an unauthenticated caller whether a private bucket exists. The preflight reply is identical for every path and is produced before authorization runs, which keeps concealment intact.

EventSource cannot set request headers, so a browser cannot open an SSE tail on a private stream with it. Use fetch with a ReadableStream and an Authorization header. EventSource is fine for public_read streams.

                    Untrusted internet

                            v
                  ┌─────────────────────┐
                  │   Reverse proxy     │  TLS, authn, per-client
                  │ (nginx / Envoy / …) │  rate limiting, CORS
                  └──────────┬──────────┘
                             │ plain HTTP, private network
                ┌────────────┼────────────┐
                v            v            v
           ┌────────┐   ┌────────┐   ┌────────┐
           │ Ursula │   │ Ursula │   │ Ursula │
           │  node  │   │  node  │   │  node  │
           └────────┘   └────────┘   └────────┘
                             ↕ gRPC h2c on private network
                          (Raft replication)

Checklist

  • Bind to the private interface. Set server.listen = "10.0.0.X:4437" or use a security group / firewall so the listener is unreachable from public networks.
  • Terminate TLS at the proxy. Ursula stays plain HTTP on the internal side.
  • Authenticate at the proxy. Validate the caller (OAuth2, mTLS, signed requests) and reject unauthenticated traffic before it reaches Ursula.
  • Block admin paths from public traffic. Deny /__ursula/* on the public listener and allow it only on an internal or ops network.
  • Use IAM roles for S3. Omit static storage.cold.s3.access_key_id / storage.cold.s3.secret_access_key values and let the AWS SDK credential chain discover credentials.
  • Encrypt data volumes. Apply full-disk encryption to raft.wal.path.
  • Keep peer traffic private. Never route gRPC peer traffic across the public internet.

Reporting vulnerabilities

Open a GitHub Security Advisory on tonbo-io/ursula rather than a public issue.