Docs/Deploy & operatev0.0.1-preview

Security

mTLS everywhere, role-based authorization, token authentication, storage encryption, and the audit trail — fail-closed at every layer.

On this page
  1. Transport: TLS and mutual TLS
  2. Authorization: five roles
  3. Token authentication
  4. Storage encryption
  5. Audit trail
  6. Logs treat identities as untrusted input
  7. Installer integrity
  8. Reporting

TulaDB’s security posture follows one rule at every layer: fail closed. Unauthenticated clients get read-only by default and can be denied outright; SDKs refuse plaintext off-box; the Kubernetes charts refuse to render without certificates; and demo conveniences exist only behind loopback binds.

Transport: TLS and mutual TLS

All four network planes support TLS. For the replication planes it isn’t optional: a node whose node_addr or any peer address is reachable beyond loopback — single nodes included — refuses to start without network_mtls_required = 1 and mutually authenticated TCP heartbeats. The fabric port carries replication and proxied writes, so an unauthenticated one would be a way around every role check; only a loopback-bound demo runs without certificates.

network_tls_enabled   = 1
network_mtls_required = 1
network_require_authenticated_clients = 1
network_tls_cert_file = "/run/secrets/tuladb/node.crt"
network_tls_key_file  = "/run/secrets/tuladb/node.key"
network_tls_ca_file   = "/run/secrets/tuladb/ca.crt"
heartbeat_tls_enabled   = 1
heartbeat_mtls_required = 1        # requires heartbeat_transport = "tcp"

Certificate revocation lists are supported (network_tls_crl_file, reloaded on SIGHUP), certificate expiry is exported as a metric so rotation becomes an alert instead of an outage, and replication peers are separately authenticated — a fabric connection must present a certificate matching a configured cluster node, so a stray client credential can’t speak the replication protocol. UDP heartbeats can’t be authenticated and are refused beyond loopback. Every listener binds exactly the host the configuration names — localhost means 127.0.0.1, never a wildcard.

On the client side, every SDK refuses to send ledger traffic in cleartext to a non-loopback address unless explicitly overridden (TULADB_CLIENT_ALLOW_PLAINTEXT), and takes its certificates from code — most SDKs also read the TULADB_CLIENT_TLS_* environment.

Authorization: five roles

Every message type on every port is gated by a minimum role, enforced before any engine effect:

Role Can
denied Nothing
read-only Queries, health, cluster status, lease-less export reads
exporter Everything read-only can, plus export leases (which hold WAL reclaim) and durable export acks (which credit history purge)
ledger-operator All ledger writes: accounts, transfers, holds, registries
cluster-admin Membership changes, leadership transfer, feature activation

Principals map to roles with glob-pattern grants — the principal being the mTLS certificate identity (or token subject):

unauthenticated_role = "denied"
role_grant_0 = "payments-api-*      -> ledger-operator"
role_grant_1 = "spiffe://prod/ops-* -> cluster-admin"
role_grant_2 = "reporting-*         -> read-only"

The default unauthenticated_role is read-only. On an exposed bind without required client authentication, the engine refuses to start if anonymous callers would get exporter or anything above it — TLS alone encrypts the channel but doesn’t authenticate the client, so the guard keys on mTLS or network_require_authenticated_clients, not on cleartext. The dangerous misconfiguration is structurally unreachable.

Roles are ordered, and the role byte an authentication reply carries is a rank: compare it with >= against the role you need rather than matching literal values. Grant the analytics exporter its own principal at exporter — an anonymous or read-only caller can read the export stream but can’t hold history or WAL on its behalf. A lease key is bound to the first authenticated principal that registers it; another principal presenting the same key is refused.

One boundary to design for: roles gate operation classes, not data slices. Any authorized writer can address any ledger in the deployment — multi-tenant isolation belongs in your service tier (or in separate deployments), not in ledger roles.

Token authentication

Alongside mTLS identity, the engine authenticates API keys (hashed, one per line in api_key_file) and OIDC bearer tokens (RS256, PS256, ES256) — JWKS by URL or file, with issuer, audience, expiry and principal-claim validation. Set oidc_audiences: left empty, any aud is accepted in this release, and the daemon logs a jwt_audience_unset warning at startup to say so. A bearer identity stops authorizing at the token’s expiry (plus oidc_clock_skew_s).

