Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Authentication & TLS

Authentication

The legacy shared --auth-user / --auth-password credential remains available for local root/break-glass administration and existing deployments. New IAM adds separate local principals, service API keys, and (Enterprise) federated OIDC/OAuth identities; it does not make provider deletion an authentication bypass. See Identity and access management for edition boundaries, provider setup, session behavior, and restore semantics.

The server uses a single shared credential (--auth-user / --auth-password, or the PATINADB_AUTH_PASSWORD environment variable).

  • Enabled when a password is set. An empty password means no authentication, which is fail-closed: the node refuses to start unless you also pass --insecure-disable-auth. This prevents accidentally exposing an open node by forgetting to set a password. With the flag, the node runs open and logs a prominent warning — only acceptable on a trusted, firewalled, single-tenant network (ideally still behind a TLS-terminating proxy). After identity-provider configuration has been used, the replicated IAM enforcement marker prevents anonymous open-admin; deleting the last provider does not clear it. See IAM.

    # Refuses to start (no password, no opt-in):
    patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap
    # → Error: refusing to start with authentication disabled: set --auth-password …
    
    # Deliberately open (trusted network only):
    patinadb-raft --id 1 --addr 127.0.0.1:21001 --db ./data --bootstrap \
      --insecure-disable-auth
    
  • Set a long, random --auth-password — this now matters more than it used to (issue #1065). The root-admin username is EXEMPT from the brute-force throttle described below, by design: a single-service-principal deployment must never be able to lock itself out of its own account (see the rotation runbook), and the credential is a constant-time compare with no argon2 cost, so throttling it bought no DoS protection anyway. That trade only holds if the password itself carries real entropy — a short or guessable one now has NO per-minute guess cap standing between it and an attacker, where it used to have one. The server warns at startup (and again on POST /mgmt/rotate-admin-password) if the password is under 16 characters, but does not refuse to start on a short one (existing deployments shouldn’t get bricked by a new check) — treat the warning as a “fix this now”, not a “fix this eventually”. Generate one with e.g. openssl rand -base64 32.

  • REST: HTTP Basic on every route except the open probes /health and /version (and /metrics only if you opt out of metrics auth — see below). With authentication enabled, /version exposes only the database-agnostic history-completeness boolean publicly; its database-scoped floor map is returned solely to peers presenting the cluster secret. An explicitly open node retains the legacy map because every database is already public there.

  • /metrics: authenticated by default AND global-Admin-only — the exposition series carry db=<name> labels, so an open endpoint would let an unauthenticated scraper enumerate every database name and its per-db volume. A credential alone was not enough: any authenticated per-db Reader could read the same labels, which under closed-mode RBAC is exactly the cross-tenant enumeration SHOW DATABASES, the Bolt system shim and the /cypher db-lookup path all deliberately prevent. /metrics therefore carries the same global Admin floor as /mgmt/* (issue #693). Operator action: your Prometheus scrape must authenticate as a global Admin — the root --auth-user/--auth-password credential, or a directory user created with SET ROLE admin. A per-db Reader scrape now gets 403. To serve it open on a private, trusted monitoring network, pass --insecure-open-metrics (or set insecure_open_metrics: true / the legacy require_metrics_auth: false in the config file) — that opt-out bypasses both the credential and the role floor. /health and /version stay open regardless.

  • Bolt: checked at LOGON (scheme: "basic"); an unauthenticated connection cannot run queries.

  • Brute-force throttle (on by default), except for the root username (issue #1065). Failed logins from a directory (CREATE USER) account are rate-limited before the argon2 verify (argon2 is deliberately slow, so an unthrottled flood would be an amplification DoS): default 20 failed attempts per minute, budgeted per source IP and per username — a distributed attack spread across IPs still trips the username budget. Over budget → 429 on REST, an AuthenticationRateLimit failure on Bolt; a successful login clears the counters. Tune with --auth-max-attempts-per-min / PATINADB_AUTH_MAX_ATTEMPTS_PER_MIN; 0 disables it (a loud startup warning is logged). The root-admin username is never subject to this limiter at all — see the callout above; this is a deliberate carve-out, not a gap. Tests: tests/authlimit.rs, tests/secret_rotation.rs.

  • Recovering a locked-out directory user (issue #1071 review follow-up). A directory account IS subject to the throttle above (correctly — see the callout: it goes through argon2 and carries real guessable entropy, unlike root), so it CAN get locked out — idle-timeout churn, a stale cached credential during your own rotation, the same shape #1065 fixed for root. Recovery is admin-mediated, since a locked-out account cannot free itself and root is exempt from the very limiter it needs to clear: either ALTER USER <name> SET PASSWORD '...' (rotates the credential AND clears the lock, same as rotate-admin-password already does for root) or, when the password is still good and only the lock needs to go, POST /mgmt/unlock-user {"username": "<name>"}. Both are logged to the protected audit class (never subject to the count-based retention cap — see Security audit log). Scope, honestly stated: this clears the account’s own failed-attempt window; a source IP that independently exhausted its OWN per-IP budget (the same one that locked the account, if it was a single-source lockout) stays throttled regardless — deliberate, not a gap (see mgmt_unlock_user’s doc comment for why), and it self-heals once that IP’s window elapses. Tests: tests/unlock_user_1071.rs.

  • Argon2-verify cache for directory users (issue #1067). REST re-authenticates on every request (auth_layer runs on every route), so without a cache a directory (CREATE USER) account paid a full ~19 MiB argon2id verify PER REQUEST — for a 99%-read single-backend deployment (this profile) that makes the auth layer a hard throughput ceiling and creates a perverse incentive to use the (unthrottled) root credential for routine traffic just to dodge the cost. A successful verify is now cached for 60 seconds by default (--auth-verify-cache-ttl-secs / PATINADB_AUTH_VERIFY_CACHE_TTL_SECS, 0 disables). This TTL is a security trade-off, not a tuning knob: it bounds how long a REVOKED credential could still authenticate if the change reached the node through anything OTHER than UserStore’s own methods — in this codebase nothing does (every ALTER USER … SET PASSWORD / DROP USER / grant change goes through UserStore). Mutations advance a per-user generation under the directory lock and invalidate the cache. Cache hits and completed Argon2 verifications revalidate that generation; an older verification cannot repopulate a valid cache entry after revocation (#1265). This is an admission check against locally applied state, not cancellation of already-admitted work or instantaneous propagation to followers. Direct-DB writes bypassing UserStore are unsupported. A wrong password is NEVER answered from the cache — it always pays the full argon2 cost, so the failed-attempt rate limiter above is completely unaffected (only a repeated CORRECT password gets cheaper). The cache key is an HMAC keyed by a process-local, never-persisted random value (not a bare SHA-256(password)) — a fast keyless hash would itself be an offline-crackable artifact for the cache’s TTL; see cache_key_mac_key’s doc comment in rbac.rs for the full argument and its honest limits. Historical measurement before the generation checks, not current-cache throughput (this box, single- threaded, RAM-only, N=2000 sequential verifies — see rbac::tests::bench_verify_cache_throughput_1067, #[ignore], re-run with --ignored --nocapture): first run under heavy sibling-agent load (loadavg ~46–74) measured 50→101,282 verifies/s (~2020×); a second run on a quieter box (loadavg ~10–51) measured 68→98,376 verifies/s, a ~1443× (#1067) speedup. These absolute verifies/s numbers are load-sensitive and do not qualify the subsequently hardened cache implementation. Tests: rbac::tests (unit-level cache + invalidation proofs) and tests/rbac.rs’s alter_user_password_invalidates_the_verify_cache_over_rest_1067 / drop_user_invalidates_the_verify_cache_over_rest_1067 (the same invalidation proved over the real REST path).

  • Peer RPCs: the /raft/* inter-node calls authenticate with a dedicated cluster secret (--cluster-secret / PATINADB_CLUSTER_SECRET, sent in the X-Cluster-Secret header), separate from the root-admin credential so the two rotate independently, and all cluster nodes must share the same cluster secret. A clustered node (not --bootstrap) with authentication enabled now refuses to start without an explicit --cluster-secret (issue #446): if it silently fell back to the admin password, a leaked admin password would also be a valid peer-RPC credential, and rotating one would silently affect the other. Set --cluster-secret to a distinct value — you may set it to the current admin password to keep the same effective secret while decoupling the two. A single --bootstrap node has no peers (the secret is moot), so it may omit it, with a startup warning to set one before growing into a cluster. Migrating an existing single-credential cluster: add --cluster-secret <value> (or PATINADB_CLUSTER_SECRET, or the config-file cluster_secret) to every node before restart.

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --auth-user neo4j --auth-password "$PATINADB_AUTH_PASSWORD"

Rotation secret memory ownership

Online admin-password and cluster-secret rotation requests own their decoded secret fields in storage that is zeroized when dropped, including rejected rotations and partially decoded requests. Accepted long-lived credentials are copied into guarded memory before these request-owned strings are released. This does not promise complete erasure of an HTTP request: raw body buffers, JSON parser scratch storage and transport-level copies are outside that ownership boundary. No heap-after-free inspection is used to establish the guarantee.

Users & roles (RBAC)

Beyond the shared credential, the server supports per-user accounts with roles. The configured --auth-user / --auth-password is the built-in root admin (always accepted — you can’t lock yourself out); additional users are created at runtime and replicate across the cluster.

Roles are global and ordered by privilege:

RoleCan do
readerread-only Cypher (MATCH … RETURN)
writerreads and writes (CREATE/SET/DELETE/MERGE)
admineverything: writes, all DDL, cluster /mgmt/*, user management

Manage users via Cypher-style DDL (admin only), e.g. over REST:

# As the root admin:
curl -u neo4j:secret -X POST http://127.0.0.1:21001/cypher \
  -H 'content-type: application/json' \
  -d "{\"query\":\"CREATE USER alice SET PASSWORD 'apw' SET ROLE writer\"}"

# alice can now write but not manage users or the cluster:
curl -u alice:apw  -X POST http://127.0.0.1:21001/cypher -d '{"query":"CREATE (n:Person)"}' ...   # 200
curl -u alice:apw  -X POST http://127.0.0.1:21001/cypher -d '{"query":"CREATE USER eve …"}' ...    # 403
  • CREATE USER <name> SET PASSWORD '<pw>' [SET ROLE <role>] (default role reader)
  • ALTER USER <name> SET PASSWORD '<pw>' | SET ROLE <role>
  • DROP USER <name>, SHOW USERS

User changes replicate through Raft (passwords are argon2-hashed on the leader) and are carried in snapshots. Enforcement applies to both REST and Bolt. Wrong credentials → 401; insufficient role → 403.

Per-database roles

A user has a global default role plus optional per-database overrides. The effective role on database X is the override for X if set, otherwise the global role — so an override can both elevate and restrict a user on a specific database:

# alice is a global reader…
curl -u neo4j:secret … -d "{\"query\":\"CREATE USER alice SET PASSWORD 'apw' SET ROLE reader\"}"
# …but a writer on the `sales` database only:
curl -u neo4j:secret … -d "{\"query\":\"GRANT writer ON DATABASE sales TO alice\"}"
# revoke it again:
curl -u neo4j:secret … -d "{\"query\":\"REVOKE ON DATABASE sales FROM alice\"}"
  • GRANT <role> ON DATABASE <db> TO <user>
  • REVOKE [<role>] ON DATABASE <db> FROM <user>

Per-database roles govern data reads/writes on that database. Cluster management (/mgmt/*), database/user DDL, and GRANT/REVOKE themselves always require the global admin role. SHOW USERS reports each user’s db_roles.

Database-level deny (closed-mode tenant isolation)

By default the role lattice bottoms out at reader — every authenticated, non-admin user can USE any database and read it, including a database it was never granted anything on. For a genuinely multi-tenant deployment, opt into closed-mode RBAC:

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --auth-password "$PW" --rbac-closed
  • Flag --rbac-closed / env PATINADB_RBAC_CLOSED / config key rbac_closed. Off by default — enabling it is a real behavior change: a non-admin whose only credential is a global role loses access to every database it holds no explicit grant on (a per-database role override, or a per-label grant). That loss of blanket access is the isolation.
  • A global admin, the root credential, and (if you disabled auth entirely) the open-node case are always unaffected — closed mode only narrows non-admin access.
  • Enforced identically on both transports — REST (server::authorize_data, which also covers the /changes CDC stream) and Bolt (against the resolved USE-target) — with a Neo.ClientError.Security.Forbidden (403 / Bolt failure) on a denied database.
  • Genuine anamnesis companions are bound to their base database. A <db>__anamnesis provenance companion has no grants of its own; closed mode resolves it back to <db> for the authorization check — a user who can read sales can read sales__anamnesis, and a user with no access to sales cannot reach its companion either. A label-scoped user is authorized against the base db’s label grants, which it typically doesn’t hold for the companion’s synthetic PROV labels — so companions fail closed for label-scoped users unless explicitly granted. Only a genuine companion inherits the base grant (issue #445): the base-binding fires solely when the base database exists and has provenance enabled (i.e. the companion was created by CALL patinadb.anamnesis.enable()). A database that merely ends in __anamnesis but is not a genuine companion governs itself — so a real db named sales__anamnesis is not reachable via a sales grant. As defence in depth the reserved __anamnesis suffix is rejected at CREATE DATABASE / FORK. (Disabling provenance keeps the companion db but makes it self-govern for authorization until re-enabled — fail-closed.)

Combine it with per-database roles or per-label grants (below) to give each tenant exactly the databases/labels it needs, with everything else invisible.

Per-label grants (fine-grained RBAC)

For finer control than a per-database role, grant a user READ or WRITE on a specific label in a database:

# alice may read Person nodes in `sales`, and write Ticket nodes there:
curl -u neo4j:secret … -d "{\"query\":\"GRANT READ ON sales:Person TO alice\"}"
curl -u neo4j:secret … -d "{\"query\":\"GRANT WRITE ON sales:Ticket TO alice\"}"
# revoke one privilege:
curl -u neo4j:secret … -d "{\"query\":\"REVOKE WRITE ON sales:Ticket FROM alice\"}"
  • GRANT READ|WRITE ON <db>:<Label> TO <user>
  • REVOKE READ|WRITE ON <db>:<Label> FROM <user>

A WRITE grant implies READ (you can’t write a label you can’t read).

When a user has any per-label grant for a database, data queries against that database are authorized per label instead of by the blanket db-role. The query’s touched node labels are extracted and checked: every label it reads needs READ, every label it writes (CREATE / MERGE / SET n:Label / REMOVE n:Label / DELETE) needs WRITE. A user with only a (global or per-db) role and no label grants is unaffected — full db access exactly as before.

Enforcement is REJECT, not row-filtering: a query that touches an ungranted label — or an unclassifiable node set (an all-graph MATCH (n), a CALL procedure that reads arbitrary labels, an unlabelled CREATE, or a DELETE whose target label isn’t statically known) — is refused with Neo.ClientError.Security.Forbidden (403 on REST, a failure over Bolt). The label extractor is default-deny: anything it cannot statically classify is rejected, so a missed label can never become a silent grant. Enforcement is identical on REST and Bolt (a shared code path) and on every node (grants replicate through Raft and are snapshot-carried, so each replica decides the same way). SHOW USERS reports each user’s label_grants (db → { label → "r"/"rw" }).

REJECT vs row-filtering — actually a mix, per query shape. A write that touches an ungranted label is always rejected outright (a write can’t be partially applied). A read is more precise: when it touches only granted labels it runs normally; when it would also touch an ungranted label, it returns an empty result (not a 403) instead of a partial one — the ungranted data is treated as if it doesn’t exist, so an aggregation, EXISTS, traversal, or COUNT over it comes back empty rather than leaking a value. Only a genuinely unclassifiable read (an all-graph MATCH (n) with no label, or a CALL to a procedure that can touch arbitrary labels) is rejected with 403, since patinaDB can’t statically prove which labels it would touch and so can’t safely return an empty-but-correct result. A query that projects a whole matched entity (RETURN p, RETURN *, labels(p), properties(p), collect(p), a returned path, …) is also treated conservatively — a node’s ungranted secondary label (added via SET n:Label) isn’t visible to static analysis, so a whole-entity projection returns empty rather than risk leaking it; project explicit properties (RETURN p.name) instead. Per-property privileges (as opposed to per-relationship-property, below) are a documented follow-on.

A pattern property counts as a read of whatever its value touches. An inline pattern property can carry an arbitrary expression, including one that reads the graph:

MATCH (p:Person), (q:Person {age > head([(p)-[:HAS]->(s:Secret) | s.amount])})
RETURN q.name

:Secret and [:HAS] are read here even though neither is named in a MATCH pattern, so both are counted (and authorized) exactly as if they were — for a user granted Person only, this read comes back empty, and the same shape in a CREATE/MERGE pattern property is rejected. Without that, the comparator would work as a value oracle (row present / row absent, one bit per query), and a CREATE (t:Tag {v: <ungranted value>}) would copy ungranted data straight into a label the user can read.

Silent filtering is now OBSERVABLE, without leaking what was filtered (issue #1064). A row-filtered empty read used to be byte-identical over REST to a genuine zero-row result — a single-trusted-backend deployment (this profile) has no RBAC admin watching every response, so it could not tell “no data” from “RBAC quietly filtered this”. If anyone later grants that backend’s own account per-label restrictions (a natural defense-in-depth move), every read touching an ungranted label — or any whole-entity projection, per the callout above — silently drops to empty, and the backend cannot detect it. Two changes close that, without disclosing anything the querying user doesn’t already know from having written their own query (the same soundness argument the empty result itself relies on):

  • The response itself is now distinguishable. REST returns {"filtered": true} instead of the indistinguishable {}; Bolt’s terminal PULL/DISCARD SUCCESS carries a filtered: true metadata key. A genuine zero-row result is completely unchanged either way — no new key, no behavior change for a query that isn’t row-filtered.
  • The durable audit log records it too — a rbac.filtered entry (Decision::Allow, since a permitted-but-filtered read is not a denial), for the specific (user, db) pair, so an operator sees it even if the backend’s own code never checks the marker. Coalesced, not one entry per hit: at this profile’s 99%-read traffic, a misconfigured grant could make FilterEmpty fire on nearly every request — writing one durable entry per hit would put a disk write on the hottest read path in the system and would itself become the dominant routine audit-log entry, crowding out ordinary data.write/data.read under the count-based retention cap (see “Durable audit log” above). The first occurrence for a (user, db) pair is written immediately (so you see the moment filtering started, not after a delay), and repeats within a 5-minute window are only counted, flushing one summary entry when the window elapses. A metric, patinadb_rbac_reads_filtered_total{db,user}, increments on every hit unconditionally (an in-process counter, not a disk write, so it carries none of that coalescing trade-off) — alert on a sustained non-zero rate the same way you’d alert on patinadb_auth_root_login_failures_total (issue #1065).

Relationship-type and relationship-property RBAC (opt-in)

Per-label grants above cover node visibility only — a label-scoped user still reads and writes every relationship type via a traversal. Two further, independently-enabled, opt-in modes close that gap; both are off by default so no existing deployment’s traversals break when you upgrade.

Relationship-type grants — --rbac-rel-grants (env PATINADB_RBAC_REL_GRANTS) enables enforcement of a per-relationship-type grant, using the bracketed form to disambiguate a type from a label:

# alice may traverse/create :KNOWS edges in `social`, but not any other type:
curl -u neo4j:secret … -d "{\"query\":\"GRANT WRITE ON social:[KNOWS] TO alice\"}"
  • GRANT READ|WRITE ON <db>:[TYPE] TO <user> / REVOKE … FROM <user>.
  • With the mode off (the default), a label-scoped user traverses any relationship type freely, unchanged. With it on, a query touching an untyped edge (-->, -[r]-> with no type, [*]) is always denied — the grant is per exact type, and an unclassifiable edge can’t be proven safe.
  • Enforcement is reject-based, not row-filtered (unlike the per-label read case above): a query touching an ungranted relationship type is refused outright.

Relationship-property grants — --rbac-rel-property-grants (env PATINADB_RBAC_REL_PROPERTY_GRANTS) is a finer, independent layer: even with a relationship type granted, an individual property on that type can still require its own grant:

# alice may read the KNOWS.since property, but not KNOWS.secret:
curl -u neo4j:secret … -d "{\"query\":\"GRANT READ ON social:[KNOWS].since TO alice\"}"
  • GRANT READ|WRITE ON <db>:[TYPE].<prop> TO <user> / REVOKE … FROM <user>.
  • Independent of --rbac-rel-grants — enable either, both, or neither.
  • A whole-relationship access (RETURN r, properties(r), a dynamic r[$key]) or an untyped edge is denied outright — as with rel-type grants, there’s no way to statically prove “every property” is safe to grant.

Both modes replicate across the cluster and are snapshot-carried, just like per-label grants; SHOW USERS reports rel_grants/rel_property_grants alongside label_grants.

Security audit log

Every authenticated write / admin / DDL operation and every authorization denial is recorded to an audit log (who, when, action, database, allow/deny, and a literal-collapsed statement fingerprint — so passwords in CREATE USER never appear). Both transports feed it: the REST /cypher choke-point and the Bolt RUN authorization choke-point both record through the same shared dispatch::classify + audit machinery, so a denial or a write/DDL issued over a raw Bolt connection (a driver, or the Neo4j Browser) shows up in the trail exactly like a REST one — Bolt is no longer a blind spot. Read it back, newest-first (admin-only):

curl -u neo4j:secret http://localhost:8080/mgmt/audit?limit=100

Each event is also emitted to tracing (target patinadb::audit) for a centralized trail via your log pipeline. Persistence is asynchronous and node-local. The request updates a bounded in-memory ring and enqueues the event for a background writer, which group-commits to the raft_audit tree in <data-dir>/_audit/patinadb.redb. This node-local file has its own redb writer, so audit retention and audit commits do not hold the Raft log’s writer lock. Its native page cache is capped at the smaller of the configured live-file cache and 64 MiB (64 MiB when unset); this is not a total process-memory limit. Shared-device I/O contention can still affect both files. Committed entries survive a restart; a request response does not acknowledge that its audit event has been fsynced. Graceful audit-log shutdown drains the writer, but a hard crash can lose the uncommitted tail. A full writer queue or a storage failure can also prevent durable persistence of an event. The tracing mirror is an export hook, not a guarantee that an external sink received or persisted it; validate your log pipeline’s delivery and retention separately. At first startup, existing audit rows and their retention counter are copied from _raft in bounded durable batches before the audit writer starts. A completion marker is persisted last; an interrupted copy can resume on restart. The legacy source remains unchanged, and a completed import is not repeated: retention must not resurrect old entries. Include _audit in stopped-node file backups or export each node’s trail; graph backups and Raft snapshots do not include this node-local history. Downgrading to a server that writes audit records in _raft creates a separate continuation, not an automatic two-way migration; preserve both files before changing versions.

Scope: the trail is node-local, NOT Raft-replicated — a denial/write hit exactly one node, so there is no single cluster-wide audit view (ship each node’s tracing output to a central sink for that). Successful reads are not recorded (writes + denials only) — read-operation auditing is a follow-on. Encryption-at-rest for the audit trail and the graph is a storage-backend concern and is out of scope for the engine — which makes full-disk encryption a required production control, not an optional one. See “Encryption at rest” below.

Forensic context: source IP + client origin (issue #1069). Every entry also carries peer_ip (the client’s source address, from REST’s real listener or the accepted Bolt TCP peer — None only in a test harness or an auth-disabled/internal path with no real network context) and an optional origin — a short client-supplied tag naming the specific caller (e.g. "svc-42", a backend instance or pipeline id), read from the reserved origin key (falling back to actor) in REST’s provenance map / Bolt’s tx_metadata. This works independently of Anamnesis provenance being enabled for the target database — Anamnesis only projects that same map into the graph as an opt-in feature; the audit log records it whenever the client sends it, because “which backend instance made this call” is precisely the question a single-principal deployment needs answered (RBAC alone answers only “which role”, and in this profile every call carries the same role). A client-supplied origin is capped at 200 bytes (truncated, never rejected — a truncated-but-present origin still has forensic value) so a stray huge string can’t inflate one audit entry without bound. Old records (persisted before this field existed) simply decode with peer_ip/ origin absent — no migration needed. GET /mgmt/audit returns both fields as JSON strings when present and null when absent (#1355). The endpoint retains its admin-only and Pro/Enterprise entitlement requirements.

Retention is class-aware (issue #1069), not purely count-based. Every denial, plus ddl.user/ddl.admin, any rotate-*/retire-* action, and unlock-user (issue #1071 review follow-up — who cleared a locked-out account’s failed-attempt window, and when, is exactly this class of event), is exempt from the count-based cap — these are the rare, forensically load-bearing events an audit log exists FOR, and at this profile’s ~10 writes/s a purely count-based cap (the pre-#1069 behavior) would let routine data.write entries push a week-old denial out of a 100,000-entry window within hours. --audit-max-entries (default 100,000, 0 = unlimited) now bounds only ROUTINE entries (data.write, data.read, and the DDL classes not named above); a denial or a named DDL/rotation event is retained until the tree itself is dropped (e.g. a fresh install), not pruned by write volume. If your deployment produces an unusual volume of denials specifically (an active attack, or a badly-configured client hammering a wrong password), that volume is now itself the signal to act on — see the patinadb-auth alert group for the analogous root-login-failure counter (issue #1065).

Protected means long-lived, not immortal — a review finding, closed before merge. Making protected entries exempt from the routine cap raises an obvious follow-up: what stops a broken client stuck in a retry loop, or an attacker deliberately drawing denials, from growing the audit tree without bound? --audit-max-protected-entries (default 1,000,000, 0 = unlimited — an explicit opt-out, not the default) is a SEPARATE, much higher, hard safety-net cap on protected entries specifically. Past it, the OLDEST protected entries start giving way — logged loudly (tracing::error!), counted (patinadb_audit_protected_entries_pruned_total), and recorded in the audit trail itself: one audit.retention_loss entry per prune batch (never one per evicted entry — that would be the same unbounded-growth problem in miniature), itself protected, naming how many entries were evicted and the oldest one’s original timestamp — so the loss is visible even to an operator who scrapes neither metrics nor logs. patinadb_audit_protected_entries_total (a gauge, always exported) is the early-warning signal, and the PatinaDBAuditProtectedEntriesGrowing alert fires at 80% of the default cap — weeks before anything is actually evicted in a typical single-backend deployment. Measured (this box, N=20,000 representative Deny entries with realistic username/statement-fingerprint/ peer_ip/origin fields, ALLOCATED disk via blocks()*512 — see audit::tests::bench_protected_entry_disk_cost, #[ignore]): ~416.6 bytes/entry, extrapolating to ~0.39 GiB for the default 1,000,000-entry cap (#1069) — a bound an operator can reason about on disk terms, not a bare number.

Encryption at rest — a REQUIRED operator control

patinaDB stores everything unencrypted on disk. That is a deliberate scope decision, not an oversight — but it shifts a real obligation onto the operator, so it is stated here rather than left implicit.

What sits in the clear under --db:

DataWhereWhy it matters if the file is exfiltrated
argon2 PHC password hashesraft_users redb tree (rbac.rs)argon2 is deliberately slow, but the hashes are offline-crackable — an attacker with the file has unlimited attempts and none of them touch the rate limiter
Graph data (every node, edge, property)the per-database .redb filestenant data, in full
Audit trailraft_audit redb treewho did what, and the literal-collapsed query shapes
Raft lograft_logs treeevery replicated write, including its values

Therefore: run patinaDB on an encrypted volume. LUKS/dm-crypt, a cloud provider’s encrypted block storage, or an encrypted ZFS/APFS dataset all satisfy this. Treat it as you would treat setting --auth-password: the product is not secure without it, and no engine-side setting substitutes for it.

Two limits worth stating plainly, because full-disk encryption is often assumed to do more than it does:

  • It protects a volume at rest, not a running node. Once mounted, every file is readable by anything with filesystem access. It defends against a stolen disk, a decommissioned drive, or a snapshot copied out of a cloud account — not against an attacker who already has a shell on the host.
  • Backups inherit nothing. A /mgmt/snapshot dump or a patinadb backup file is plain bytes wherever it lands. Encrypt the destination too, and treat a backup file as exactly as sensitive as the database it came from.

Engine-side at-rest encryption (an encrypted storage backend, or key-wrapping for the credential tree specifically) remains out of scope; if you need key-management guarantees the OS layer cannot give, that is the gap to raise.

TLS

Native TLS for the HTTP plane

Pass --tls-cert + --tls-key (PEM) to serve the REST / management / Cypher API and the inter-node Raft RPCs (they share --addr) over HTTPS. Peers then talk https:// to each other and verify the certificate.

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --tls-cert /etc/patinadb/server.pem \
  --tls-key  /etc/patinadb/server.key \
  --tls-ca   /etc/patinadb/ca.pem \
  --auth-password "$PATINADB_AUTH_PASSWORD"
  • The certificate’s SANs must cover the peer --addr hosts (IPs/hostnames a peer dials), or peer verification fails.
  • --tls-ca is the CA peers verify each other with — for a self-signed / private-CA cluster, the cert (or CA) that signed every node’s --tls-cert. Omit it when node certs chain to a public CA (system roots are used).
  • TLS is opt-in: with no flags, the node serves plaintext (use the reverse proxy stance below).
  • TLS (native or via a reverse proxy) is required for production. With no transport encryption, REST Basic credentials and Bolt LOGON credentials cross the wire in cleartext on every request — a long-lived, replayable secret an on-path attacker can simply capture. If authentication is enabled (--auth-password, i.e. not --insecure-disable-auth) and neither --tls-cert/--tls-key nor a TLS proxy is configured, the node logs a loud startup warning naming exactly this risk (this fires for a single node too, not just a cluster — the cluster-secret warning above is a separate, additional one that only applies when peers are configured). The node does not refuse to start — a TLS-terminating reverse proxy in front of a plaintext node is a legitimate, supported deployment — but plaintext with no proxy in front of it is not something to run in production.

The same --tls-cert/--tls-key also secures the Bolt endpoint: it terminates TLS before dispatching, so native drivers (bolt+s:// / neo4j+s://) and the Neo4j Browser (wss://) both connect over TLS. The certificate must cover the Bolt host clients dial (see --advertised-addr for routing behind a proxy).

Reverse-proxy termination (alternative / for Bolt)

You can instead terminate TLS at a reverse proxy (nginx, Caddy, HAProxy, a cloud load balancer) — required for encrypted Bolt today:

  • Terminate https:// in front of the REST --addr (or use native TLS above).
  • Terminate bolt+s:// / neo4j+s:// (or wss for the Browser) in front of the Bolt --bolt-addr.

--advertised-addr behind a proxy

neo4j:// routing drivers fetch a routing table and then connect to the address the server advertises. Behind a proxy, the listen address is not the address clients should use, so set --advertised-addr to the public host:port:

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --bolt-addr 127.0.0.1:7687 \
  --advertised-addr graph.example.com:7687 \
  --auth-password "$PATINADB_AUTH_PASSWORD"

Now routing sends drivers to graph.example.com:7687 (your proxy), which terminates TLS and forwards to the node. For a direct bolt:// connection with no proxy, leave --advertised-addr unset (it defaults to --bolt-addr).

Hardening against malformed input

The Bolt wire decoder (packstream) parses attacker-controlled bytes before authentication succeeds — a HELLO/LOGON handshake is unauthenticated by definition. A length-prefixed PackStream List/Map/String used to pre-allocate a buffer sized directly from the untrusted length field, so a handful of crafted bytes claiming a huge length could drive a large-allocation denial-of-service before a single credential was checked. The decoders now bound every pre-allocation by the remaining input size, so a claimed length can never allocate more than the bytes actually available — a malformed/truncated frame errors cleanly instead of pinning memory. Every one of these decoders has a libFuzzer harness in the repository (crates/patinadb-raft/fuzz: packstream_unpack, bolt_request, snapshot_header; crates/patinadb/fuzz: the Cypher parser, the snapshot record stream, and the engram-history stream; crates/patinadb-io/fuzz: the CSV/Parquet/Arrow-IPC import readers). PR CI builds every target as a compile gate against bit-rot (the harnesses live in workspace-excluded crates that cargo build --workspace never touches), and a scheduled workflow (.github/workflows/fuzz.yml, weekly plus manual dispatch) runs each target for a bounded five minutes against its checked-in seed corpus, uploading any crash reproducer as a build artifact. This is bounded CI fuzzing, not a continuous-fuzzing infrastructure like OSS-Fuzz — a deep, hours-long campaign is still a manual cargo +nightly fuzz run <target> from the matching crates/*/fuzz directory.

Unauthenticated Bolt connection lifecycle

An unauthenticated attacker can open a Bolt socket and speak just enough protocol to keep it alive without ever supplying credentials. Three independent, coordinated bounds close every phase of that lifecycle end to end, with no gap between them:

  1. Accept → version handshake (--bolt-handshake-timeout-secs, default 10): a socket that sends nothing, or stalls partway through the 20-byte Bolt version-negotiation preamble (or, on the TLS/WebSocket transports, the TLS ClientHello or the HTTP upgrade), is dropped after this deadline.
  2. Version handshake → first successful authentication (--bolt-auth-timeout-secs, default 30, issue #730): a hard deadline, armed once as a fixed instant the moment the version handshake completes. This closes a residual the idle-read timeout below cannot: a bare HELLO with no credentials, a TELEMETRY message, and any unrecognized message are all answered without authentication — each resets an idle clock, so a client that never intends to authenticate could otherwise dribble one such message forever and keep the connection (and its file descriptor and tokio task) alive indefinitely. Unlike --bolt-idle-timeout-secs, no volume or cadence of pre-auth traffic can push this deadline out; once the connection authenticates it is never checked again.
  3. After authentication (--bolt-idle-timeout-secs, default 300; plus the transaction- and stream-specific reapers, --idle-tx-timeout-secs and --stream-pull-timeout-secs): the usual message-resettable idle clock, appropriate once a real session is established.

--max-bolt-connections sits underneath all three: its permit is acquired only at the instant a connection first authenticates (BoltConn::admit_connection), never at TCP accept, so a flood of connections in any of the unauthenticated phases above can never spend the budget meant for real, authenticated clients — it is bounded purely in time by (1) and (2), regardless of how many are opened at once.

Rejected for this specific gap: a separate semaphore-based cap on “handshake-complete-but-not-authenticated” connections (it does not, by itself, stop a connection within the cap from living forever, and needs new plumbing spanning the listener and an HTTP-mirror question this repository explicitly treats as a follow-on); and reusing the brute-force auth throttle (--auth-max-attempts-per-min) to rate-limit pre-auth traffic — that limiter is keyed on failed authentication attempts, and this exploit never calls authenticate() at all, so it is the wrong tool.

AI-agent access (MCP)

patinaDB no longer ships an MCP server. The patinadb-mcp crate was removed in 2026-07 because its default mode had no filesystem gate: an agent could read and write arbitrary host files through the query tool. Rather than patch a component that was not yet on the supported path, it was withdrawn.

If you need agent access today, put your own adapter in front of the Bolt endpoint and give it a dedicated account whose rights you control with the “Users & roles (RBAC)” mechanisms above — a read-only agent gets a Reader role — and note that the “Cypher-driven file I/O” sandbox below independently confines LOAD CSV / patinadb.export.* regardless of what the agent asks for. A replacement built over Bolt is planned; there is no supported MCP surface in the meantime.

Trust domains

A node exposes several surfaces with different trust expectations. Treat them as distinct and firewall accordingly:

SurfacePortAuthTrust domain
Client REST (/cypher, /mgmt/*)--addrBasic → per-user RBACapplication / operators
Bolt--bolt-addrLOGON → per-user RBACdrivers / Neo4j Browser
Peer RPC (/raft/*)--addrcluster secretother cluster nodes only
/metrics--addrBasic + global Admin by default (open with --insecure-open-metrics)monitoring stack
/health, /version--addropenload balancers / probes

The peer-RPC surface shares the port with the client REST surface but is a cluster-internal trust domain — it should only be reachable from the other nodes, never the public internet. The cluster secret is the boundary; rotate it independently of the admin password. Enable HTTP-plane TLS (--tls-cert / --tls-key / --tls-ca) so peer RPCs and client traffic are encrypted and peers authenticate each other’s certificates.

A peer RPC carries the cluster secret and nothing else (issue #696). Up to that fix every /raft/{append,vote,snapshot} request also carried Authorization: Basic base64(user:root-password) — dead weight since issue #385 made the receiver read only the cluster secret, but with peer TLS off it put the root password on the private network several times a second per peer. So a passive observer on that segment, or an attacker who got traffic for a peer address, recovered full admin rather than just the cluster secret. The Basic header is no longer sent on any peer RPC; the two secrets are now genuinely independent on the wire as well as in configuration.

This does not make peer TLS optional. The cluster secret is still a bearer token in cleartext without it, and it is the boundary for the whole cluster-internal surface.

Firewalling it concretely

The Bolt port is the one most often left open by accident: unlike the REST port it is rarely fronted by a reverse proxy, so whatever you bind is what the internet gets. Bind it to a private interface and allow only your application hosts.

The safest binding is loopback plus a TLS-terminating proxy (see Reverse-proxy termination above). Where drivers must reach the node directly, bind a private address — never 0.0.0.0 on a host with a public interface:

--addr 10.0.1.11:21001 \
--bolt-addr 10.0.1.11:7687

Then restrict both ports. With nftables (adjust the subnets):

table inet patinadb {
  set app_hosts   { type ipv4_addr; flags interval; elements = { 10.0.2.0/24 } }
  set cluster_peers { type ipv4_addr; elements = { 10.0.1.11, 10.0.1.12, 10.0.1.13 } }

  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    iif lo accept

    # Bolt: application hosts only.
    ip saddr @app_hosts   tcp dport 7687  accept
    # REST + peer RPC share this port: allow app hosts AND cluster peers.
    ip saddr @app_hosts   tcp dport 21001 accept
    ip saddr @cluster_peers tcp dport 21001 accept

    # Everything else — including the public internet — is dropped.
  }
}

On a cloud provider, express the same thing as security groups: one group allowing 7687 and 21001 from the application tier, and a second allowing 21001 only from the cluster’s own group (peer RPC). Do not put 0.0.0.0/0 on either.

Because REST and peer RPC share a port, a rule that opens 21001 to your application tier also exposes /raft/* to it. That surface is protected by the cluster secret (a missing or wrong X-Cluster-Secret header is rejected), but treat the secret as a credential of the same weight as the admin password, and prefer network isolation on top rather than relying on it alone.

Verify from outside the allowed range — an unreachable port is the only proof that matters:

# From a host that should NOT have access: both must fail/time out.
nc -zv -w3 <node> 7687
nc -zv -w3 <node> 21001

RBAC changes propagate through Raft (eventual on followers)

Existing Bolt sessions for directory users are also checked against that user’s generation before each authenticated message, including PULL and COMMIT (#1265). Password, role or grant changes, user deletion/recreation and snapshot directory replacement invalidate the old session. Its pending cursor and uncommitted transaction are discarded; reconnect and authenticate again. Changes to another user do not invalidate this session. This does not interrupt a message already in flight. Root-admin and auth-disabled sessions are separate from the replicated user directory.

User and grant changes (CREATE/ALTER/DROP USER, GRANT/REVOKE) are replicated log commands, not local edits: the leader hashes the password (argon2) and proposes the record, and every node applies it on commit. Two consequences:

  • A change is durable and cluster-wide once committed, but a follower only reflects it after it applies that log entry — a just-created user may be briefly unknown on a replica that hasn’t caught up. Authenticate writes and admin against the leader (or use a linearizable read) if you need read-your-own-grant immediately.
  • The root admin (--auth-user / --auth-password) is not replicated — it’s local config accepted directly on every node, so you can always authenticate even before the user directory has replicated (and you can’t lock yourself out of a node by dropping users).

Cypher-driven file I/O (LOAD CSV / export procs)

Two Cypher features touch the server’s local filesystem: LOAD CSV FROM 'file://…' reads a host file, and the CSV export procedures (patinadb.export.csv / patinadb.export.query / apoc.export.csv.query) write one. On the server these are locked down by two independent layers — both must pass:

  1. Role: global Admin. File I/O is authorized by effect, not by whether the query mutates the graph. A LOAD CSV (a read) and the export procs (declared ProcMode::Read) both require the global admin role — a per-database Reader or Writer is refused (403 on REST, a Bolt failure). Graph-only procedures (algorithms, statistics) are unaffected and stay Reader-level.

  2. Path sandbox (deny-by-default). The requested path must canonicalize to a location strictly under a configured allow-directory:

    • --allow-csv-dir <dir> — permitted directories for LOAD CSV reads (mirrors Neo4j’s dbms.directories.import).
    • --allow-export-dir <dir> — permitted directories for export writes.

    Both are repeatable and default-deny: with none configured the server refuses all Cypher file I/O. Paths are canonicalized before the check, so .. traversal and symlinks that escape the allow-directory are rejected. TOCTOU-hardened open (Linux): the canonicalize check and the actual file open are two separate syscalls, which in principle leaves a symlink-swap window between them. On Linux, the sandbox closes it by re-verifying the already-open file descriptor’s real path (via /proc/self/fd/N) is still under an allow-directory before any bytes are read or written — a race that swaps a symlink after the initial check is caught and the operation aborts before data crosses the boundary. (On other platforms the check falls back to canonicalize-then-open; a full openat2(RESOLVE_BENEATH) is a documented follow-up.)

Example: allow reads from /srv/import and writes to /srv/export only:

patinadb-raft --id 1 --addr 127.0.0.1:21001 --db /var/lib/patinadb --bootstrap \
  --auth-password "$PW" \
  --allow-csv-dir /srv/import \
  --allow-export-dir /srv/export

Then, authenticated as an admin:

LOAD CSV WITH HEADERS FROM 'file:///srv/import/people.csv' AS row
CREATE (:Person {name: row.name});

CALL patinadb.export.query('MATCH (n:Person) RETURN n.name AS name',
                           '/srv/export/people.csv');

A path outside those directories (e.g. file:///etc/passwd, or /srv/export/../../etc/cron.d/x) is denied by the sandbox, and a non-admin is denied by the role gate before the query runs — no file is touched either way.

Deployment checklist

  • Set a strong --auth-password (via env var, not a flag in shell history).
  • Put --db on an encrypted volume, and encrypt wherever backups land (see “Encryption at rest”). Nothing patinaDB stores is encrypted by the engine — hashes, graph data, audit trail and Raft log are all in the clear.
  • Set a distinct --cluster-secret (env var) shared by every node.
  • Bind --addr / --bolt-addr to localhost or a private interface; expose only through the TLS proxy.
  • Restrict the peer-RPC / /metrics surfaces to the cluster + monitoring network. /metrics is authenticated and global-Admin-only by default (issue #693) — point the scrape at an Admin credential; only pass --insecure-open-metrics when it’s confined to a trusted monitoring network.
  • Enable TLS (--tls-cert/--tls-key/--tls-ca), or terminate TLS at a reverse proxy, for any production deployment — including a single node: with auth enabled and no TLS, REST/Bolt credentials cross the wire in cleartext (the node warns about this at startup). A multi-node cluster additionally needs it for the cluster secret + peer RPCs (a separate startup warning).
  • Same --auth-password and --cluster-secret on every cluster node.
  • --advertised-addr = the public endpoint when using routing behind a proxy.
  • Back up the --db directory (it holds the graph, the Raft log, and snapshots).
  • Leave --allow-csv-dir / --allow-export-dir unset unless you need LOAD CSV / export procs — file I/O is deny-by-default and requires the global admin role. When you do set them, point at dedicated, isolated directories (never a path holding secrets, config, or the --db dir).

Container deployment

The server image runs as UID/GID 65532:65532 on Distroless. The supplied Compose deployment additionally uses a read-only root filesystem, drops all Linux capabilities, disables privilege escalation and bounds temporary storage and task count. /data remains writable for the database, backups and restore staging. Other output locations need explicit writable mounts; configuration and keys should be mounted read-only. These restrictions require runtime configuration and do not follow automatically from docker run IMAGE. See the repository’s deploy/README.md for the equivalent Docker flags and docs/container-hardening.md for the qualification status.

Separate container roles

The default server image contains only patinadb-raft; the HTTP management API remains available there. The graph browser/Admin panel and offline CLI tools have separate browser and tools Docker targets. Each carries its own dependency notices and SBOM. The server build does not execute npm or depend on Neo4rs. Offline tools need an explicit data mount with the server stopped; do not open a live server directory concurrently. The Browser’s HTTP proxy remains a local, unauthenticated single-user tool: keep its loopback boundary. It is not a public admin authentication gateway. See the deployment README for Linux container invocations and the remaining image qualification gates.

The image verification evidence includes the installed Debian package versions and their original copyright and common-license files, bound to each platform image digest. Missing texts or a package/version mismatch with the runtime SBOM prevent successful verification. Original-text presence alone does not establish license clearance or delivery of corresponding source code.

Docker-only delivery includes the separately licensed OS components. The approved OS profile binds the base digest and exact package/source versions to original copyright and common-license hashes on both platforms. It does not add GPL/LGPL to the application dependency allowlist. Unknown or changed OS inputs stop the gate pending review. Raw scanner unknowns remain recorded alongside the explicit OS review; they are not rewritten as a permissive license.

The Docker build checks every binary’s architecture, GNU loader and dynamic libc/GCC dependencies, rejecting static or unreviewed linkage and alternate search paths. Its runtime-linkage.json records binary hashes and dependencies in the shipped notice bundle. This checks the shared-library assumption; it does not itself deliver the covered sources.

Every published image needs its authenticated corresponding-system-source archive, including the actual builder CA overlay and telemetry dependencies. Preserve the shipped notices and the recipient’s rights to replace a covered LGPL shared library with an interface-compatible modified library and debug that modification. The Docker distribution notice states this limited exception to conflicting proprietary restrictions. It grants no general access to PatinaDB source.

The conservative native-source SBOM also records retained, hash-checked import metadata for ML-KEM, ML-DSA, s2n-bignum and Fiat, and the exact ITT source revision. An import revision identifies provenance; generated files and local adaptations are not represented as an unmodified upstream release or proof of which objects were linked. Remaining original-notice and source-delivery gaps remain explicit.

For a retained Debian source bundle, operators can independently check its signatures and exact image coverage using:

python3 scripts/verify_system_sources.py SOURCE_BUNDLE \
  --keyring /trusted/debian-archive-keys.gpg \
  --image-receipt server=/audit/server/release-evidence.json \
  --image-receipt tools=/audit/tools/release-evidence.json

Choose the trust keyring separately and first generate each external receipt from the actual OCI archive with verify_release_oci.py. Supply one receipt for every variant included in the bundle; each must cover amd64 and arm64. This verifies retained source bytes and image binding. It does not distribute those sources, replace an appropriate recipient-facing source mechanism, or resolve the separate browser original-notice and native-attribution gaps.

To create the distributable source archive from an authenticated local cache:

python3 scripts/build_system_sources.py --cache SOURCE_CACHE \
  --keyring /trusted/debian-archive-keys.gpg \
  --image-receipt server=/audit/server/release-evidence.json \
  --image-receipt tools=/audit/tools/release-evidence.json \
  --output system-sources.tar

The cache contains authentication/original-authentication-report.json, its referenced InRelease/Sources.xz files under authentication/, and the exact source archives under sources/. A previously retained source bundle can serve as this cache. Missing exact versions or invalid signatures/checksums fail; there is no substitution with a newer package. Output must not already exist. Identical inputs produce identical tar bytes. The archive includes its manifest, checksums and offline verifier, but trust keys must be supplied independently. Retain and make the archive accessible through the chosen recipient-facing source-delivery mechanism; running this command alone does not publish it.

To collect the exact sources directly from authenticated Debian repositories, use collect_system_sources.py with the same receipt/keyring/output arguments and repeat --repository HTTPS_BASE=SUITE for each selected source repository. For the current Debian 12 base these are https://deb.debian.org/debian=bookworm, https://deb.debian.org/debian=bookworm-updates and https://security.debian.org/debian-security=bookworm-security. The collector verifies InRelease signatures before trusting index hashes and source locations. It fails if the exact image package versions have left those indexes; use an independently selected signed historical repository or retained cache rather than upgrading sources to unrelated versions. Historical signature verification is an authenticity check, not a security-freshness claim.

The Docker workflow and local docker-build.sh oci|push share collect-release-sources.sh; source collection failure prevents successful completion/publication. The workflow retains the source tar and audit report with its release-evidence artifact. The repository pins original Debian archive public-key bytes and their fingerprints/checksums under scripts/third-party-notices/debian-keys/; rollover co-signatures remain verified. Workflow artifact retention is currently 90 days and access follows repository permissions. It does not replace a durable, recipient-accessible source-delivery mechanism for the intended distribution.

Native components with alternative licenses record an explicit elected_license in the reviewed inventory and generated SPDX metadata. Validation checks that this selection satisfies the original expression, preserves all AND obligations and uses only already allowed terms. Declared expressions and all original notice bytes remain intact. This records the distribution’s choice without asserting that every native provenance or relinking question is resolved.

Upstream MIT declarations (2026-10-03)

The user accepts upstream MIT declarations without maintainer confirmation. Seven reviewed browser dependencies now carry their version-bound package manifest, author metadata, an explicitly labelled SPDX MIT standard template and a declaration explanation. No upstream LICENSE file, copyright holder or year is fabricated. Existing embedded notices remain unchanged. The collector checks the reviewed manifest hash, package identity and MIT election; unknown packages and changed declarations still fail closed. Fresh browser OCI evidence is required; earlier server/tools evidence is retained at its original source.

Identity provider security

OIDC/OAuth validation, SCIM credentials, outbound-network restrictions, local break-glass access, and administrative-restore quarantine are documented in Identity and access management. The implementation currently allows explicit private-network CIDRs while continuing to block metadata endpoints; retain HTTPS and issuer/audience/algorithm restrictions.

Dependency and container license checks

The supply-chain CI inventories all tracked Rust and npm lockfiles, including both embedded frontends and auxiliary tooling. Only approved license terms pass; unknown declarations and failed audits block the aggregate check. It runs for every pull request/main push and daily to detect newly disclosed advisories. Server, browser/admin, tools and telemetry images have separate license/SPDX records for amd64 and arm64, bound to their actual image digests. Basic repository inventory generation does not substitute for image evidence. Branch protection requires the separate documented rollout after the workflow passes on main.