Skip to content

fix(storage-mongodb): store each access key ID once - #823

Open
LeeroyHannigan wants to merge 2 commits into
mainfrom
fix/mongodb-unique-access-key-id
Open

LeeroyHannigan wants to merge 2 commits into
mainfrom
fix/mongodb-unique-access-key-id

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

What

Migration 6, unique_access_key_ids, gives the MongoDB catalog a unique index on access_keys.access_key_id. It does not apply to PostgreSQL, SQLite or Cassandra, which key that table by the ID.

Before it builds the index, the migration groups the documents of access_keys by ID:

  • Where one user holds every document of an ID, the migration keeps the document a credential lookup reads and deletes the others. It logs each delete, and it records a note that verify prints.
  • To find the document a lookup reads, it reads the record IDs of the copies and sends explain for the lookup's query with that ID. On a forward collection scan it keeps the copy with the lowest record ID. With any other plan, for example through an operator's index on access_key_id, it sends the lookup's own query.
  • It stops with exit 5 and writes nothing in the cases it cannot decide. Each item names the documents and gives the mongosh command that deletes or corrects them. The cases are:
    • an ID of more than one user
    • two or more documents with no ID, or with the same ID that is not a string
    • an ID that is an array
  • The build commits after a majority of the data-bearing voting members build the index. After the build, a read groups only the documents whose ID is an array. listIndexes with includeIndexBuildInfo tells a build that is still running from a built index.

The migration does not refuse an index that an operator added to access_keys. ExtendDB owns the collections, tables and indexes of its catalog and data databases, and it does not support a change to them. When an added index makes the build fail, migrate --yes exits 1 with the server's error. The migration reads one index that it did not create as its own. That index is the unique access_key_id_1 that the upgrade manual tells the operator of a large collection to build.

On MongoDB, create_access_key now answers an internal error when a generated ID is already stored, as PostgreSQL and SQLite do. Before, it answered 404 "User not found".

The upgrade manual describes the migration, its notes and the cases where it stops. It also gives its size limit and a mongosh procedure for a large collection. It names the added indexes that made the build fail. The [storage] section of the admin guide says that ExtendDB owns the collections, tables and indexes of its catalog and data databases. It does not support a change to them, and such a change can make a later migration fail. The guide's advice for slow PostgreSQL queries no longer tells the operator to add indexes. docs/schema/mongodb.txt has the new index, and the docs that name the latest migration now name 6.

Why

The MongoDB catalog had no unique index on access_key_id. Each import stored a document, also under an ID that another document held. A credential lookup read the first of those documents that its query plan met. A delete of the key deleted one of them. So, a key that a user imported twice still authenticated after its delete, and the user's key list still showed it. An import also succeeded for an ID that another user held, and lookups then read either user's document. PostgreSQL, SQLite and Cassandra key the table by the ID, and they answer such an import with 409.

Testing done

At 99953b1:

  • The six MongoDB migration test binaries pass on MongoDB 7.0.45: migrations_fresh, migrations_guards, migrations_holders, migrations_locks, migrations_released and migrations_spill. On 8.0.34, migrations_released, migrations_fresh and migrations_spill pass.
  • migrations_released adds 7 tests of migration 6:
    • A golden test on the oldest released catalog. With the profiler, it also checks that the migration sent no lookup query of its own.
    • The refusals of copies, and the body's own refusal of copies written after detect.
    • An operator's compound index, and a partial index that answers the lookup for some IDs only.
    • Query settings that make the lookup scan backward. This test needs MongoDB 8.0 and returns early on 7.0, which CI runs.
    • Array IDs beside the index.
  • migrations_spill checks a read whose $group spills to disk.
  • The unit tests of the migration cover the key and the options of the index that it reads as its own. They also cover listIndexes entries of a running build, the explained command, and the copy that a forward scan reads. The crate's 173 unit tests pass.
  • Indexes that an operator added, on 7.0.45 and 8.0.34. Beside a non-unique access_key_id_1, migrate --yes deleted the extra copy and then exited 1 with IndexKeySpecsConflict. Beside a unique index on access_key_id under another name, it exited 1 with IndexOptionsConflict. With either index dropped, the next migrate --yes applied migration 6.
  • This migration's index, hidden. On 7.0.45, migrate --yes applied migration 6 and the index stayed hidden. On 8.0.34 it exited 1 with IndexOptionsConflict until the index was unhidden.
  • cargo +1.97.0 clippy -p extenddb-storage-mongodb --all-targets --all-features -- -D warnings, cargo fmt --all -- --check and cargo +1.88.0 check of the extenddb-storage-mongodb package are clean.

