Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Authentication & TLS

Authentication

The 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 /health and /version (and /metrics only if you opt out of metrics auth — see below).

  • /metrics: authenticated by default — the exposition series carry db=<name> labels, so an open endpoint would let an unauthenticated scraper enumerate every database name and its per-db volume. 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 set insecure_open_metrics: true / the legacy require_metrics_auth: false in the config file). /health and /version stay 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 the X-Cluster-Secret header), 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:

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

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

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

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

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

Per-database roles

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

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

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

Database-level deny (closed-mode tenant isolation)

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

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --auth-password "$PW" --rbac-closed
  • Flag --rbac-closed / env PATINADB_RBAC_CLOSED / config key rbac_closed. Off by default — enabling it is a real behavior change: a non-admin whose only credential is a global role loses access to every database it holds no explicit grant on (a per-database role override, or a per-label grant). That loss of blanket access is the isolation.
  • A global admin, the root credential, and (if you disabled auth entirely) the open-node case are always unaffected — closed mode only narrows non-admin access.
  • Enforced identically on both transports — REST (server::authorize_data, which also covers the /changes CDC stream) and Bolt (against the resolved USE-target) — with a Neo.ClientError.Security.Forbidden (403 / Bolt failure) on a denied database.
  • Anamnesis companions are bound to their base database. A <db>__anamnesis provenance companion has no grants of its own; closed mode resolves it back to <db> for the authorization check — a user who can read sales can read sales__anamnesis, and a user with no access to sales cannot reach its companion either. A label-scoped user is authorized against the base db’s label grants, which it typically doesn’t hold for the companion’s synthetic PROV labels — so companions fail closed for label-scoped users unless explicitly granted.

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 --addr hosts (IPs/hostnames a peer dials), or peer verification fails.
  • --tls-ca is the CA peers verify each other with — for a self-signed / private-CA cluster, the cert (or CA) that signed every node’s --tls-cert. Omit it when node certs chain to a public CA (system roots are used).
  • TLS is opt-in: with no flags, the node serves plaintext (use the reverse proxy stance below).

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:

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

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

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:

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

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

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

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

Embedded library / CLI are unaffected. The sandbox is only installed by the server process; the embedded patinadb library and the patinadb-cli import/export commands 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-addr to localhost or a private interface; expose only through the TLS proxy.
  • Restrict the peer-RPC / /metrics surfaces to the cluster + monitoring network. /metrics is authenticated by default; only pass --insecure-open-metrics when 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-password and --cluster-secret on every cluster node.
  • --advertised-addr = the public endpoint when using routing behind a proxy.
  • Back up the --db directory (it holds the graph, the Raft log, and snapshots).
  • Leave --allow-csv-dir / --allow-export-dir unset unless you need LOAD CSV / export procs — file I/O is deny-by-default and requires the global admin role. When you do set them, point at dedicated, isolated directories (never a path holding secrets, config, or the --db dir).