Skip to content

fix(iam): delete what a principal or account owns with it - #923

Open
hitesh96db wants to merge 13 commits into
mainfrom
fix/iam-delete-leftovers
Open

hitesh96db wants to merge 13 commits into
mainfrom
fix/iam-delete-leftovers

Conversation

@hitesh96db

Copy link
Copy Markdown
Contributor

What

A deleted user, group, role or account now leaves nothing behind for one created later with the same name or ID, on all four backends.

Inline policies and permissions boundaries are stored by principal name, with no foreign key to the principal. On MongoDB and Cassandra, no IAM record is linked to its principal or account at all. Before this change, each delete path removed a different subset (read from main at 35edb778):

Delete SQLite / PostgreSQL left MongoDB left Cassandra left
user inline policies, boundary boundary, tags boundary
group inline policies inline policies, members inline policies, members
role inline policies, boundary inline policies, boundary, tags, sessions inline policies, boundary, tags, sessions
account nothing (foreign keys) every IAM record of the account every IAM record of the account

Changes:

  • Deletes remove everything the principal owns. SQLite and PostgreSQL delete a principal's inline policies and boundary in the same transaction as its row. MongoDB and Cassandra have no transaction across tables, so they delete what a principal owns first, credentials first, and its own record last. A failure then leaves the principal with less than it had, never a credential or permission without it, and a retry finishes. MongoDB's delete_user now returns errors from these deletes; before, it ignored them.
  • Account deletes on MongoDB and Cassandra remove the account's users, groups and roles, and everything they own, before the account record. The "refuse while tables exist" check stays.
  • Creates start clean. After its own insert succeeds, a user, group or role create deletes anything stored under its name. A create that is refused as a duplicate touches nothing. On MongoDB and Cassandra, an account create does the same for its ID. Records that an earlier release left behind are cleared when the name or ID is used again, without a catalog migration. If the cleanup fails, MongoDB and Cassandra remove the new record again, so a retry is not refused as a duplicate.
  • MongoDB refuses a taken account ID. Its accounts collection is unique on account_name only, so create_account now checks the ID first. Before, a second account with the same ID could be created, and the cleanup on create must never reach a live account.
  • A deleted account grants nothing (MongoDB and Cassandra). The access key and session lookups, the console password hash read, the management-API password check and the AssumeRole trust-policy read treat a principal whose account no longer exists as unknown. This covers records that an earlier release's account delete left behind. On Cassandra the account delete now commits at quorum, which the console's quorum reads need to see it.
  • A policy or boundary on a missing principal is refused. The management API and the console answer HTTP 404 IAM <user|group|role> not found instead of 204, as IAM answers NoSuchEntity. The check is in the server: the storage methods stay plain data access, and the cleanup on create covers a put that races a delete.
  • SQLite: no transaction left open on a pooled connection. Dropping a SQLite transaction only queues its rollback. A BEGIN IMMEDIATE on that connection before the rollback ran failed with attempted to call begin_with at non-zero transaction depth. That already happened on create_user's error paths; the new NotFound and AlreadyExists returns made it frequent. The creates and deletes now commit or roll back explicitly before they return.

I kept cascade rather than IAM's DeleteConflict refusal: ExtendDB already cascades access keys, tags and memberships, and refusing would break existing callers. docs/differences-from-dynamodb.md records the difference.

Why

Closes #693.
Closes #694.

A principal created with a deleted principal's name got its permissions: an inline policy or boundary on every backend, and on MongoDB and Cassandra also its access keys, sessions or group members. On MongoDB and Cassandra, a deleted account's users stayed in the catalog with their password hashes.

On SQLite, on main (35edb778), I reproduced the role, user and group cases with extenddb manage. A recreated role listed the old policy and boundary, and a new session of it was allowed ListTables. A recreated user and a recreated group behaved the same way.

