Summary
When a non-run_as_system function performs a write through the wrapped MongoDB collection inside a transaction, the RBAC permission pre-check fetches the target document with a findOne/find that is not given the transaction session. As a result, that read runs outside the transaction and cannot see documents inserted (or modified) earlier in the same transaction.
When the target document was created earlier in the same transaction, the pre-check read returns null, and the write is rejected with:
Error: Update not permitted
even though the user is fully authorized and the document genuinely exists within the transaction.
Impact
- Any rules-enforced (non-system) function that, within a single transaction, inserts a document and then updates it (a common "upsert-or-accumulate" pattern) fails with a misleading
Update not permitted.
- The error message points at authorization, but the real cause is transaction visibility — this is very hard to diagnose, because:
- The user does have write permission.
- The document does exist (a session-aware
findOne in user code finds it moments earlier).
- The same code path works fine when the document already existed before the transaction, so the failure is data-dependent and looks intermittent.
findOneAndUpdate and updateMany share the same pattern and the same latent bug.
Root cause
In packages/flowerbase/src/services/mongodb-atlas/index.ts, the write wrappers run a permission pre-check that reads the current document so it can evaluate write rules against it. That pre-check read is called with only the query — no session/options, while the actual write that follows it is correctly given options (and therefore the session).
updateOne (non-system branch):
// Apply access control filters
const formattedQuery = getFormattedQuery(filters, query, user);
const safeQuery = Array.isArray(formattedQuery) ? normalizeQuery(formattedQuery) : formattedQuery;
const result = await collection.findOne(buildAndQuery(safeQuery)); // ← no session/options passed
if (!result) {
if (options?.upsert) {
const upsertResult = await collection.updateOne(
buildAndQuery(safeQuery),
normalizedData,
options // ← real write DOES get the session
);
emitMongoEvent("updateOne");
return upsertResult;
}
throw new Error("Update not permitted"); // ← thrown here when the in-transaction doc is invisible
}
// ...
const res = await collection.updateOne(
buildAndQuery(safeQuery),
normalizedData,
options // ← real write DOES get the session
);
The asymmetry is the bug: the pre-check read omits the session, so it reads at the default read concern outside the active transaction, while the write correctly joins the transaction.
The same session-less pre-check read appears in:
updateOne → const result = await collection.findOne(buildAndQuery(safeQuery))
findOneAndUpdate → const currentDoc = await collection.findOne(buildAndQuery(safeQuery))
updateMany → const result = await collection.find({ $and: formattedQuery }).toArray()
Each is followed by an if (!result/currentDoc) throw new Error('Update not permitted').
Steps to reproduce
Preconditions:
- A collection with a normal (non-system) role that grants
write/insert (i.e. rules are enforced; the function is not run_as_system).
- MongoDB running as a replica set (so transactions are available).
Inside a single transaction, insert a document and then update it, using the wrapped collection API and passing the session to both calls:
exports = async function () {
const service = context.services.get("mongodb-atlas");
const db = service.db(context.values.get("DATABASE_NAME"));
const coll = db.collection("demo");
const session = await service.startSession();
const _id = new BSON.ObjectId();
const res = await session.withTransaction(
async () => {
// 1) Insert within the transaction (session passed) — succeeds
await coll.insertOne({ _id, counter: 1 }, { session });
// 2) A session-aware read DOES see the just-inserted doc
const found = await coll.findOne({ _id }, { session }); // → returns the doc
// 3) Update the same doc within the same transaction (session passed)
// Flowerbase's INTERNAL permission pre-check findOne runs WITHOUT the session,
// so it cannot see the uncommitted insert → throws "Update not permitted"
await coll.updateOne({ _id }, { $inc: { counter: 1 } }, { session });
return true;
},
{
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" },
readPreference: "primary"
}
);
return { ok: res !== undefined };
};
Expected: the updateOne succeeds (the document exists within the transaction; the user is authorized).
Actual: Error: Update not permitted is thrown from the pre-check if (!result) branch in updateOne, because the internal pre-check findOne reads outside the transaction and sees no document.
Notes:
- Adding
run_as_system: true to the function makes the error disappear, because the system branch skips the permission pre-check entirely — confirming the gate (not the write) is the failure point.
- Stack trace originates at the
throw new Error('Update not permitted') in updateOne (the if (!result) branch), not the later field-validation branch.
Environment
@flowerforce/flowerbase: 1.10.0 (also present on main as of 2026-06-10 — the relevant code is unchanged)
- Self-hosted Flowerbase (Realm-compatible server)
- Node.js: 22
- MongoDB: Atlas, replica set (multi-document transactions)
- OS: Linux (Docker,
node:22 base image)
Observed real-world stack trace
function error: Update not permitted
Error: Update not permitted
at /app/node_modules/@flowerforce/flowerbase/dist/services/mongodb-atlas/index.js:728:31
at Generator.next (<anonymous>)
at fulfilled (/app/node_modules/@flowerforce/flowerbase/dist/services/mongodb-atlas/index.js:5:58)
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
(index.js:728 is the if (!result) throw new Error('Update not permitted') branch of updateOne in the compiled output of 1.10.0.)
Summary
When a non-
run_as_systemfunction performs a write through the wrapped MongoDB collection inside a transaction, the RBAC permission pre-check fetches the target document with afindOne/findthat is not given the transaction session. As a result, that read runs outside the transaction and cannot see documents inserted (or modified) earlier in the same transaction.When the target document was created earlier in the same transaction, the pre-check read returns
null, and the write is rejected with:even though the user is fully authorized and the document genuinely exists within the transaction.
Impact
Update not permitted.findOnein user code finds it moments earlier).findOneAndUpdateandupdateManyshare the same pattern and the same latent bug.Root cause
In
packages/flowerbase/src/services/mongodb-atlas/index.ts, the write wrappers run a permission pre-check that reads the current document so it can evaluate write rules against it. That pre-check read is called with only the query — nosession/options, while the actual write that follows it is correctly givenoptions(and therefore the session).updateOne(non-system branch):The asymmetry is the bug: the pre-check read omits the session, so it reads at the default read concern outside the active transaction, while the write correctly joins the transaction.
The same session-less pre-check read appears in:
updateOne→const result = await collection.findOne(buildAndQuery(safeQuery))findOneAndUpdate→const currentDoc = await collection.findOne(buildAndQuery(safeQuery))updateMany→const result = await collection.find({ $and: formattedQuery }).toArray()Each is followed by an
if (!result/currentDoc) throw new Error('Update not permitted').Steps to reproduce
Preconditions:
write/insert(i.e. rules are enforced; the function is notrun_as_system).Inside a single transaction, insert a document and then update it, using the wrapped collection API and passing the session to both calls:
Expected: the
updateOnesucceeds (the document exists within the transaction; the user is authorized).Actual:
Error: Update not permittedis thrown from the pre-checkif (!result)branch inupdateOne, because the internal pre-checkfindOnereads outside the transaction and sees no document.Notes:
run_as_system: trueto the function makes the error disappear, because the system branch skips the permission pre-check entirely — confirming the gate (not the write) is the failure point.throw new Error('Update not permitted')inupdateOne(theif (!result)branch), not the later field-validation branch.Environment
@flowerforce/flowerbase: 1.10.0 (also present onmainas of 2026-06-10 — the relevant code is unchanged)node:22base image)Observed real-world stack trace
(
index.js:728is theif (!result) throw new Error('Update not permitted')branch ofupdateOnein the compiled output of 1.10.0.)