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. When it’s empty the node falls back to the admin password, so single-credential deployments are unchanged — but all cluster nodes must share the same cluster secret (or password, in the fallback case).
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. - 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.
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. patinaDB rejects a whole query that touches an ungranted label; it does not transparently filter out the ungranted rows and run the rest (Neo4j’s property/label “traverse/read” filtering model). Row- level filtering, and per-property privileges, are documented follow-ons.
Security audit log
Every authenticated write / admin / DDL operation and every authorization
denial is recorded to a node-local 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 durable,
centralized trail via your log pipeline. Scope (honest): the /mgmt/audit ring is
in-memory + bounded + node-local (not Raft-replicated, not persisted across a
restart). Successful reads are not recorded (writes + denials only). Persisted /
replicated audit and read-operation auditing are follow-ons.
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).
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.)
Embedded library / CLI are unaffected. The sandbox is only installed by the server process; the embedded
patinadblibrary and thepatinadb-cliimport/exportcommands run with the caller’s own privileges and no confinement — there is no remote attacker in that trust boundary.
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) for any multi-node cluster — otherwise the cluster secret + client credentials cross the wire in cleartext (the node warns about this at startup). - 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).