Testing done

  • New shared check extenddb_storage::iam_leftovers_conformance (feature test-util). Each backend's suite runs it against a real database. It checks that a delete leaves no policy or boundary, that a create over rows stored under its name starts empty, that a refused duplicate create keeps the live principal's policies and boundary, and that an account created again with the same ID has no principals or policies.
    • SQLite (in-memory) and PostgreSQL 17 (tests/iam_leftovers.rs): pass.
    • Negative controls: the check fails with main's users.rs, groups.rs and roles.rs on both backends. With only the cleanup in SQLite's create_user removed, it fails at "created over rows left under its name".
    • MongoDB (management_store::live_tests, added to devtools/run-mongodb-tests --rust-live) and Cassandra (tests/direct/iam_leftovers.rs): not run locally, because this machine has neither. Both also test that a user and role of a deleted account (only the account record removed) cannot log in or be assumed. The credential stores' new checks have no unit test: MongoDB's hard-codes the extenddb_catalog database, and Cassandra's is private to the crate.
  • New wire test tests/test_iam_delete_leftovers.py (9 tests, all backends). For a role, user, group and account, it gives each everything it can own, deletes it, checks that its key or session is refused, creates it again, and checks that it starts empty and denied. It also checks that the account's user can no longer log in through the management API or the console. Two tests check the 404 on a missing principal.
    • On main's SQLite build, 8 of the 9 fail. The account test passes there, as expected: the foreign keys already cascade an account delete on SQLite. Its failure would show on MongoDB and Cassandra, which I could not run.
    • On this branch, the 27 existing pytest files that create, delete or attach to principals, plus the new file, pass on SQLite release builds (668 passed, 8 skipped) and PostgreSQL release builds (666 passed, 10 skipped), on the head with main merged.
  • cargo +1.97.0 fmt --all -- --check: clean.
  • cargo +1.97.0 clippy --all-targets -- -D warnings on the six changed crates, with both feature sets of clippy.yml: clean. The workspace-wide run stops at an unused variable in crates/core/tests/data_file_decode_peak.rs, which this branch does not touch.
  • cargo +1.88.0 check on the changed crates: OK.
  • cargo test --lib for storage, storage-sqlite, storage-mongodb, storage-cassandra and server: pass.
  • cargo test -p extenddb-storage-postgres --tests against PostgreSQL 17: 395 passed. 4 failed, and they fail the same way on main here: the 3 migrate_data_db_name tests need a password in the connection string, and a_role_that_does_not_own_the_tables_is_told_what_to_run depends on the local superuser setup.

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: n/a. No schema, trait or wire change. The management API now answers 404 where it answered 204 for a policy or boundary on a missing principal.

Breaking changes

  • PUT .../users|groups|roles/{name}/policy/{policy} and PUT .../users|roles/{name}/permissions-boundary on a principal that does not exist answer 404 IAM <type> not found, where they answered 204. A script that attaches before it creates must create first.
  • Deleting a principal removes its inline policies and boundary (and, on MongoDB and Cassandra, everything else it owns). A deployment that relied on them coming back when a principal with the same name was created again must attach them again.
  • On MongoDB, create-account with an account ID that is already in use answers Account already exists.

The upgrade manual has a v0.1.15 entry. No catalog migration is required.


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.

