Repository navigation
fix(iam): delete what a principal or account owns with it - #923
hitesh96db wants to merge 13 commits into
Conversation
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.
navareamol
left a comment
There was a problem hiding this comment.
No blocking findings. Two things to settle:
- CI is red and the branch is behind main.
run-testsfails onpanicking_refresh_does_not_leak_inflight_flagincrates/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 iniam_leftovers.rsandlive_tests.rs. Could you generate the password in the test, or dismiss the alerts with a comment? - The description says a deleted principal leaves nothing behind, but the failed-login history in
login_attemptsis 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'slogin_attemptsrows 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() |
There was a problem hiding this comment.
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.
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.
|
Thanks @navareamol. Both points are addressed in the commits pushed to 1. CI and the branch
2. You were right: failed logins are keyed by the sign-in name
Tests:
|
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
left a comment
There was a problem hiding this comment.
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.
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
mainat35edb778):Changes:
delete_usernow returns errors from these deletes; before, it ignored them.accountscollection is unique onaccount_nameonly, socreate_accountnow 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.IAM <user|group|role> not foundinstead of 204, as IAM answersNoSuchEntity. 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.BEGIN IMMEDIATEon that connection before the rollback ran failed withattempted to call begin_with at non-zero transaction depth. That already happened oncreate_user's error paths; the newNotFoundandAlreadyExistsreturns made it frequent. The creates and deletes now commit or roll back explicitly before they return.I kept cascade rather than IAM's
DeleteConflictrefusal: ExtendDB already cascades access keys, tags and memberships, and refusing would break existing callers.docs/differences-from-dynamodb.mdrecords 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 withextenddb manage. A recreated role listed the old policy and boundary, and a new session of it was allowedListTables. A recreated user and a recreated group behaved the same way.Testing done
extenddb_storage::iam_leftovers_conformance(featuretest-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.tests/iam_leftovers.rs): pass.main'susers.rs,groups.rsandroles.rson both backends. With only the cleanup in SQLite'screate_userremoved, it fails at "created over rows left under its name".management_store::live_tests, added todevtools/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 theextenddb_catalogdatabase, and Cassandra's is private to the crate.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.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.mainmerged.cargo +1.97.0 fmt --all -- --check: clean.cargo +1.97.0 clippy --all-targets -- -D warningson the six changed crates, with both feature sets ofclippy.yml: clean. The workspace-wide run stops at an unused variable incrates/core/tests/data_file_decode_peak.rs, which this branch does not touch.cargo +1.88.0 checkon the changed crates: OK.cargo test --libfor storage, storage-sqlite, storage-mongodb, storage-cassandra and server: pass.cargo test -p extenddb-storage-postgres --testsagainst PostgreSQL 17: 395 passed. 4 failed, and they fail the same way onmainhere: the 3migrate_data_db_nametests need a password in the connection string, anda_role_that_does_not_own_the_tables_is_told_what_to_rundepends on the local superuser setup.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, 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}andPUT .../users|roles/{name}/permissions-boundaryon a principal that does not exist answer 404IAM <type> not found, where they answered 204. A script that attaches before it creates must create first.create-accountwith an account ID that is already in use answersAccount 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.