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 onPOST /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
/healthand/version(and/metricsonly if you opt out of metrics auth — see below). With authentication enabled,/versionexposes 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 carrydb=<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 enumerationSHOW DATABASES, the Boltsystemshim and the/cypherdb-lookup path all deliberately prevent./metricstherefore 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-passwordcredential, or a directory user created withSET ROLE admin. A per-db Reader scrape now gets403. To serve it open on a private, trusted monitoring network, pass--insecure-open-metrics(or setinsecure_open_metrics: true/ the legacyrequire_metrics_auth: falsein the config file) — that opt-out bypasses both the credential and the role floor./healthand/versionstay 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 →429on REST, anAuthenticationRateLimitfailure on Bolt; a successful login clears the counters. Tune with--auth-max-attempts-per-min/PATINADB_AUTH_MAX_ATTEMPTS_PER_MIN;0disables 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 asrotate-admin-passwordalready 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 (seemgmt_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_layerruns 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,0disables). 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 thanUserStore’s own methods — in this codebase nothing does (everyALTER USER … SET PASSWORD/DROP USER/ grant change goes throughUserStore). 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 bypassingUserStoreare 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 bareSHA-256(password)) — a fast keyless hash would itself be an offline-crackable artifact for the cache’s TTL; seecache_key_mac_key’s doc comment inrbac.rsfor 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 — seerbac::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) andtests/rbac.rs’salter_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 theX-Cluster-Secretheader), 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-secretto a distinct value — you may set it to the current admin password to keep the same effective secret while decoupling the two. A single--bootstrapnode 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>(orPATINADB_CLUSTER_SECRET, or the config-filecluster_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:
| Role | Can do |
|---|---|
reader | read-only Cypher (MATCH … RETURN) |
writer | reads and writes (CREATE/SET/DELETE/MERGE) |
admin | everything: 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 rolereader)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/ envPATINADB_RBAC_CLOSED/ config keyrbac_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/changesCDC stream) and Bolt (against the resolvedUSE-target) — with aNeo.ClientError.Security.Forbidden(403/ Bolt failure) on a denied database. - Genuine anamnesis companions are bound to their base database. A
<db>__anamnesisprovenance companion has no grants of its own; closed mode resolves it back to<db>for the authorization check — a user who can readsalescan readsales__anamnesis, and a user with no access tosalescannot 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 byCALL patinadb.anamnesis.enable()). A database that merely ends in__anamnesisbut is not a genuine companion governs itself — so a real db namedsales__anamnesisis not reachable via asalesgrant. As defence in depth the reserved__anamnesissuffix is rejected atCREATE 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, orCOUNTover it comes back empty rather than leaking a value. Only a genuinely unclassifiable read (an all-graphMATCH (n)with no label, or aCALLto a procedure that can touch arbitrary labels) is rejected with403, 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 viaSET 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
:Secretand[:HAS]are read here even though neither is named in aMATCHpattern, so both are counted (and authorized) exactly as if they were — for a user grantedPersononly, this read comes back empty, and the same shape in aCREATE/MERGEpattern property is rejected. Without that, the comparator would work as a value oracle (row present / row absent, one bit per query), and aCREATE (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/DISCARDSUCCESScarries afiltered: truemetadata 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.filteredentry (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 ordinarydata.write/data.readunder 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 onpatinadb_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 dynamicr[$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:
| Data | Where | Why it matters if the file is exfiltrated |
|---|---|---|
| argon2 PHC password hashes | raft_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 files | tenant data, in full |
| Audit trail | raft_audit redb tree | who did what, and the literal-collapsed query shapes |
| Raft log | raft_logs tree | every 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/snapshotdump or apatinadb backupfile 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
--addrhosts (IPs/hostnames a peer dials), or peer verification fails. --tls-cais 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-keynor 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:
- Accept → version handshake (
--bolt-handshake-timeout-secs, default10): a socket that sends nothing, or stalls partway through the 20-byte Bolt version-negotiation preamble (or, on the TLS/WebSocket transports, the TLSClientHelloor the HTTP upgrade), is dropped after this deadline. - Version handshake → first successful authentication
(
--bolt-auth-timeout-secs, default30, 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 bareHELLOwith no credentials, aTELEMETRYmessage, 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. - After authentication (
--bolt-idle-timeout-secs, default300; plus the transaction- and stream-specific reapers,--idle-tx-timeout-secsand--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:
| Surface | Port | Auth | Trust domain |
|---|---|---|---|
Client REST (/cypher, /mgmt/*) | --addr | Basic → per-user RBAC | application / operators |
| Bolt | --bolt-addr | LOGON → per-user RBAC | drivers / Neo4j Browser |
Peer RPC (/raft/*) | --addr | cluster secret | other cluster nodes only |
/metrics | --addr | Basic + global Admin by default (open with --insecure-open-metrics) | monitoring stack |
/health, /version | --addr | open | load 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:
-
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 (declaredProcMode::Read) both require the globaladminrole — a per-database Reader or Writer is refused (403on REST, a Bolt failure). Graph-only procedures (algorithms, statistics) are unaffected and stay Reader-level. -
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 forLOAD CSVreads (mirrors Neo4j’sdbms.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 fullopenat2(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
--dbon 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-addrto localhost or a private interface; expose only through the TLS proxy. - Restrict the peer-RPC /
/metricssurfaces to the cluster + monitoring network./metricsis authenticated and global-Admin-only by default (issue #693) — point the scrape at an Admin credential; only pass--insecure-open-metricswhen 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-passwordand--cluster-secreton every cluster node. -
--advertised-addr= the public endpoint when using routing behind a proxy. - Back up the
--dbdirectory (it holds the graph, the Raft log, and snapshots). - Leave
--allow-csv-dir/--allow-export-dirunset unless you needLOAD CSV/ export procs — file I/O is deny-by-default and requires the globaladminrole. When you do set them, point at dedicated, isolated directories (never a path holding secrets, config, or the--dbdir).
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.