Failed authentication is throttled per connection. The fifth failed MSG_AUTH (auth_max_failures_per_conn, default 5) is answered ERR_PERMISSION and the connection is closed. On thread-per-connection ingress — which includes all TLS ingress — each earlier failure reply is delayed by auth_failure_delay_ms (default 50) times the failure count, at most one second, without slowing any other connection. A success resets the count; 0 disables either limit. There is no per-source-IP limit yet.

Revoking a credential — removing an API key, rotating a key out of the JWKS, a client certificate passing notAfter — doesn’t end a connection that has already authenticated. client_max_lifetime_ms (default one hour) bounds how long one can live. Set credential_refresh_fail_closed_after_ns to bound how long cached credentials remain honoured if your IdP becomes unreachable; the default keeps last-known-good keys serving indefinitely, which favors availability — decide that trade explicitly. Note that in-band token presentation is currently supported by tulactl and the C SDK; the other SDKs authenticate with mTLS client certificates, and the REST gateway authenticates its own callers with API keys.

Storage encryption

Data at rest can be encrypted by the engine (storage_encryption_enabled, key by file or hex): the WAL, snapshots, the account store, the partition index and the derived query-index stores are sealed with AES-256 in CTR mode plus an HMAC-SHA256 tag (encrypt-then-MAC), so a modified block fails to open rather than decrypting to garbage. Startup fails closed without a key. Storage encryption requires the single-lane WAL. A node holding any secret — a storage key, TLS key, API keys or HMAC key — also makes itself non-dumpable, so key material stays out of core dumps (core_dumps_with_secrets opts out). The production Kubernetes chart makes it mandatory — the chart fails to render without a key rather than quietly installing an unencrypted store.

Audit trail

Two layers of after-the-fact evidence, both opt-in:

  • Audit log — an append-only, hash-chained record of requests with their authenticated principal (or (anonymous)), role, payload digest and result, with a strict fail-closed mode for deployments where “auditing broke” must stop the system rather than continue unrecorded. By default an append failure is logged and counted rather than failing the request.
  • Tamper-evident watermarking — periodic HMAC watermarks anchor the audit stream, and an offline verifier (tuladb_audit_verify, source build only — it isn’t in the release binaries or the image) checks integrity end to end. This detects after-the-fact tampering by anyone who doesn’t hold the HMAC key; an attacker with write access to logs, watermarks, and the anchor can still erase history — scope it honestly and keep the key material separate.

The ledger itself is the deepest audit layer: no delete API exists for accounts or transfers, rejected operations are durable records, and the WAL hash chain in full-payload mode makes the transaction history itself tamper-evident.

Logs treat identities as untrusted input

A certificate’s SAN or CN is chosen by whoever issued it, so the engine escapes principals before they reach a log line: bytes outside printable ASCII, plus space, =, " and \, become \xNN, and at most 128 bytes are kept. A crafted certificate can’t end a line, forge the next one, fake a key=value field or send terminal escapes — in plain, syslog and stderr output as well as JSON. Expect John Doe to log as John\x20Doe.

Installer integrity

Releases are served from downloads.tuladb.com (see Downloads). Each release directory carries a SHA256SUMS manifest over its files, a keyless cosign (Sigstore) signature over that manifest made by the product’s release workflow at the release tag, and the trusted_root.json to check the signature offline. Verify the signature against that exact workflow identity, then the files against the manifest; the Downloads page gives both commands. Before a release is listed on the page, our CI downloads every file and repeats those checks (against Sigstore’s own trust root as well), plus each file’s size and executable format, and refuses on any mismatch.

tuladb-installer, the server installer, also checks the release embedded in it against the SHA-256 recorded when it was built, and refuses to run on a mismatch. That catches corruption and an edited payload; the signed manifest is what proves origin. The evaluation-stack installers (available to design partners on request) verify their own .sha256 sidecar, refuse to run piped into a shell, and pin the digest of every asset they fetch; they have an explicit, loudly-logged escape hatch for air-gapped mirrors.

Reporting

Report suspected vulnerabilities to [email protected] — the security disclosure policy covers scope, what to include, and what to expect from us, and is referenced from /.well-known/security.txt. Please don’t file suspected vulnerabilities in public trackers.