Authentication & TLS
Authentication
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).# 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 -
REST: HTTP Basic on every route except the open probes
/healthand/version(and/metricsonly if you opt out of metrics auth — see below). -
/metrics: authenticated by default — the exposition series carrydb=<name>labels, so an open endpoint would let an unauthenticated scraper enumerate every database name and its per-db volume. It sits behind the same Basic auth as every other route. 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)./healthand/versionstay open regardless. -
Bolt: checked at LOGON (
scheme: "basic"); an unauthenticated connection cannot run queries. -
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"
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.
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. The log is durable — every entry is
persisted to the node’s own on-disk store (fsynced before the write/denial that
produced it returns), so it survives a restart; it is not just an in-memory
ring. Retention is bounded (--audit-max-entries, default 100,000 — oldest
entries are pruned past the cap; 0 = unlimited). Scope (honest): 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
— use OS-level disk encryption today.
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. This class of decoder is also under
continuous fuzzing (cargo +nightly fuzz run packstream_unpack /
bolt_request, see the repository’s patinadb-raft/fuzz) to catch regressions
before they ship.
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 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.
RBAC changes propagate through Raft (eventual on followers)
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). - 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 by default; 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).