Hitesh Sharma added 7 commits November 9, 2026 17:13
Inline policies and permissions boundaries are stored by principal
name with no foreign key to the principal, and MongoDB and Cassandra
link no IAM record to its principal or account. A delete left them
behind, and a principal created later with the same name, or an
account with the same id, got them and their permissions (#693, #694).

- SQLite, PostgreSQL: a user, group or role delete also deletes its
  inline policies and boundary, in the principal's transaction.
- MongoDB: a user delete also deletes its boundary and tags, a group
  delete its policies and memberships, a role delete its policies,
  boundary, tags and sessions. Errors are returned, not ignored.
- Cassandra: a group delete also deletes its policies and memberships,
  a user delete its boundary, a role delete its policies, boundary,
  tags and sessions.
- MongoDB, Cassandra: an account delete deletes the account's users,
  groups, roles and all they own. What a principal or account owns goes
  before its own record, so a failure leaves less, never more.
- Every backend: a user, group or role create, after its own insert,
  also deletes rows stored under its name, so rows an earlier release
  left are cleared. MongoDB and Cassandra do the same for an account id.
  MongoDB now refuses a taken account id on create.
- MongoDB, Cassandra: a user, access key, session or role of an account
  that no longer exists is unknown when signing in or assuming a role.

extenddb_storage::iam_leftovers_conformance (feature test-util) checks
this against a real database; each backend's suite runs it.

Fixes #693.
Fixes #694.
Putting an inline policy or a permissions boundary on a user, group or
role that does not exist answered 204 and stored it under the name, so
it waited for the next principal created with that name. The
management API and the console now answer 404 "IAM <type> not found",
as IAM answers NoSuchEntity. A create also clears such rows, which
covers a put that races a delete.
Each test gives a role, user, group or account everything it can own, deletes it, checks that its credentials stop working, creates it again with the same name or id, and checks that it starts empty. Two more check that a policy or boundary put on a missing principal is refused with 404.
The getting-started and admin guides list what a user, group, role or account delete removes. The upgrade manual has a v0.1.15 entry, the differences document compares the delete and the put with IAM's DeleteConflict and NoSuchEntity, and the storage guide states the cascade rules a backend must follow.
A principal's create or delete returned an error, such as
AlreadyExists or NotFound, by dropping its BEGIN IMMEDIATE
transaction. Dropping a SQLite transaction only queues its rollback,
and a BEGIN IMMEDIATE on the connection before the rollback ran
failed with "attempted to call begin_with at non-zero transaction
depth", so the next write on that connection answered 500.

The creates and deletes now commit or roll back through one helper
before they return.
@hitesh96db hitesh96db added security v1 Needed for the 1.0 release release-blocker Must ship for its milestone's release labels Nov 10, 2026
@navareamol navareamol self-assigned this Nov 10, 2026

@navareamol navareamol 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.

No blocking findings. Two things to settle:

  1. CI is red and the branch is behind main. run-tests fails on panicking_refresh_does_not_leak_inflight_flag in crates/cache, which this PR does not touch. It passed 100 of 100 runs on main here, so I think it is a flake. Could you update the branch and rerun it? CodeQL also reports 4 "hard-coded cryptographic value" alerts on the test passwords in iam_leftovers.rs and live_tests.rs. Could you generate the password in the test, or dismiss the alerts with a comment?
  2. The description says a deleted principal leaves nothing behind, but the failed-login history in login_attempts is keyed by name and follows it. Measured on SQLite and Cassandra: 6 wrong passwords for a user, delete the user, create the same name again, then the correct password from the same address gets 429 for up to 15 minutes. It is the same on main, so this PR does not cause it. Could you clear that name's login_attempts rows in the delete and the create, or narrow the claim in the description? Filing it as its own issue also works for me.

The wire tests pass on SQLite, PostgreSQL and Cassandra. On main, 8 of the 9 fail on SQLite, as the description says. The two Cassandra direct tests pass too.

I'll approve once 1 is green and 2 is settled.

let cleared = self
.delete_principal_dependents(account_id, principal_type, principal_name)
.await;
if cleared.is_err()

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.

If the clear fails and the delete of the new row fails too, the second error is only logged and the create returns the first. The principal then exists with the old rows still under it. A retry of the create gets 409 and skips the clear, so nothing removes them. Is anything else clearing them in that case? I read this from the code and did not inject the failure.

@navareamol navareamol added this to the 1.0 milestone Nov 10, 2026
Hitesh Sharma added 4 commits November 10, 2026 13:11
panicking_refresh_does_not_leak_inflight_flag slept a fixed 50 ms after
the refresh that panics, and expected the next stale read to start a new
refresh. On a busy CI runner the spawned task had not yet run and been
dropped by then, so the flag was still set and the read was counted as
refresh_skipped_inflight. It failed that way on main, on merge-queue
runs for #827 and #833, and on this branch. A 60 ms delay before the
panic reproduces it every time.

Give the test a 60 s hard TTL, so a hard miss cannot hide a leaked flag,
and wait with a 10 s deadline: first for the panicking refresh to run,
then for a stale read to start a refresh that succeeds. With the RAII
guard removed the test still fails, at the deadline.
# Conflicts:
#	docs/manuals/07-upgrade-manual.md
The Cassandra and MongoDB tests that sign in a user of a deleted account
hashed and checked a password written in the source, which CodeQL
reports as a hard-coded cryptographic value (4 alerts). Generate it per
run instead; the tests check the same thing.
Failed logins are recorded under the sign-in name, account_id/user_name,
with no link to the user. A user created with the name of a deleted one
started with its failures: after five wrong passwords, a delete and a
create of the same name, the right password got 429 from that address
for up to 15 minutes.

A user's delete and create now delete the failed logins under its
sign-in name, and an account's delete those under every account_id/
name, on all four backends. SQLite and PostgreSQL do it in the same
transaction as the principal, MongoDB and Cassandra with the rest of
what the principal or account owns. The account prefix is matched
literally (substr, starts_with, an escaped anchored regex, a filtered
read of the partition keys), so another account whose id starts with
the same digits keeps its failures.

The conformance check covers the user's delete, a create over a name
with failures, and the account's delete, including the other account.
Three wire tests send the reviewer's sequence. Both fail without the
change. The per-address limit is unchanged.
@hitesh96db

Copy link
Copy Markdown
Contributor Author

Thanks @navareamol. Both points are addressed in the commits pushed to b61b793e. CI is running on that head now.

1. CI and the branch

  • Branch updated (8100d88d): merged main. The one conflict was in the upgrade manual, where this PR and main each added a section in the same place; both are kept.
  • panicking_refresh_does_not_leak_inflight_flag (489849bb): it is a timing flake, not caused by this PR. It failed with the same metrics (stale_hits: 2, refresh_success: 0, refresh_skipped_inflight: 1) on main (run 42729580298) and on the merge-queue runs for fix(authz): check a point-in-time restore's target before its source #827 and fix(storage-postgres): store attribute names containing U+0000 in the catalog #833.
    • Cause: the test slept a fixed 50 ms and assumed the spawned refresh that panics had run and been dropped by then. A busy runner misses that window. A 60 ms delay before the panic reproduces it every time.
    • Fix: the test now uses a 60 s hard TTL and waits, with a 10 s deadline, for the panicking refresh to run and then for a refresh to succeed.
    • Checked: it passes with a 60 ms and a 1 s delay injected before the panic, and the cache suite passed 50 of 50 runs. With the RAII guard removed it still fails, at the deadline.
  • CodeQL (5ffbe4d8): the Cassandra and MongoDB tests now generate the password per run instead of using a literal, so the 4 alerts should close.

2. login_attempts follows the name (b61b793e)

You were right: failed logins are keyed by the sign-in name account_id/user_name, with no link to the user. I cleared them rather than narrowing the claim:

  • a user's delete and its create delete the failed logins under its sign-in name. The create covers failures recorded while no user had the name.
  • an account's delete deletes those under every account_id/ name.
  • this is on all four backends. SQLite and PostgreSQL do it in the principal's transaction; MongoDB (failed_logins) and Cassandra do it with the rest of what the principal or account owns.
  • the account prefix is matched literally, not as a pattern, so another account whose ID starts with the same digits keeps its failures.
  • the per-address limit is unchanged.

Tests:

  • The shared iam_leftovers_conformance check now covers the user's delete, a create over a name with failures, and the account's delete, including the other account. It passes on SQLite and PostgreSQL locally. Removing any one of the three new SQLite calls makes it fail with the matching message. On MongoDB and Cassandra it runs in CI.
  • Three wire tests in test_rate_limiting.py send your sequence (wrong passwords, delete, create the same name, right password), plus the variants of a create over a name with failures and an account created again. They pass against a SQLite build of this branch. Against the same build without the fix, they get 429.
  • The upgrade manual, the storage guide and getting-started now list failed logins among what a delete removes.

Hitesh Sharma added 2 commits November 10, 2026 16:56
The two MongoDB tests that check a deleted principal or account leaves
nothing behind lived in src/management_store/live_tests.rs, ignored
unless run with --ignored. Move them to tests/iam_leftovers.rs as
direct integration tests: they call the catalog store against a live
replica set with no ExtendDB server in between, as the Cassandra
crate's tests/direct_integration.rs does.

They use the migration tests' Deployment for their scratch databases,
and skip without EXTENDDB_TEST_MONGODB_URI unless
EXTENDDB_TEST_MONGODB_REQUIRED is 1, which run-mongodb-tests sets. The
one crate-private item they need, MongoCatalogStore::on_database, is
reached through on_database_for_tests, compiled only with the test-util
feature that the crate's test builds enable.
On Cassandra, login_attempts is keyed by (principal, attempted_at) with
attempted_at in milliseconds, so the conformance check's three
back-to-back failures can land in one millisecond and overwrite each
other: cassandra-storage counted 2 where the check expected 3. The
check is about failures going away, so it now asserts they are present
before the delete and in the other account afterwards, and absent where
a delete or create clears them.

The assertion and panic messages named the sign-in name, which carries
the account id; CodeQL reports that as cleartext logging. They now name
a fixed label instead.

@navareamol navareamol 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.

Both points are fixed at bb9b025, and CI is green. The failed-login history now goes with the name on delete, create and account delete, and the tests I ran on SQLite, PostgreSQL and Cassandra pass.

One follow-up, not for this PR: if the cleanup in clear_new_principal fails and removing the new row fails too, a retry gets 409 and the old rows stay. Could you file an issue for it? Approving.

@hitesh96db
hitesh96db added this pull request to the merge queue Nov 11, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 11, 2026

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 security v1 Needed for the 1.0 release

Projects

None yet

4 participants