Skip to content

Permission pre-check read in updateOne / findOneAndUpdate / updateMany ignores the transaction session, breaking read-your-own-write inside a transaction #83

Description

@ameliekassnernovacode

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:

  • updateOneconst result = await collection.findOne(buildAndQuery(safeQuery))
  • findOneAndUpdateconst currentDoc = await collection.findOne(buildAndQuery(safeQuery))
  • updateManyconst 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.)


Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions