Declarative User & Role Management¶
This guide walks through managing Neo4j users, roles, and privileges as Kubernetes resources via the Neo4jUser and Neo4jRole CRDs.
If you've previously bootstrapped users by kubectl exec-ing into a pod and running Cypher, this is the GitOps replacement: every user, every role, every privilege expressed as YAML in your repo, reconciled by the operator, drift-corrected automatically.
Concepts at a glance¶
| Resource | Owns | Mirrors |
|---|---|---|
Neo4jUser |
identity, password, status, home DB, role bindings, external auth providers | CREATE/ALTER/DROP USER, GRANT/REVOKE ROLE |
Neo4jRole |
role existence, privileges (GRANT/DENY) on the role | CREATE/DROP ROLE, GRANT/DENY/REVOKE |
Neo4jRoleBinding |
role grants for users the operator does NOT own (SSO/LDAP first-login users) | GRANT/REVOKE ROLE only |
Three CRDs, one design rule: privileges live on Neo4jRole, not on Neo4jUser or Neo4jRoleBinding. Putting privileges on users would re-implement Neo4j's RBAC inside-out and create merge conflicts when two CRs touch the same role. Always model "what can be done" as a role and "who can do it" as a user-to-role binding.
All three CRDs are namespace-scoped and reference their target self-managed Neo4jEnterpriseCluster/Neo4jEnterpriseStandalone via spec.clusterRef in the same namespace. In-database users and roles are not managed on Neo4j Aura — Aura access is governed by Aura IAM / console-RBAC (see the Aura Orchestration Guide). See Cluster vs namespace scope below for the design rationale.
Prerequisites¶
- A
Neo4jEnterpriseClusterorNeo4jEnterpriseStandaloneinReadyphase. - The operator running with the
userandrolecontrollers enabled (production mode loads them by default; in dev mode pass--controllers=cluster,standalone,database,user,role,...). - Enterprise edition (role management and
SET STATUS SUSPENDEDare Enterprise-only features).
Quick start: a read-only user¶
Three resources, applied in order:
# 1. The password lives in a Secret, never in the CR.
apiVersion: v1
kind: Secret
metadata:
name: analytics-reader-creds
namespace: prod
type: Opaque
stringData:
password: "ChangeMe123!"
---
# 2. Optional: a custom role with explicit privileges.
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRole
metadata:
name: analytics-reader
namespace: prod
spec:
clusterRef: prod-cluster
name: analytics_reader # Neo4j role name: letters, digits, underscores only (metadata.name has a hyphen)
privileges:
- "GRANT ACCESS ON DATABASE analytics TO analytics_reader"
- "GRANT MATCH {*} ON GRAPH analytics NODES * TO analytics_reader"
- "DENY WRITE ON GRAPH analytics TO analytics_reader"
Graph privileges never go on a composite database
GRANT MATCH {*} ON GRAPH <composite> is accepted by Neo4j, persisted,
and shown back by SHOW ROLE PRIVILEGES — and does nothing. Graph
privileges attach to a composite database's
constituent target databases, not to the composite.
GRANT ACCESS ON DATABASE <composite> is different: that one is required,
and is how a user is permitted to query through the composite at all.
The operator reports this as PrivilegesResolve=False with reason
GraphPrivilegeOnComposite rather than letting it pass silently.
---
# 3. The user, bound to the role.
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jUser
metadata:
name: analytics-reader
namespace: prod
spec:
clusterRef: prod-cluster
username: analytics_reader
passwordSecretRef:
name: analytics-reader-creds
roles:
- analytics-reader # references the Neo4jRole above
Apply with kubectl apply -f. The operator will:
- Wait for
prod-clusterto beReady. - Create the role
analytics_readerand apply the three privileges. - Create the user
analytics_readerwith the password from the Secret. - Grant
analytics_readerthe roleanalytics_reader. - Set status conditions
Ready=True,RolesSynced=True,PasswordSynced=Trueon the user;Ready=True,PrivilegesSynced=Trueon the role.
If the role does not yet exist when the user is reconciled, the user enters PendingDependencies and waits — when the role lands, the user reconciles automatically.
Common patterns¶
Built-in roles (no Neo4jRole needed)¶
The six Neo4j built-ins (reader, editor, publisher, architect, admin, PUBLIC) always exist. Bind to them directly without a Neo4jRole:
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jUser
metadata:
name: app-service
namespace: prod
spec:
clusterRef: prod-cluster
passwordSecretRef:
name: app-service-creds
roles: [publisher]
PUBLIC is granted to every user automatically. Listing it has no effect; the controller emits a warning event and skips it.
Adopting a built-in role to manage its privileges¶
Built-in role privileges can be customised, but the operator refuses by default to prevent accidents. Opt in with adoptBuiltin: true:
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRole
metadata:
name: editor
namespace: prod
spec:
clusterRef: prod-cluster
name: editor
adoptBuiltin: true
privileges:
- "GRANT MATCH {*} ON GRAPH * TO editor"
- "GRANT WRITE ON GRAPH * TO editor"
- "DENY DELETE ON GRAPH * TO editor" # extra restriction (DELETE = remove graph data; DROP is for schema objects)
Adopted built-in roles are never dropped on CR delete; only their privileges are reconciled.
Password rotation¶
Update the Secret. The operator detects that the Secret changed and issues ALTER USER ... SET PASSWORD ... automatically. status.passwordSecretHash is an opaque change token derived from the Secret's namespace, name, key and resourceVersion — not from the password itself:
kubectl create secret generic analytics-reader-creds \
--from-literal=password='NewStrongPassword!' \
--dry-run=client -o yaml | kubectl apply -f -
The next reconcile (≤30s by default) sets status.passwordLastRotated and emits a PasswordRotated event.
Suspending a user (security incident)¶
The operator issues ALTER USER ... SET STATUS SUSPENDED; the user can no longer authenticate. Role grants in spec.roles continue to be reconciled (they are not revoked). Setting accountStatus: active issues SET STATUS ACTIVE and the user can authenticate again.
Setting a home database¶
Removing the field after it was set issues ALTER USER ... REMOVE HOME DATABASE and reverts to the DBMS default.
External authentication (LDAP / OIDC / SSO)¶
Configure the provider at the DBMS level (dbms.security.authentication_providers etc.), then bind the user to provider-specific IDs:
spec:
clusterRef: prod-cluster
username: alice
externalAuth:
- provider: oidc-okta
id: alice@example.com
- provider: ldap1
id: "uid=alice,ou=people,dc=example,dc=com"
# No passwordSecretRef — alice authenticates via OIDC/LDAP only.
roles: [reader]
You may combine passwordSecretRef and externalAuth; alice will then be authenticatable via either path.
Bind roles to a user the operator does not own¶
When a user is provisioned externally — typically by Neo4j on first OIDC/LDAP login, or by a bulk import outside the operator — there is no Neo4jUser CR for them. Use Neo4jRoleBinding instead:
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRoleBinding
metadata:
name: alice-binding
namespace: prod
spec:
clusterRef: prod-cluster
username: alice@example.com # SSO-provisioned, not a Neo4jUser CR
roles: [editor, analytics-reader]
# enforceExclusive: true # opt in: revoke any role NOT listed here
Key differences from Neo4jUser:
- Never creates or drops the user. If the user does not exist at reconcile time the binding sits in the
UserNotFoundcondition and reconciles automatically when the user appears. - Default is non-exclusive. Other tools or manual grants on the user are tolerated; only the roles named in
.spec.roles(and roles previously granted by this binding) are managed. SetenforceExclusive: trueto make the spec authoritative for the user's complete role set. - Validator forbids overlap with
Neo4jUser. If aNeo4jUserin the same namespace targets the sameclusterRef/username, the binding is rejected. Manage role grants in one place.
On CR delete, deletionPolicy: Revoke (default) revokes only the roles this binding granted (recorded in status.grantedRoles); use deletionPolicy: Retain to release the finalizer without revoking.
Retain on delete¶
By default, deleting the CR also drops the underlying Neo4j user/role. To detach without dropping (useful during migrations):
The controller will only remove the finalizer; the Neo4j user/role lives on.
Creating a role from another role¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRole
metadata:
name: junior-editor
namespace: prod
spec:
clusterRef: prod-cluster
copyOf: editor # honoured ONLY at creation time
privileges:
- "DENY DELETE ON GRAPH * TO junior_editor"
copyOf is consulted only when the role does not yet exist. Once created, .privileges is the source of truth for ongoing reconciliation.
Privileges as fields instead of Cypher¶
For the common cases you can write privileges as structured rules instead of Cypher strings. The operator renders each one to a statement and reconciles it exactly like the string form:
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRole
metadata:
name: analytics-reader
spec:
clusterRef: my-cluster
name: analytics_reader
privilegeRules:
- grant: ACCESS
onDatabase: analytics
- grant: MATCH
properties: ["*"]
onGraph: analytics
nodes: ["*"]
Rules cover ACCESS on a database and TRAVERSE, READ, MATCH and WRITE
on a graph. Anything else stays in privileges, and the two can be combined in
one role. Because the database is a field, a misspelt or not-yet-created one is
reported precisely — PrivilegesResolve=False names
spec.privilegeRules[0].onDatabase — and the role picks the database up as
soon as its Neo4jDatabase appears. The full field reference is in the
Neo4jRole API reference.
Privilege drift reconciliation¶
The role controller treats .privileges as the source of truth. On every reconcile it:
- Reads
SHOW ROLE <name> PRIVILEGES AS COMMANDSfrom Neo4j. - Works out what each
.privilegesstatement looks like once Neo4j has stored it, and compares that with the live set. - Applies the difference: missing privileges get a fresh
GRANT/DENY; extra privileges get a derivedREVOKE.
Step 2 matters because Neo4j does not store a privilege as you wrote it:
| You write | Neo4j stores |
|---|---|
GRANT MATCH {*} ON GRAPH sales NODES * TO r |
... ON GRAPH sales NODE * TO r |
GRANT ACCESS ON DATABASES a, b TO r |
one row per database |
GRANT READ {name, age} ON GRAPH g NODES Person TO r |
one row per property |
GRANT MATCH {*} ON GRAPH g TO r (no segment) |
a NODE * row and a RELATIONSHIP * row |
GRANT EXECUTE FUNCTIONS apoc.* ON DBMS TO r |
... EXECUTE USER DEFINED FUNCTION apoc.* ... |
GRANT ACCESS ON DATABASE Sales TO r |
... ON DATABASE sales ... |
GRANT ACCESS ON DATABASE myalias TO r |
the alias's target database |
So you can write privileges in any form Neo4j accepts. The operator learns each
statement's stored form in one of two ways, chosen once for the whole operator
with --privilege-normalisation (Helm: privilegeNormalisation).
Learn mode (default)¶
The operator learns each statement's stored form from its own grant to the
real role: it reads the role's privileges, grants, and reads again. It keeps the
result in the role's status.privilegeRenderings. It writes nothing to Neo4j
that it would not have written anyway.
What it cannot see is a privilege that was already on the role before the
operator granted it: that grant is a no-op, so nothing new appears. Learn mode
never guesses about such rows. It records them in
status.unattributedPrivileges, never revokes them, and sets
PrivilegesSynced to Unknown with reason UnattributedPrivileges and a
message naming them. That covers:
- a role that had privileges before its
Neo4jRoleexisted — adopted built-ins, roles created by hand, and every existing role when you upgrade from an operator release without learn mode; - a role whose status was lost (an etcd restore) — everything is treated as pre-existing again, which is safe and only weakens enforcement.
Rows granted after learn mode met the role are attributed exactly, and
out-of-band additions are revoked as usual. To get full enforcement on a role
with unattributed rows, remove the unwanted ones by hand: when an unattributed
row goes away, learn mode re-learns, and the next grant attributes whatever the
spec still needs. Deleting and recreating the Neo4jRole also works — the role
starts empty, so everything is attributed.
When an alias is retargeted, a privilege granted through it stays on the old target — as it does in Neo4j itself — and gains the new one. Revoke the old target's row by hand if it should go.
Upgrade the CRDs with the operator
Learn mode keeps its state in Neo4jRole status fields that older CRDs do
not have, and the API server silently drops fields its CRD does not
declare. helm upgrade does not update CRDs. If you upgrade the
operator without them, every Neo4jRole goes to Failed with a message
telling you to apply the chart's crds/ directory. No privilege is
removed while it is Failed, but spec changes are not applied until the
CRDs are upgraded.
Probe mode¶
--privilege-normalisation=probe attributes every row exactly, including on
roles that already had privileges. It grants each distinct statement once to a
short-lived role named operator_privilege_probe_<random>, reads it back and
drops that role. The result is cached in memory per server version and alias
layout, so this happens once per statement per operator process, not on every
reconcile. It also moves a privilege when the alias it was granted through is
retargeted.
Those short-lived roles appear in Neo4j's security log, which is why this is not the default. A role with that prefix that survives was left by an operator crash mid-probe; it holds one privilege, has no users, and is safe to drop.
If you kubectl exec into a pod and run REVOKE ACCESS ON DATABASE x FROM analytics_reader directly, the controller will re-apply that grant within ~30 seconds. To opt out per-role:
With enforcePrivileges: false, the controller still creates the role and applies every privilege in .privileges on each reconcile (including ones you add later, and re-granting one that was revoked out-of-band), but never revokes anything added out-of-band. Useful when you intend to layer manual privilege overrides on top.
Immutable privileges¶
Privileges created with GRANT IMMUTABLE cannot be revoked while authentication is enabled. The controller detects these (via the immutable column of SHOW ROLE PRIVILEGES) and:
- Skips them in the revoke set.
- Emits a
PrivilegesDriftKeptwarning event listing each kept privilege. - Sets
status.privilegeDrift: trueand conditionPrivilegesSynced=False, reason=PrivilegesDrifted.
This is informational, not fatal — the role's Ready condition still reflects whether the requested privileges have been applied.
Attribute-based access control (ABAC)¶
Where Neo4jUser and Neo4jRoleBinding map specific usernames to roles, Neo4jAuthRule maps anyone whose OIDC token matches a condition to roles. It's the operator's binding for Neo4j's attribute-based access control, introduced in Neo4j 2026.03.
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jAuthRule
metadata:
name: emea-business-hours
spec:
clusterRef: production
name: emea_business_hours
condition: |
abac.oidc.user_attribute('region') = 'EMEA'
AND time.transaction('UTC').hour >= 6
AND time.transaction('UTC').hour < 18
grantedRoles:
- reader
Prerequisites before any Neo4jAuthRule will reach Ready:
- The cluster runs Neo4j 2026.03 or later. Older clusters cause the rule to sit in
AuthRuleVersionTooOld=True. - The cluster's
spec.configsetsdbms.security.abac.authorization_providersto a configured OIDC provider name. The operator surfaces this asOIDCProviderConfigured=True/Falseon the rule's status; it does not auto-edit the cluster spec. - Each role in
spec.grantedRolesexists as aNeo4jRolein the same namespace, or directly in Neo4j. Missing roles park the rule inPendingDependencies=Trueuntil they land.
Drift reconciliation: the controller reads SHOW AUTH RULES and converges. Editing the condition out-of-band, disabling the rule, or attaching extra role grants are all reverted on the next reconcile (set enforceRoles: false on the spec to stop revoking out-of-band grants).
Manual queries need a
CYPHER 25prefix. AUTH RULE syntax is only parsed under Cypher 25. Neo4j 2026.x defaults the system database to Cypher 5, so a hand-typedkubectl exec … cypher-shell -- "SHOW AUTH RULES"returns42I06: Invalid input 'AUTH'even when the cluster is fully configured. The operator prefixes its own AUTH RULE statements automatically; for ad-hoc diagnostics, prependCYPHER 25yourself:kubectl exec mycluster-server-0 -- cypher-shell --format plain -u neo4j -p ... \ "CYPHER 25 SHOW AUTH RULES YIELD name, condition, enabled, roles RETURN name, condition, enabled, roles"Alternatively, set the system DB's default language permanently with
ALTER DATABASE system SET DEFAULT LANGUAGE CYPHER 25.
See the Neo4jAuthRule API reference for the full spec, condition syntax, and limitations.
Property-based access control (PBAC)¶
Neo4jRole.spec.privileges accepts the full Cypher privilege grammar, including the FOR pattern WHERE … clause used by property-based access control. PBAC refines MATCH, READ, and TRAVERSE privileges with per-row conditions on node or relationship properties:
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRole
metadata:
name: redacted-reader
spec:
clusterRef: production
name: redacted_reader
privileges:
- "GRANT TRAVERSE ON GRAPH * FOR (n:Email) WHERE n.classification IS NOT NULL TO redacted_reader"
- "DENY READ {*} ON GRAPH * FOR (n) WHERE NOT n.classification IN ['UNCLASSIFIED', 'PUBLIC'] TO redacted_reader"
PBAC privileges flow through the same drift-reconciliation loop as ordinary privileges. The role validator rejects PBAC privileges that name a Neo4jShardedDatabase (PBAC is unsupported on sharded property databases) and warns when ON GRAPH * is combined with a PBAC FOR pattern WHERE … clause, since the privilege would silently no-op against any sharded DBs in scope. See the Neo4jRole API reference for examples and the full list of upstream limitations (single-property rules, performance overhead, property-immutability requirement).
Status conditions reference¶
Neo4jUser¶
| Condition | Meaning |
|---|---|
Ready |
User exists in Neo4j, password and roles in sync |
RolesSynced |
Granted roles equal spec.roles (PUBLIC excluded) |
PasswordSynced |
The password from the current version of the Secret has been applied |
PendingDependencies |
One or more spec.roles reference custom roles that don't yet exist |
ClusterNotReady |
spec.clusterRef exists but is neither Ready nor Degraded (still forming, or has lost its majority) |
Neo4jRole¶
| Condition | Meaning |
|---|---|
Ready |
Role exists, privileges in sync |
PrivilegesSynced |
Live privileges match spec.privileges and spec.privilegeRules (immutable extras excluded). False/PrivilegesDrifted: something could not be reconciled (see events). Unknown/UnattributedPrivileges: learn mode holds rows it cannot attribute and will not revoke — see Learn mode |
PrivilegesResolve |
Every database a privilege names exists. False/DatabaseNotFound: those privileges are skipped (Neo4j refuses a grant on a missing database) and the rest applied; the message names them, and the field for a privilegeRules entry. False/GraphPrivilegeOnComposite: a GRAPH privilege on a composite database, which Neo4j accepts and ignores |
ClusterNotReady |
spec.clusterRef exists but is neither Ready nor Degraded (still forming, or has lost its majority) |
Neo4jRoleBinding¶
| Condition | Meaning |
|---|---|
Ready |
User exists and all desired roles are granted |
RolesSynced |
status.grantedRoles covers spec.roles |
UserNotFound |
The named user does not exist in Neo4j (waiting for SSO/LDAP first-login) |
PendingDependencies |
One or more spec.roles reference a custom role that doesn't exist yet |
ClusterNotReady |
spec.clusterRef exists but is neither Ready nor Degraded (still forming, or has lost its majority) |
The kubectl get printer columns surface the most actionable bits:
$ kubectl get neo4jusers -A
NAMESPACE NAME CLUSTER USERNAME ACCOUNTSTATUS PHASE READY AGE
prod analytics-reader prod-cluster analytics_reader active Ready True 3m
$ kubectl get neo4jroles -A
NAMESPACE NAME CLUSTER PHASE READY DRIFT AGE
prod analytics-reader prod-cluster Ready True false 3m
Lifecycle and ordering¶
| Event | What happens |
|---|---|
| Apply CR | Controller adds finalizer; creates user/role on next reconcile |
| Update spec | Diffed against live state; only changed fields trigger Cypher |
| Update Secret | Secret's resourceVersion changes → ALTER USER SET PASSWORD |
kubectl delete |
Finalizer-protected: controller drops user/role first (unless deletionPolicy: Retain) |
| Cluster neither Ready nor Degraded | ClusterNotReady condition; reconcile requeued every 30s |
| Referenced role missing | PendingDependencies condition; requeue when role lands |
The user controller watches Neo4jRole resources and re-reconciles bound users when a role is created or updated. You don't need to apply CRs in any particular order — the operator converges.
Cluster vs namespace scope¶
All three CRDs are namespace-scoped with same-namespace clusterRef only. This matches the existing pattern of Neo4jDatabase, Neo4jBackup, Neo4jPlugin. It means:
- A team that owns a namespace owns its Neo4j cluster, users, and roles.
- Standard
Role+RoleBindingpatterns apply — noClusterRolerequired. - Reuse of role definitions across clusters is achieved via Kustomize / Helm templating at the manifest layer, not by sharing a single CR.
If you need a true multi-tenant pattern (one shared Neo4j cluster, per-team user manifests in team namespaces), open an issue.
RBAC for the CRDs themselves¶
Grant teams permission to manage their users without granting cluster admin:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: neo4j-user-manager
namespace: prod
rules:
- apiGroups: ["neo4j.neo4j.com"]
resources: ["neo4jusers", "neo4jroles"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
The team can now manage users and roles for the cluster in their namespace, but cannot modify the cluster's infrastructure spec.
Troubleshooting¶
Symptom: PendingDependencies condition stuck on a Neo4jUser.
Check that the referenced custom role's Neo4jRole CR exists in the same namespace and points at the same clusterRef. Built-in role names are case-sensitive (reader, not Reader).
Symptom: Password updates not picked up.
Confirm the Secret's data.<key> (default password) actually changed; kubectl describe the user and look for the PasswordRotated event. The controller detects rotation from the Secret being written (its resourceVersion), not from the password value — if kubectl apply reports the Secret unchanged, nothing was written and there is nothing to rotate. Any write to the Secret, even a label edit, re-applies the password once; that is harmless.
Symptom: validation failed: ... privilege statement must end with TO <role>.
Each entry in Neo4jRole.spec.privileges must end with TO <spec.name> so the operator can derive the matching REVOKE. The role name must match exactly (case-sensitive).
Symptom: PrivilegesSynced is Unknown with reason UnattributedPrivileges.
Learn mode (the default) found rows on the role that were there before the
operator granted to it — every existing role shows this after upgrading to a
release with learn mode. It will not revoke them, because it cannot tell
whether one is a spec statement's own row; status.unattributedPrivileges
lists them. Nothing is broken, but out-of-band additions to those rows are not
removed. To restore full enforcement, either remove the unwanted rows by hand
(when an unattributed row goes away, learn mode re-learns and the next grant
attributes whatever the spec still needs), delete and recreate the
Neo4jRole (it starts empty, so everything is attributed), or run the
operator with --privilege-normalisation=probe. See Learn mode.
Symptom: PrivilegesResolve is False with reason DatabaseNotFound.
A privilege names a database this cluster does not have. Neo4j refuses such a
grant, so the operator skips it and applies the rest of the role. If the
database simply is not created yet, the condition clears on its own — within
seconds of its Neo4jDatabase appearing. Otherwise fix the name; for a
privilegeRules entry the message names the field, e.g.
spec.privilegeRules[2].onDatabase. On a DR cluster, remember the replica of
foo is called foo-replica.
Symptom: every Neo4jRole is Failed with the installed Neo4jRole CRD predates this operator.
The operator was upgraded without its CRDs. helm upgrade never updates CRDs,
and the API server silently drops the status fields learn mode keeps its state
in. Apply the release's CRDs (see the Installation guide);
no privilege is removed while the roles are Failed, but spec changes are not
applied until you do.
Symptom: roles named operator_privilege_probe_<random> in SHOW ROLES, or in the security log.
Those are probe mode's short-lived roles (--privilege-normalisation=probe),
used to learn how Neo4j stores each statement; each is created, read and
dropped within one call. One that survives was left by an operator crash
mid-probe: it holds a single privilege, has no users, and is safe to drop.
Learn mode, the default, never creates them.
Symptom: cannot revoke immutable privilege warning.
Some privileges were created with GRANT IMMUTABLE outside the operator and cannot be removed while auth is enabled. Either change .privileges to include them (so they no longer count as drift) or set enforcePrivileges: false.
Symptom: Cluster Ready but operator can't connect.
The user/role controllers reuse the same connection helper as Neo4jDatabase. If Neo4jDatabase works against the cluster, these will too. If neither works, check the cluster's spec.auth.adminSecret and TLS configuration.
Symptom: RoleSyncFailed events with Neo.ClientError.Cluster.NotALeader on a multi-server cluster.
Admin commands (GRANT, REVOKE, CREATE/DROP ROLE, etc.) must execute on the cluster leader. The operator uses the Neo4j routing scheme (neo4j:///neo4j+s://) so the driver auto-routes writes to the leader; if you see NotALeader errors anyway, the most likely cause is a stuck routing table on the operator's Bolt client (e.g. immediately after a manual leader rotation). The next reconcile (≤30s) refreshes the routing table and the operation succeeds.
If the errors are persistent — not transient — check that dbms.routing.getRoutingTable is reachable from the operator pod (it normally is for any Enterprise 5.26+ cluster).
Limits and non-goals¶
- Cluster admin user safety: the operator refuses to manage usernames matching reserved keywords (
system). The bootstrap admin user (defined bycluster.spec.auth.adminSecret) is technically manageable, but doing so is risky — a misconfiguredNeo4jUsercould lock the operator out of its own cluster. Prefer leaving it alone. - Auto-generated passwords: not supported; you must provide a Secret.
- Cypher-injection of role/user names: the operator quotes all identifiers with backticks and uses parameters for password and provider IDs. Special characters in names are safe.
- Cross-cluster role reuse via a single CR: not supported. Use Kustomize / Helm to template the same
Neo4jRoleinto multiple namespaces.