specs/atomic-auth.md
Rama master · réplica de lectura del repositorio original.
# Atomic auth transactions
**Status:** Implemented (PR #4524), preview
**Author:** Colin Barry
**Last updated:** 2026-10-08
> Auth statements run inside `BEGIN` .. `COMMIT`. Everything the transaction
> changes becomes visible on this instance at once, or not at all. Each replica
> then applies it at once too, or is marked behind and catches up by snapshot.
---
## 1. Motivation
Each auth statement used to stand alone. `CREATE USER` took the auth lock,
wrote through to disk, replicated, and released; the next statement started
again. Setting a user up meant several of those in a row, and anything that
went wrong half way left the earlier statements applied.
That is a problem for the common case, which is not one statement but a
sequence:
```cypher
CREATE USER alice IDENTIFIED BY 'secret';
CREATE ROLE analyst;
GRANT MATCH TO analyst;
GRANT READ ON NODES CONTAINING LABELS :Person TO analyst;
SET ROLE FOR alice TO analyst;
```
Run as five statements, every pause between them is a state another session can
see and act on: alice exists with no role, the role exists with no privileges.
A failure at the fourth leaves the first three in place, and a replica can be
holding any prefix of the sequence.
The gaps are not only an inconvenience. Setting a user up often means granting
broadly and then narrowing, and the pause in between is a window where the user
holds more access than anyone intended:
```cypher
CREATE USER alice IDENTIFIED BY 'secret';
GRANT READ {*} ON NODES CONTAINING LABELS :Employee TO alice;
/* alice can read salary here */
DENY READ {salary} ON NODES CONTAINING LABELS :Employee TO alice;
```
A session that authenticates inside that window reads everything. Reordering
is not a general answer: a later `GRANT` clears an earlier `DENY` of the same
permission, so whether denying first helps depends on which permissions are
involved. It does nothing at all for a sequence whose statements are all
grants, where the user is simply unusable, and visible, until every one of them
has landed.
A transaction removes the question: no ordering within it is observable, so
there is no window to get wrong.
Wrapping them in a transaction makes the whole sequence one change.
## 2. Core principle
> An auth transaction is isolated until it commits, atomic when it does, and
> applied whole, or not at all, on every replica.
Nothing it writes is visible to another session, or to a replica, until
`COMMIT`. `ROLLBACK` discards it. Its reads are not isolated in the same way:
a record read once reads the same for the rest of the transaction, but a scan,
such as `SHOW USERS`, sees what other sessions have committed since. A
concurrent change by another transaction to anything the transaction read or
wrote fails the commit rather than overwriting it, and that check at `COMMIT`
is what makes the outcome serializable. A statement that asks only whether any
record exists, such as the check for a first user, depends only on that answer:
records changing while some still exist do not fail the commit, unless the
transaction itself removed one, when it depends on the record the check found
(section 4).
---
## 3. Using it
Open a transaction, run auth statements, commit:
```cypher
BEGIN;
CREATE USER alice IDENTIFIED BY 'secret';
CREATE ROLE analyst;
GRANT MATCH TO analyst;
SET ROLE FOR alice TO analyst;
COMMIT;
```
Until `COMMIT`, no other session sees alice or the role. After it, every other
session and every replica sees all of it.
`ROLLBACK` instead of `COMMIT` discards the whole transaction:
```cypher
BEGIN;
CREATE USER bob;
CREATE ROLE ops;
ROLLBACK; /* neither bob nor ops exists */
```
A transaction reads its own writes, so later statements see earlier ones:
```cypher
BEGIN;
GRANT DATABASE sales TO alice;
SET MAIN DATABASE sales FOR alice; /* sees the grant above */
COMMIT;
```
### 3.1 A transaction is either auth or data, not both
The first statement decides. An auth statement makes it an auth transaction and
every later query of any other kind is refused; a data query makes it a data
transaction and every later auth statement is refused. Either way the error is:
```
A transaction that runs an auth query can contain only auth queries. Run other
queries in a separate transaction.
```
The mixed statement fails. The server does not end the transaction itself.
Over Bolt, the client resets the session after a failure, which ends the
transaction and discards everything in it, so a later `COMMIT` reports that
there is no transaction to commit.
### 3.2 What runs inside an auth transaction
Every auth statement: users, roles, privileges, fine-grained label, edge and
property permissions, database grants, impersonation, and the `SHOW` family
that reads them. A transaction adds no restriction of its own, so a statement
that needs an enterprise licence, or that is not permitted on a replica or a
coordinator, is refused inside a transaction exactly as it is outside one.
`BEGIN` itself still needs a current database, as it does for a data
transaction, so a session without one, such as on a coordinator, cannot open
an auth transaction.
Statements that are not auth statements are refused, including profile queries,
system statements such as `SHOW TRANSACTIONS` and parameter and replication
queries, and the rest of Cypher. A profile query is a data query, so inside an auth
transaction it is refused by the rule in 3.1 above.
Profiles are **not** transactional. A profile write, user or tenant, is refused
inside any transaction: both are durable the moment the statement runs, so a
transaction could neither isolate them nor roll them back.
---
## 4. Conflicts
Two auth transactions that touch unrelated records both commit. One that reads
or writes a record another transaction changed underneath it fails at `COMMIT`
with a serialization error. Nothing is applied, and the error is transient, so
a driver with retry logic replays the transaction without the application
having to handle it. That differs from the data path, whose commit-time
serialization error is not marked transient.
Conflicts are detected per record read, not per subsystem. Two sessions each
creating a different user both succeed; two sessions changing the same user do
not. One exception at bootstrap: on an instance with no users yet, two sessions
each creating their first user conflict, because each has to decide whether it
is creating the first one. And once a transaction has dropped a user, a
`CREATE USER` in it decides whether it is the first user from the user it found
still there, so a concurrent change to that user fails the commit too.
Two statements read more than their names suggest. `DROP ROLE` reads every
user's role assignments to check that none holds the role, so it conflicts with
any concurrent role assignment, even of another role. On a licensed instance,
creating the first user also creates the built-in roles if no role exists yet,
so it reads the whole role set and conflicts with any concurrent `CREATE ROLE`.
Both conflicts are retryable like any other.
A statement that lists records depends on the whole list. This is worth
knowing, because it is the case that surprises people:
```cypher
BEGIN;
CREATE USER erin;
SHOW USERS; /* reads the whole user set */
COMMIT; /* fails if another session created a user meanwhile */
```
The listing is a serialisable read: the transaction saw a user set that another
session has since changed, so committing against it would mean acting on a
state that never existed. Retrying re-reads the set and succeeds.
An open transaction holds no lock between statements. Conflict detection is
optimistic and the loser finds out at `COMMIT`, which takes the system lock
when it has changes to replicate; see section 6 for a commit refused because
another session holds it. A statement can still wait while another session's
system query replicates, because that query holds the replication state while
it does, and every write statement, data or auth, reads the instance's role
under it.
A transaction that reads the same record twice, or asks twice whether any
record of a kind exists, and gets two different answers fails at `COMMIT`, even
if the record has since changed back: it acted on two states that never existed
together.
A transaction that names a database, in `GRANT`, `DENY` or `REVOKE DATABASE`
or `SET MAIN DATABASE`, fails at `COMMIT` if that database has been dropped
since.
---
## 5. Replication
An auth transaction replicates as a single batched request at `COMMIT`. A
replica applies all of it or none of it, so it cannot be left holding a user
without the grant that accompanied it.
The system lock is taken at `COMMIT` for the duration of the flush and
replication, not for the life of the transaction, and only when the transaction
has changes to replicate. An open auth transaction does not block other
sessions' system queries.
A replica the main cannot reach during the commit is marked behind and
recovers by full snapshot. The main sends only the batched format, which a
replica running an earlier version cannot decode. Such a replica rejects the
batch, the commit still succeeds on the main, and the main's next check of the
replica sends it a full auth snapshot, so every auth commit costs that snapshot
until the replica is upgraded. An upgraded replica still accepts the earlier
per-record format from an earlier main, so upgrading replicas before the main,
as section 7 says, avoids that.
An instance that is no longer main when a transaction with changes commits
refuses the commit with "Cannot commit because instance is not main anymore."
The role is checked once, under the replication read lock, before the change is
flushed. A demotion that lands after that check and before the change
replicates leaves it durable on this instance and not replicated, as for an
autocommit system query, and the instance's recovery from the new main later
overwrites it. That window is #5062.
---
## 6. Limits
- **Profiles are outside the transaction.** Profile writes, user and tenant, are
refused inside one. Profile reads answer from committed state, and inside a
data transaction they are not isolated, so repeating one can give a different
answer.
- **Per-user resource limits are outside the transaction.** They are
process-wide and have no rollback. A user dropped inside a transaction keeps
its limits until the transaction commits.
- **Auth transactions are not counted in a database's transaction metrics.**
The commit and rollback counters and the active-transactions gauge count data
transactions; an auth transaction never touches a database.
- **A stream runs each batch as one transaction.** A batch whose transformation
emits auth statements is an auth transaction, so it cannot also contain data
statements. The stream owner's privileges are checked against the database
each statement targets, the system database for auth statements, as they are
for a session. A stream cannot change database, so a data statement's target
is always the stream's own database.
- **`SHOW TRANSACTIONS` and `TERMINATE TRANSACTIONS` find an auth transaction
by its session's current database.** A user with `TRANSACTION_MANAGEMENT` on
that database can list the open transaction's statements, with passwords
masked, and terminate it, without the `AUTH` privilege.
- **`DROP DATABASE ... FORCE` ends an open auth transaction in a session using
that database.** The transactions it ends are chosen by each session's current
database, as for a data transaction, so an auth transaction is ended even
though it never touches the database. Its `COMMIT` then fails, and running it
again needs a session with a current database that still exists.
- **Privilege changes do not reach sessions that are already connected.** A
session checks its statements against the privileges it had when it
authenticated, so a `REVOKE` or `DROP USER` takes effect on that session when
it reconnects, not at `COMMIT`. `COMMIT` checks no privilege of its own: each
statement was checked when it ran. Triggers, streams, and transaction-management
queries re-read privileges. This is existing behaviour, unchanged here.
- **A commit can fail because another session holds the system lock.** If it
is held for more than 100ms, the commit is refused with the same transient
serialization error as a conflict, so a driver's retry logic replays it. A
single auth statement outside a transaction still reports "Multiple
concurrent system queries are not supported." in the same situation.
---
## 7. Backwards compatibility
A single auth statement outside a transaction behaves as before on the instance
that runs it: it takes the lock, writes, replicates and releases, with no
transaction involved. Existing scripts are unaffected, with one exception: a
profile write, user or tenant, inside an explicit transaction was applied at
once before and is now refused.
Some behaviour outside transactions changes too:
- A profile read no longer takes the system lock, so it is not refused while
another session runs a system query.
- `SHOW TRANSACTIONS` shows a statement that sets a password rebuilt from its
parsed form, with every password as `'****'`, so no spelling of a password
reaches it. Other statements are shown as written.
- A replica that fails to apply a profile change refuses it, so the main
recovers the replica by snapshot, instead of acknowledging a change it did
not make.
Replication is the exception. Every auth change now goes out in the batched
format, so an older replica cannot decode it. Outside a transaction each record
a statement changes still goes out as its own batch of one, as before, so a
statement that changes several records is not applied atomically on a replica.
Upgrade replicas before the main; see section 5.