At 57ccdc7. The changes since then delete code that none of these runs reach:

  • The six test binaries also passed on MongoDB 7.0.0.
  • On 7.0.0 and 7.0.45, a fail point held a unique build of access_key_id_1. While it held the build, the preview, verify and migrate --yes exited 5 and named an ID of two users.
  • On a local mongo:7 replica set with 1,000,000 keys and 1,000 IDs stored twice, migrate --yes took 35 s. Each ID kept the copy its lookup read. With 2,000,000 keys the preview stopped after the 10 s request limit. After the manual's mongosh procedure, the preview took 0.6 s and migrate --yes took 1.9 s.
  • The MongoDB catalog checks pass: the schema, the catalog lock, access control, the released catalogs and the 0.1.14 binary.
  • TestAccessKeyIdIsStoredOnce in tests/test_auth_permissions.py passes on MongoDB. Earlier on this branch it passed on PostgreSQL and SQLite, and it failed on MongoDB with a main build. The PostgreSQL CLI migration tests also passed earlier on this branch. The files these tests cover did not change after those runs.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: ADR-0023, catalog migrations. This change adds migration 6 under it.

Breaking changes

  • Migration 6 is breaking on MongoDB. A 0.1.x release does not expect the index to refuse a write, so a rollback from 1.0 needs a restore.
  • On MongoDB, migrate --yes can stop with exit 5 until an operator deletes or corrects documents. The output and the upgrade manual give the mongosh commands.
  • On MongoDB, an import of an access key ID that is already stored now answers 409.
  • On a collection of about 2,000,000 keys or more, the read of migration 6 can stop at the 10 s request limit. The upgrade manual gives the steps to run in mongosh first.

Merging with main

Main now has its own migrations 6 and 7, from #671 and #819. When I merge main into this branch, this migration becomes migration 8.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

The MongoDB catalog had no unique index on access_keys.access_key_id, so
every import stored a document, also under an ID that another document
held. A credential lookup read the first matching document its query
met, and a delete removed one document, so a key imported more than once
still authenticated after its delete. PostgreSQL, SQLite and Cassandra
key the table by the ID and answer such an import with 409.

Migration 6, unique_access_key_ids, creates the unique index on MongoDB
and does not apply to the other backends. One aggregation scans the
collection in natural order and groups the documents by ID. Where one
user holds every document of an ID, the body keeps the copy a lookup
reads, logs and deletes the others, and records a note with their _id
values. To find that copy it reads the record IDs of the ID's copies and
sends explain for the lookup's query with that ID, because a partial
index can give one ID another plan than the next. When the plan is a
forward collection scan, the body keeps the copy with the lowest record
ID, which that scan reads first. With any other plan it sends the
lookup's own query. A copy deleted or given another ID since the read is
neither deleted nor named in the note. detect stops the
run with exit 5, and nothing is written, when the documents of an ID
belong to more than one user, when two or more documents have no ID or
share one that is not a string, or when an ID is an array. It also stops
the run for an index that would make the build fail (access_key_id_1
with other options, or the unique index under another name) and for the
unique index when it is hidden, which a lookup does not use. Each item
names the documents or the index and the mongosh command that fixes it.
The body reads again before it writes and refuses the same way. The
build commits once a majority of the data-bearing voting members have
built the index. Once the index is built, the read looks for array IDs
alone, so a reconverge does not group the collection. listIndexes with
includeIndexBuildInfo tells a build that is still running from a built
index, and while one runs the read groups every document.

A generated ID that another key holds now answers an internal error, as
on PostgreSQL and SQLite, instead of 404 "User not found".

