Repository navigation
fix(console): end console sessions when the principal is deleted or its password changes - #704
Open
LeeroyHannigan wants to merge 9 commits into
Open
LeeroyHannigan wants to merge 9 commits into
LeeroyHannigan wants to merge 9 commits into
Conversation
Add a PasswordHashStore trait to CatalogStore with two reads: the stored password hash of an admin, and the console password hash of an IAM user. Each backend implements it in a new password_hash_store.rs. The console uses these reads to tie a session to the password hash it was created against. On Cassandra the reads run at LOCAL_QUORUM. Deleting an admin and changing an admin's password now write at LOCAL_QUORUM instead of the driver default of ONE. Changing an IAM user's password and deleting its iam_users row now commit their lightweight transactions at LOCAL_QUORUM through a new apply_lwt_quorum helper. A read issued after one of these writes returns sees it in the same datacenter.
A console session stayed valid after its admin or IAM user was deleted or had its password changed, until it had been idle for 8 hours. SessionStore::get checked only the idle timeout, and nothing but logout removed a session, so the old cookie kept full console access. Login now reads the principal's password hash once, verifies the password against it, and binds the session to a SHA-256 digest of that hash. Every console request reads the hash from the catalog again. When the principal is gone, has no console password, or has a different hash, the session is removed and the request is redirected to the login page. bcrypt draws a new salt for every hash, so every password change stores a different hash. When the catalog cannot be read, the request gets a 503 and the session is kept. All servers read the same catalog, so a deletion or password change made through one server ends the session on every server, at the next request that server serves for it.
Log in through the console, then change the principal through the management API and check the next console request. A deleted admin's session gets the login redirect while another admin's session still reaches the dashboard. After an admin password change the old session gets the login redirect and the new password logs in. An IAM user's session ends on a password change and again on deletion. A fourth test logs in on a second server that shares the catalog (EXTENDDB_TEST_PEER_ENDPOINT), deletes the admin through the first server, and checks the session on the second one. It is skipped when the variable is unset.
The security model, architecture guide, admin guide and getting started guide now say that deleting an admin or IAM user, or changing its password, ends its console sessions at their next request on every server. The security model states the Cassandra datacenter bound and the 503 response when the catalog cannot be read, and the troubleshooting guide lists both causes.
The session lookup refreshed a session's last-active time before the catalog read. A request refused with 503 because the catalog could not be read therefore still counted as activity, and a client retrying through a long catalog outage kept an idle session alive for as long as the outage lasted. The lookup now only checks the idle timeout. A request whose check passes refreshes the time after the catalog read. A request refused with 503 leaves it unchanged, so the 8 hour idle timeout keeps running during an outage.
require_session turns the result of the session check into the session data, the login redirect, or a 503 when the catalog cannot be read. That mapping is now its own function, with one test per result. No test covered the 503 before: mapping it to the login redirect passed every test.
…rs 503 The architecture guide now says sessions expire after 8 hours of inactivity, as the other guides do, and names the PasswordHashStore trait the console reads password hashes through. The security model says a request refused with 503 does not count as activity. The troubleshooting entry for the 503 says that after a PostgreSQL restart the first reloads can still answer 503, once for each pooled catalog connection the restart closed.
…live No test covered the idle timeout in SessionStore::lookup: with the check removed, an idle session never ended and every console test still passed. SessionStore now holds its time to live in a field. new() sets it to SESSION_TTL, 8 hours, and a test-only with_ttl() sets a shorter one, so a test can backdate a session past the limit. Subtracting 8 hours from Instant::now() returns None on Windows when the host has been up for less than 8 hours. One new test checks that a session idle for longer than its time to live ends and stays ended on the next request, while a session idle for less keeps working. The other checks that new() keeps the 8 hour limit.
The architecture guide said the console reads all data through ManagementStore and PasswordHashStore. It also reads settings through SettingsStore and the login limits through RateLimitStore. The sentence now names all four, which are part of CatalogStore.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 31, 2026 22:40
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Console sessions now end when their admin or IAM user is deleted or has its password changed.
Login reads the principal's password hash from the catalog once, verifies the password against that hash, and stores a SHA-256 digest of the same hash in the session. Every console request reads the hash again and compares digests:
bcrypt draws a new salt for every hash, so every password change stores a new hash, including a change to the same password. The catalog has no record of when a password last changed, and this check does not need one, so there is no migration.
Login used to verify through
verify_admin_passwordandverify_iam_user_password, which read the hash themselves. The console now reads the hash once and uses it for both the check and the digest. A login that races a password change either fails, or creates a session that ends at its first request. It cannot bind the old password to the new hash.The reads go through a new
PasswordHashStoretrait, added toCatalogStore. Each backend implements it in its ownpassword_hash_store.rs.Bound
Nothing is cached. A session stops working at the first request it makes after the delete or password change returns, on any server that shares the catalog. On PostgreSQL that request reads the committed row. MongoDB reads go to the primary. Only one server can open a SQLite catalog.
On Cassandra the hash reads use LOCAL_QUORUM. Deleting an admin, changing an admin password, changing an IAM user password and deleting the user's
iam_usersrow now commit at LOCAL_QUORUM as well; before this change they used the driver default of ONE. Servers in the datacenter that took the write see it at their next request. Servers in other datacenters see it once it has replicated there.Paths covered
Each of these changes or removes the row the check reads. Apart from the Cassandra write consistency above, none of them needed a code change:
DELETE /management/admins/{name}andextenddb manage delete-adminPUT /management/admins/{name}/passwordandextenddb manage change-admin-passwordDELETE /management/accounts/{id}/users/{name}, the console's user delete page andextenddb manage delete-userPUT /management/accounts/{id}/users/{name}/password, called by an admin or by the user, andextenddb manage change-user-passwordNot covered by this change
IF NOT EXISTS(Cassandra: admin_users is written with and without lightweight transactions #695). This change raises their consistency to LOCAL_QUORUM and keeps them plain writes.Secureattribute (console: the session cookie has no Secure attribute #687), and the login response time shows whether a username exists (console and management API login: response time shows whether a username exists #688).Merging with #718
#718 limits failed logins per source address and runs every bcrypt verification through one bounded path. The two PRs conflict in
crates/server/src/console/pages/auth_pages.rsandcrates/server/src/management/password.rs. #718's body lists what the second of the two to merge has to change, including routingverify_loginthrough the bounded path and removingcrate::management::password::verify_password.Why
A console session stayed valid after its admin or IAM user was deleted or had its password changed.
SessionStore::getchecked only the 8 hour idle timeout, and nothing except logout removed a session. A cookie issued before the change kept full console access, including access key creation, until it sat idle for 8 hours.To reproduce on
main:POST /management/adminsand{"admin_name": "ops2", "password": "..."}.ops2and keep theextenddb_sessioncookie.DELETE /management/admins/ops2. It returns 204, and a new console login asops2fails./consolewith the cookie from step 2. The server returns 200 with the dashboard.Step 3 can also be a password change (
PUT /management/admins/ops2/password). Deleting an IAM user or changing its password gives the same result for the user's session.Testing done
Tests
crates/server/src/console/session_tests.rshas 9 tests ofSessionStore, 8 of them ofSessionStore::validateagainst an in-memory catalog. They cover a deleted admin next to an admin who keeps access, a removed session that stays removed when the same hash comes back, an admin password change, an IAM user password change and deletion, a catalog error, a 503 that leaves the last-active time unchanged while a passed check refreshes it, a session idle for longer than its time to live, which ends and stays ended while a session idle for less keeps working, and an unknown token. The ninth checks that sessions last 8 hours without activity.crates/server/src/console/pages/mod.rshas 3 tests of the response for each check result: the session data, the login redirect, and HTTP 503.crates/storage-sqlite/src/password_hash_store.rshas 3 tests that the reads follow password changes and deletions, including deletion of the user's account.tests/test_console_session_revocation.pylogs in through the console and changes the principal through the management API. A deleted admin's session gets the login redirect while another admin's session still reaches the dashboard. After an admin password change the old session gets the redirect and the new password logs in. An IAM user's session ends on a password change, and the next session ends when the user is deleted. A fourth test logs in on a second server on the same catalog (EXTENDDB_TEST_PEER_ENDPOINT), deletes the admin through the first server, and checks the session on the second server. It is skipped when the variable is unset.Negative controls
SessionStore::validate, 5 of the 6 session tests then insession_tests.rsfailed (the unknown token test passed), and the 3 integration tests failed with HTTP 200 where they expect the login redirect. This run came before the activity and idle tests were added.SessionStore::newusing a time to live other than 8 hours, the 8 hour test fails.Commands run
Before the rebase onto
f49c5f823:cargo fmt --all -- --checkandcargo clippy --all-targets -- -D warningson 1.97.0 for the six changed cratescargo test --lockedon stable for extenddb-storage, extenddb-storage-sqlite, extenddb-storage-postgres, extenddb-storage-cassandra, extenddb-storage-mongodb and extenddb-server: 1272 passed, 0 failedcargo test --lockedon 1.88.0 for extenddb-storage, extenddb-storage-sqlite and extenddb-server: 545 passed, 0 failed;cargo check --all-targetson 1.88.0 for the other three backendscargo check --locked --workspace --all-targetson 1.97.0tests/test_console_session_revocation.pyagainst a SQLite server: 3 passed, 1 skippedtests/test_console_session_revocation.pyagainst two servers on one PostgreSQL catalog, withEXTENDDB_TEST_PEER_ENDPOINTpointing at the second server: 4 passed, including the test that deletes the admin through one server and checks the session on the otherCI does not set
EXTENDDB_TEST_PEER_ENDPOINT, so it skips the two-server test.After the rebase onto
f49c5f823and the last two commits (the idle test and an architecture guide sentence):cargo fmt --all -- --checkandcargo clippy --locked -p extenddb-server --all-targets -- -D warningson 1.97.0, the toolchain the Clippy workflow uses: cleancargo test --locked -p extenddb-serveron stable (1.99.0): 249 passed, 0 failedcargo test --locked -p extenddb-serveron 1.88.0: 249 passed, 0 failedChecklist
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).
CI ran the workspace tests on Rust 1.88.0 and on stable, and the integration suites on all four backends. All of them passed. Locally I ran the crates listed under Commands run. Clippy ran with
-D warnings, without the pedantic group.CatalogStoregains thePasswordHashStoretrait, which all four backends implement, and every console request now reads the catalog. Neither has an RFC or an ADR.ADR / RFC: none
Breaking changes
There are no configuration or schema changes. These behaviours change:
admin_usersoriam_users. With 50 clients loading the dashboard in a loop, debug builds on a busy host served 22 to 28 percent fewer pages per second than main on SQLite and 31 to 37 percent fewer on PostgreSQL. Item operations use a separate connection pool, and their latency under that load moved the same way on main and on this branch.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.