Skip to content

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 Neo4jEnterpriseCluster or Neo4jEnterpriseStandalone in Ready phase.
  • The operator running with the user and role controllers enabled (production mode loads them by default; in dev mode pass --controllers=cluster,standalone,database,user,role,...).
  • Enterprise edition (role management and SET STATUS SUSPENDED are 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:

  1. Wait for prod-cluster to be Ready.
  2. Create the role analytics_reader and apply the three privileges.
  3. Create the user analytics_reader with the password from the Secret.
  4. Grant analytics_reader the role analytics_reader.
  5. Set status conditions Ready=True, RolesSynced=True, PasswordSynced=True on the user; Ready=True, PrivilegesSynced=True on 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)

spec:
  accountStatus: suspended

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

spec:
  homeDatabase: analytics

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 UserNotFound condition 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. Set enforceExclusive: true to make the spec authoritative for the user's complete role set.
  • Validator forbids overlap with Neo4jUser. If a Neo4jUser in the same namespace targets the same clusterRef/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):

spec:
  deletionPolicy: Retain

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:

  1. Reads SHOW ROLE <name> PRIVILEGES AS COMMANDS from Neo4j.
  2. Works out what each .privileges statement looks like once Neo4j has stored it, and compares that with the live set.
  3. Applies the difference: missing privileges get a fresh GRANT/DENY; extra privileges get a derived REVOKE.

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 Neo4jRole existed — 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:

spec:
  enforcePrivileges: false

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 PrivilegesDriftKept warning event listing each kept privilege.
  • Sets status.privilegeDrift: true and condition PrivilegesSynced=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:

  1. The cluster runs Neo4j 2026.03 or later. Older clusters cause the rule to sit in AuthRuleVersionTooOld=True.
  2. The cluster's spec.config sets dbms.security.abac.authorization_providers to a configured OIDC provider name. The operator surfaces this as OIDCProviderConfigured=True/False on the rule's status; it does not auto-edit the cluster spec.
  3. Each role in spec.grantedRoles exists as a Neo4jRole in the same namespace, or directly in Neo4j. Missing roles park the rule in PendingDependencies=True until 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 25 prefix. AUTH RULE syntax is only parsed under Cypher 25. Neo4j 2026.x defaults the system database to Cypher 5, so a hand-typed kubectl exec … cypher-shell -- "SHOW AUTH RULES" returns 42I06: Invalid input 'AUTH' even when the cluster is fully configured. The operator prefixes its own AUTH RULE statements automatically; for ad-hoc diagnostics, prepend CYPHER 25 yourself:

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 + RoleBinding patterns apply — no ClusterRole required.
  • 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 by cluster.spec.auth.adminSecret) is technically manageable, but doing so is risky — a misconfigured Neo4jUser could 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 Neo4jRole into multiple namespaces.

See also