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. No /healthz or /readyz. Use /__ursula/metrics as a process-alive probe (it serves only after the runtime initializes).
  • 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, usage entry, and cold objects idempotently, leaving other tenants untouched.

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.

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. 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.
                    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.