The MongoDB migration tests expect migration 6. Tests on released
catalogs cover the body: a golden test, which also checks with the
profiler that the body sent no lookup query of its own, refusals of
copies and of indexes, a table of index variants measured against
MongoDB's own createIndexes, an operator's compound index, a partial
index that answers the lookup for some IDs only, query settings that
make the lookup scan backward (MongoDB 8 only), the read of array IDs
beside the index, the body's own refusal, and a read whose $group spills
to disk. Unit tests of the body cover Decimal128 key directions, entries
of listIndexes for a running build, the explained command, the explain
replies that report a forward scan, and the copy such a scan reads. New pytest cases check, on
every backend, that an import of an existing ID answers 409 and keeps
the first secret, and that a deleted key stops authenticating. The
PostgreSQL CLI migration tests read the not applicable lines of migrate
--list. docs/schema/mongodb.txt has the new index, and the upgrade
manual describes the migration, its notes, its size limit, the steps
that remove extra copies by hand on a large collection, and what to do
when it stops.
/// one, which the build creates beside it. This migration's index, hidden,
/// is refused too: MongoDB 8 answers the build with IndexOptionsConflict,
/// and MongoDB 7 answers that the index exists.
fn conflict(spec: &Document) -> Option<Conflict> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to understand the problem this is solving. It sounds like we're trying to accommodate operators putting their own indexes on access_keys, and working around them if they conflict with the index we want to add. Is that correct? If so, I'd seriously question whether we should be that accommodating. At the most, we should tell users that if they modify the schema we create, it's at their own risk and may prevent future updates, etc., from completing successfully. I don't think we should open the Pandora's box of letting users tweak up ExtendDB-owned tables. Apologies if I'm misreading the intent here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly. When an index would make the build fail, the migration stops before it writes anything and names the index to drop. It accepts one index that it did not create.

That one is the index the upgrade manual tells an operator to build by hand. The runner gives each request 10 s, and on a local mongo:7 replica set the grouping read passed that limit at 2 million keys. For a collection that large, the manual gives the mongosh commands to delete the extra copies and to create the index with createIndex({ access_key_id: 1 }, { unique: true }). MongoDB names that index access_key_id_1, so migration 6 treats it as its own and reads only the documents whose ID is an array.

conflict() covers every other index that makes the build fail: a unique index on access_key_id under another name, a different index named access_key_id_1, and ours while it is hidden. With the refusal, the preview and migrate --yes both stop before they delete a copy. Without it, run deletes any extra copies, then createIndexes fails with MongoDB's own error, and the next run completes once the operator drops the index. The deletes are the same either way. The one exception is our index hidden on MongoDB 7, where the build succeeds and the index stays hidden.

I agree that we should not support changes to ExtendDB's collections. If you would rather not carry the refusals, I will remove Clash, Conflict, conflict() and their tests, keep the check for the hand-built index, and add a sentence to the admin guide that a change to the catalog's collections or indexes can make a later migration fail. Do you want me to make that change?

One other part reads an operator's index, and I would keep it. When one user holds an ID more than once, the body uses the lookup's query plan to find the copy that the lookup reads, because an index on access_key_id can change which copy that is. The body keeps that copy and deletes the others, so the secret that authenticates today still authenticates after the migration.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay ... I'm not going to dig in on this, but I do think it'd be wise to advise users that changing the schema of ExtendDB-owned tables is not supported, and may cause migration and other failures. We will create a great deal of complexity for ourselves otherwise. Also left a note requesting a pass through the "advice" messages to make them simpler and clearer for human consumption.

@LeeroyHannigan LeeroyHannigan Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the change in 99953b1. Migration 6 no longer refuses an index that an operator added to access_keys. Clash, Conflict, conflict() and their tests are gone. So are the Decimal128 handling of index keys and options and the live test of 14 index variants, which only those indexes needed.

The [storage] section of the admin guide now says that ExtendDB owns the collections, tables and indexes of its catalog and data databases. A change to them is not supported and can make a later migration fail. The guide's advice for slow PostgreSQL queries no longer tells operators to add indexes.

Two parts still read an index that the migration did not create:

  • The unique access_key_id_1 that the upgrade manual tells the operator of a large collection to build counts as the migration's own index.
  • When one user holds an ID more than once, the body asks for the lookup's query plan and keeps the copy that the lookup reads.

When an added index makes the build fail, migrate --yes now exits 1 with MongoDB's error. On 7.0.45 and 8.0.34, a non-unique access_key_id_1 gives IndexKeySpecsConflict, and the unique index under another name gives IndexOptionsConflict. After the operator drops the index, the next run applies migration 6. The upgrade manual says this in one paragraph.

Main now has its own migrations 6 and 7, from #671 and #819, so this one becomes migration 8 when I merge main.

@jcshepherd jcshepherd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Questions about whether we're accommodating operator-owned changes to ExtendDB-owned schema, plus a nit.

Migration 6 no longer refuses an index that an operator added to
access_keys. Before this change, detect stopped the run with exit 5
when access_keys had one of these indexes:

- an access_key_id_1 other than the index the migration creates
- the unique index on access_key_id under another name
- the unique access_key_id_1 when hidden

Each refusal printed the mongosh command that drops or unhides the
index. ExtendDB owns the collections, tables and indexes of its catalog
and data databases, and a change to them is not supported. The
migration does not refuse such a change. This change deletes Clash,
Conflict, conflict() and the tests of the index refusals.

It also deletes the Decimal128 handling of key directions and index
options. The only index that the migration reads as its own is the one
that the upgrade manual's command builds, which has an Int32 direction.
Beside an index whose key direction is a Decimal128, the read now
groups the whole collection, which gives the same groups and is slower.
true_value() needs no Decimal128 case: on 7.0.45 the server lists
unique and sparse as booleans, whatever number created them.

This change also deletes the live test that applied migration 6 beside
14 index variants. Each variant ran on a catalog that holds one key, so
the migration had nothing to delete. The test checked only that the
index build succeeds with that index in place. Ten of the variants were
indexes that an operator adds under another name. ExtendDB does not
support such indexes.

The rest of the migration behaves as before. The read groups only the
documents with an array ID when access_key_id_1 is built with the key
and options of the manual's command. The same applies when that index
is hidden. detect refuses an ID that more than one user holds, and an
array ID. It also refuses two or more documents that have no ID or
share an ID that is not a string. run refuses copies written after
detect. When one user holds an ID more than once, the body sends
explain for the lookup's query and keeps the copy that the lookup
reads.

On mongo:7 (server 7.0.45) and mongo:8.0 (server 8.0.34) replica sets,
a non-unique access_key_id_1 stops migrate --yes with exit 1 and
IndexKeySpecsConflict. The run deletes the extra copies before the
build fails, and only its warnings name them. A unique index on
access_key_id under another name stops migrate --yes with exit 1 and
IndexOptionsConflict. After the operator drops either index, migrate
--yes applies migration 6. With a hidden access_key_id_1, migrate --yes
on 7.0.45 applies migration 6 and the index stays hidden. On 8.0.34 it
exits 1 with IndexOptionsConflict, and it applies migration 6 after the
index is unhidden.

The [storage] section of the admin guide's configuration reference now
says that ExtendDB owns these collections, tables and indexes. It adds
that a change to them is not supported and can make a later migration
fail. The guide's advice for slow PostgreSQL queries no longer tells
the operator to add indexes. The upgrade manual no longer lists the
index refusals. It names the two added indexes that made the build
fail. It tells the operator to keep the output of a failed run, whose
warnings are the only record of the copies that run deleted. A new
unit test checks which forms of access_key_id_1 the migration reads as
its own.

Signed-off-by: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
owner's with {delete} in mongosh"
)
}
(Bson::Null, None) => format!(

@jcshepherd jcshepherd Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make a pass on these "advice" snippets, and apply STE to them to make them more immediately intelligible to a user. E.g., "{n} documents in {db}.{COLLECTION} have no access key ID. No login uses these documents, so deleting them is safe. Keep at most one such document and delete the others. To delete them, run this in mongosh: {delete}. Affected documents: {copies}". Problem, reason, action, plain language.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-blocker Must ship for its milestone's release v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants