From 177fc594383459400471eda2ebafaa641da11870 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:54:31 +0000 Subject: [PATCH 1/4] fix(console): serve an IAM user its own user page and access keys only An IAM-user console session could open every page of its own account. The account page named every user, group and role. A user page showed that user's access key IDs, inline policy names, groups and tags. A group page showed its members and inline policy names, and a role page showed its trust policy, inline policy names and tags. The handlers checked only that the account in the path was the session's own. The management API answers the same reads to the same user with HTTP 403. An IAM-user session now opens only its own user page and the forms that create and delete its own access keys. These are the access key reads and writes the management API gives the same user. The console has no form to change the password or import a key. require_user_session lets an IAM-user session through when the account ID and the user name in the path are its own. The management API applies the same rule to an IAM user's access keys. Any other user's pages redirect it to /console before the handler reads anything about that user. The account page, the group and role pages and the account list are for admins. Their handlers redirect an IAM-user session to /console before they read what the page shows. The admin-only forms under the account also redirect it to /console. Before, some of them redirected to the account page, the group page or the account list. The user's own page lists its access keys and leaves out its inline policies, groups and tags. The management API does not give the user those either. The dashboard links an IAM-user session to its own user page, and the navigation bar shows the Accounts link to admins only. The breadcrumbs of the user page and the new access key page leave out the pages the session may not open. Admin sessions get the same pages as before. The tests that read an IAM user's own account page, its dashboard link and its account list now expect the redirects. --- crates/server/src/console/html.rs | 23 ++- .../src/console/pages/access_key_pages.rs | 51 ++--- .../server/src/console/pages/account_pages.rs | 47 +++-- .../src/console/pages/account_pages_tests.rs | 26 ++- .../server/src/console/pages/account_scope.rs | 114 ++++++++++- .../server/src/console/pages/group_pages.rs | 21 +- crates/server/src/console/pages/mod.rs | 12 +- crates/server/src/console/pages/role_pages.rs | 11 +- crates/server/src/console/pages/user_pages.rs | 119 ++++++++--- .../src/console/pages/user_pages_tests.rs | 184 ++++++++++++++++++ tests/test_console_account_scope.py | 15 +- tests/test_console_integration.py | 12 +- tests/test_cross_account_isolation.py | 14 +- 13 files changed, 514 insertions(+), 135 deletions(-) create mode 100644 crates/server/src/console/pages/user_pages_tests.rs diff --git a/crates/server/src/console/html.rs b/crates/server/src/console/html.rs index f1d93616a..b5617490c 100755 --- a/crates/server/src/console/html.rs +++ b/crates/server/src/console/html.rs @@ -163,13 +163,26 @@ label{{display:block;margin:0.5rem 0 0.2rem;font-weight:500;font-size:0.9rem}} ) } -/// Build the navigation bar for an authenticated session. -pub fn nav_bar(identity_label: &str) -> String { +/// The caller a navigation bar is built for: the label shown at its right, +/// and whether the caller is an admin. +pub struct NavIdentity { + pub label: String, + pub is_admin: bool, +} + +/// Build the navigation bar for an authenticated session. The Accounts link +/// is left out for IAM users, because the account list and the account pages +/// redirect an IAM-user session to the console dashboard. +pub fn nav_bar(identity: &NavIdentity) -> String { + let accounts_link = if identity.is_admin { + "\nAccounts" + } else { + "" + }; format!( r#""#, - identity = escape(identity_label), + identity = escape(&identity.label), ) } diff --git a/crates/server/src/console/pages/access_key_pages.rs b/crates/server/src/console/pages/access_key_pages.rs index a16eeb971..1236fb116 100755 --- a/crates/server/src/console/pages/access_key_pages.rs +++ b/crates/server/src/console/pages/access_key_pages.rs @@ -13,11 +13,10 @@ use axum::response::{Html, IntoResponse, Redirect, Response}; use crate::console::ConsoleState; use crate::console::html; -use super::account_scope::require_account_session; +use super::account_scope::require_user_session; +use super::user_pages::user_breadcrumb; use super::{CsrfOnly, identity_label, op_error_message, require_csrf}; -use crate::management::CallerIdentity; - /// POST /console/accounts/{id}/users/{name}/access-keys/new pub async fn create_access_key( State(state): State>, @@ -25,22 +24,11 @@ pub async fn create_access_key( Path((account_id, user_name)): Path<(String, String)>, Form(form): Form, ) -> Response { - let session = match require_account_session(&headers, &state, &account_id).await { + // Self-service (the session's own user) or admin. + let session = match require_user_session(&headers, &state, &account_id, &user_name).await { Ok(s) => s, Err(r) => return r, }; - - // Allow self-service (same IAM user) or admin. - let allowed = match &session.identity { - CallerIdentity::Admin(_) => true, - CallerIdentity::IamUser { - account_id: a, - user_name: u, - } => a == &account_id && u == &user_name, - }; - if !allowed { - return Redirect::to("/console").into_response(); - } if let Err(r) = require_csrf(&form.csrf, &session) { return r; } @@ -49,16 +37,12 @@ pub async fn create_access_key( let eid = html::escape(&account_id); let eun = html::escape(&user_name); - let crumbs = html::breadcrumb(&[ - ("Dashboard", Some("/console")), - ("Accounts", Some("/console/accounts")), - (&account_id, Some(&format!("/console/accounts/{eid}"))), - ( - &user_name, - Some(&format!("/console/accounts/{eid}/users/{eun}")), - ), - ("New Access Key", None), - ]); + let crumbs = user_breadcrumb( + &session.identity, + &account_id, + &user_name, + Some("New Access Key"), + ); match state .catalog_store @@ -120,22 +104,11 @@ pub async fn delete_access_key( Path((account_id, user_name, key_id)): Path<(String, String, String)>, Form(form): Form, ) -> Response { - let session = match require_account_session(&headers, &state, &account_id).await { + // Self-service (the session's own user) or admin. + let session = match require_user_session(&headers, &state, &account_id, &user_name).await { Ok(s) => s, Err(r) => return r, }; - - // Allow self-service (same IAM user) or admin. - let allowed = match &session.identity { - CallerIdentity::Admin(_) => true, - CallerIdentity::IamUser { - account_id: a, - user_name: u, - } => a == &account_id && u == &user_name, - }; - if !allowed { - return Redirect::to("/console").into_response(); - } if let Err(r) = require_csrf(&form.csrf, &session) { return r; } diff --git a/crates/server/src/console/pages/account_pages.rs b/crates/server/src/console/pages/account_pages.rs index 48cd0d9e0..a1a70ca7a 100755 --- a/crates/server/src/console/pages/account_pages.rs +++ b/crates/server/src/console/pages/account_pages.rs @@ -48,7 +48,7 @@ pub async fn dashboard(State(state): State>, headers: HeaderMa /// The dashboard page for `session`. An admin session sees `counts`, the /// deployment's `(account_count, admin_count)`. An IAM-user session sees a -/// link to its own account and no count, whatever `counts` holds. +/// link to its own user page and no count, whatever `counts` holds. fn render_dashboard( session: &SessionData, counts: Option<(i64, i64)>, @@ -71,13 +71,17 @@ fn render_dashboard( "# ) } - CallerIdentity::IamUser { account_id, .. } => { + CallerIdentity::IamUser { + account_id, + user_name, + } => { let eid = html::escape(account_id); + let eun = html::escape(user_name); format!( r#"
-

{eid}

-

Your Account

-View → +

{eun}

+

Your user in account {eid}

+Access keys →
"# ) } @@ -106,22 +110,20 @@ pub async fn list_accounts(State(state): State>, headers: Head Ok(s) => s, Err(r) => return r, }; + // The management API lists accounts to admins only, and every account + // page redirects an IAM-user session, so the list would link it nowhere. + if !is_admin(&session.identity) { + return Redirect::to("/console").into_response(); + } let nav = html::nav_bar(&identity_label(&session.identity)); let crumbs = html::breadcrumb(&[("Dashboard", Some("/console")), ("Accounts", None)]); - let rows: Vec<(String, String)> = match &session.identity { - crate::management::CallerIdentity::Admin(_) => state - .catalog_store - .list_all_accounts() - .await - .unwrap_or_default(), - crate::management::CallerIdentity::IamUser { account_id, .. } => state - .catalog_store - .list_accounts_for(account_id) - .await - .unwrap_or_default(), - }; + let rows: Vec<(String, String)> = state + .catalog_store + .list_all_accounts() + .await + .unwrap_or_default(); let mut table = String::from( r"", @@ -162,7 +164,7 @@ pub async fn new_account_form( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to("/console/accounts").into_response(); + return Redirect::to("/console").into_response(); } let nav = html::nav_bar(&identity_label(&session.identity)); @@ -217,7 +219,7 @@ pub async fn create_account( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to("/console/accounts").into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { @@ -292,6 +294,11 @@ pub async fn account_detail( Ok(s) => s, Err(r) => return r, }; + // The page lists every user, group and role of the account, which the + // management API gives admins only. + if !is_admin(&session.identity) { + return Redirect::to("/console").into_response(); + } let nav = html::nav_bar(&identity_label(&session.identity)); let eid = html::escape(&account_id); @@ -412,7 +419,7 @@ pub async fn delete_account( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to("/console/accounts").into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; diff --git a/crates/server/src/console/pages/account_pages_tests.rs b/crates/server/src/console/pages/account_pages_tests.rs index d2429093f..36d8c50ab 100644 --- a/crates/server/src/console/pages/account_pages_tests.rs +++ b/crates/server/src/console/pages/account_pages_tests.rs @@ -1,8 +1,9 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 -//! Tests for the console dashboard: the deployment-wide counts are shown to -//! admin sessions only. +//! Tests for the console dashboard: the deployment-wide counts and the +//! Accounts link are shown to admin sessions only, and an IAM-user session +//! gets a link to its own user page. use axum::http::StatusCode; use axum::response::Response; @@ -54,10 +55,25 @@ async fn iam_user_dashboard_has_no_deployment_count() { assert!(!body.contains(&ADMIN_COUNT.to_string()), "{body}"); assert!(!body.contains("Admin Users"), "{body}"); assert!(!body.contains("

Accounts

"), "{body}"); +} + +#[tokio::test] +async fn iam_user_dashboard_links_to_its_own_user_page_and_no_account_page() { + let response = render_dashboard(&session(iam_user()), None, "test-version"); + + let body = body_text(response).await; assert!( - body.contains(r#""#), + body.contains(r#""#), "{body}" ); + // The account list and the account page redirect an IAM-user session, so + // neither the card nor the navigation bar links to them. + assert!(!body.contains(r#"href="/console/accounts""#), "{body}"); + assert!( + !body.contains(r#"href="/console/accounts/444455556666""#), + "{body}" + ); + assert!(!body.contains("Accounts"), "{body}"); } #[tokio::test] @@ -82,4 +98,8 @@ async fn admin_dashboard_shows_the_deployment_counts() { )), "{body}" ); + assert!( + body.contains("Dashboard\nAccounts\n"), + "{body}" + ); } diff --git a/crates/server/src/console/pages/account_scope.rs b/crates/server/src/console/pages/account_scope.rs index 5c9aa6475..56d2e1d29 100644 --- a/crates/server/src/console/pages/account_scope.rs +++ b/crates/server/src/console/pages/account_scope.rs @@ -8,6 +8,14 @@ //! may use the pages of its own account only. Every handler under that path //! gets its session from [`require_account_session`], which applies the //! check before the handler reads anything from the catalog. +//! +//! Inside its own account an IAM-user session may use the pages of its own +//! user only: the user page and the access key forms, which get their session +//! from [`require_user_session`]. Every other page and form under the path is +//! for admins, and its handler redirects an IAM-user session to the console +//! home page before it reads anything from the catalog. This matches the +//! management API, which gives an IAM user its own access keys and password +//! and refuses it every other read and write. use std::sync::Arc; @@ -35,6 +43,28 @@ pub(super) async fn require_account_session( Ok(session) } +/// [`require_account_session`] for a page of the user `user_name` in the +/// account `account_id`: the user page and the forms that create and delete +/// the user's access keys. +/// +/// An admin session may use the pages of every user. An IAM-user session may +/// use the pages of its own user only, by the rule the management API applies +/// to an IAM user's access keys: the account ID and the user name in the path +/// both equal the session's own. Any other user's page redirects it to the +/// console home page. The check reads nothing from the catalog, so another +/// user's name and a name that no user has get the same answer. +#[allow(clippy::result_large_err)] +pub(super) async fn require_user_session( + headers: &HeaderMap, + state: &Arc, + account_id: &str, + user_name: &str, +) -> Result { + let session = require_account_session(headers, state, account_id).await?; + user_scope(&session.identity, account_id, user_name)?; + Ok(session) +} + /// `Ok` when `identity` may use the pages of the account `account_id`, or a /// redirect to the console home page when it may not. #[allow(clippy::result_large_err)] @@ -48,13 +78,32 @@ fn account_scope(identity: &CallerIdentity, account_id: &str) -> Result<(), Resp } } +/// `Ok` when `identity` may use the pages of the user `user_name` in the +/// account `account_id`, or a redirect to the console home page when it may +/// not. +#[allow(clippy::result_large_err)] +fn user_scope( + identity: &CallerIdentity, + account_id: &str, + user_name: &str, +) -> Result<(), Response> { + match identity { + CallerIdentity::Admin(_) => Ok(()), + CallerIdentity::IamUser { + account_id: own_account, + user_name: own_user, + } if own_account == account_id && own_user == user_name => Ok(()), + CallerIdentity::IamUser { .. } => Err(Redirect::to("/console").into_response()), + } +} + #[cfg(test)] mod tests { use axum::http::StatusCode; use axum::http::header::LOCATION; use axum::response::Response; - use super::account_scope; + use super::{account_scope, user_scope}; use crate::management::CallerIdentity; const OWN_ACCOUNT: &str = "444455556666"; @@ -137,4 +186,67 @@ mod tests { assert!(account_scope(&admin(), id).is_ok(), "admin refused {id}"); } } + + /// The name of the user `iam_user` logs in as. + const OWN_USER: &str = "bob"; + + #[test] + fn iam_user_may_use_its_own_user_pages() { + assert!(user_scope(&iam_user(), OWN_ACCOUNT, OWN_USER).is_ok()); + } + + #[tokio::test] + async fn iam_user_is_redirected_to_the_console_from_another_users_pages() { + for (account, user) in [ + (OWN_ACCOUNT, "alice"), + (OTHER_ACCOUNT, OWN_USER), + (OTHER_ACCOUNT, "alice"), + ] { + let refused = + user_scope(&iam_user(), account, user).expect_err("the check let the session in"); + let (status, location, body) = answer(refused).await; + + assert_eq!(status, StatusCode::SEE_OTHER, "{account}/{user}"); + assert_eq!(location.as_deref(), Some("/console"), "{account}/{user}"); + assert!(body.is_empty(), "{}", String::from_utf8_lossy(&body)); + } + } + + #[tokio::test] + async fn another_user_and_a_missing_user_get_the_same_answer() { + // The check reads no catalog, so it cannot tell a user that exists + // from a name that no user has. + let other = user_scope(&iam_user(), OWN_ACCOUNT, "alice").expect_err("let in"); + let missing = + user_scope(&iam_user(), OWN_ACCOUNT, "nobody-has-this-name").expect_err("let in"); + + assert_eq!(answer(other).await, answer(missing).await); + } + + #[test] + fn iam_user_is_refused_a_name_that_only_resembles_its_own() { + for name in [ + "Bob", "BOB", "bo", "bobb", " bob", "bob ", "bob/", "", "bob\0", + ] { + assert!( + user_scope(&iam_user(), OWN_ACCOUNT, name).is_err(), + "{OWN_ACCOUNT}/{OWN_USER} was let into the pages of {name:?}" + ); + } + } + + #[test] + fn admin_may_use_every_users_pages() { + for (account, user) in [ + (OWN_ACCOUNT, OWN_USER), + (OWN_ACCOUNT, "alice"), + (OTHER_ACCOUNT, "alice"), + (MISSING_ACCOUNT, "nobody-has-this-name"), + ] { + assert!( + user_scope(&admin(), account, user).is_ok(), + "admin refused {account}/{user}" + ); + } + } } diff --git a/crates/server/src/console/pages/group_pages.rs b/crates/server/src/console/pages/group_pages.rs index a251541b2..6e4647414 100755 --- a/crates/server/src/console/pages/group_pages.rs +++ b/crates/server/src/console/pages/group_pages.rs @@ -29,7 +29,7 @@ pub async fn new_group_form( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } let nav = html::nav_bar(&identity_label(&session.identity)); @@ -83,7 +83,7 @@ pub async fn create_group( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; @@ -144,6 +144,11 @@ pub async fn group_detail( Ok(s) => s, Err(r) => return r, }; + // The page lists the group's members and policies, which the management + // API gives admins only. + if !is_admin(&session.identity) { + return Redirect::to("/console").into_response(); + } let nav = html::nav_bar(&identity_label(&session.identity)); let eid = html::escape(&account_id); @@ -268,7 +273,7 @@ pub async fn delete_group( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; @@ -341,10 +346,7 @@ pub async fn add_group_member( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!( - "/console/accounts/{account_id}/groups/{group_name}" - )) - .into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; @@ -396,10 +398,7 @@ pub async fn remove_group_member( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!( - "/console/accounts/{account_id}/groups/{group_name}" - )) - .into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; diff --git a/crates/server/src/console/pages/mod.rs b/crates/server/src/console/pages/mod.rs index 2573b6a9f..ce439ae0d 100755 --- a/crates/server/src/console/pages/mod.rs +++ b/crates/server/src/console/pages/mod.rs @@ -46,6 +46,7 @@ use axum::http::HeaderMap; use axum::response::{IntoResponse, Redirect, Response}; use crate::console::ConsoleState; +use crate::console::html; use crate::management::CallerIdentity; /// Session data returned by `require_session`: identity + CSRF token. @@ -120,9 +121,10 @@ fn extract_session_token(headers: &HeaderMap) -> Option { None } -/// Format a `CallerIdentity` for display in the nav bar. -fn identity_label(identity: &CallerIdentity) -> String { - match identity { +/// Describe a `CallerIdentity` for the nav bar: its display label, and +/// whether it is an admin, which decides the admin-only links. +fn identity_label(identity: &CallerIdentity) -> html::NavIdentity { + let label = match identity { CallerIdentity::Admin(name) => format!("Admin: {name}"), CallerIdentity::IamUser { account_id, @@ -130,6 +132,10 @@ fn identity_label(identity: &CallerIdentity) -> String { } => { format!("{account_id}/{user_name}") } + }; + html::NavIdentity { + label, + is_admin: is_admin(identity), } } diff --git a/crates/server/src/console/pages/role_pages.rs b/crates/server/src/console/pages/role_pages.rs index c97c9bbaa..b188c3615 100755 --- a/crates/server/src/console/pages/role_pages.rs +++ b/crates/server/src/console/pages/role_pages.rs @@ -29,7 +29,7 @@ pub async fn new_role_form( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } let nav = html::nav_bar(&identity_label(&session.identity)); @@ -93,7 +93,7 @@ pub async fn create_role( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; @@ -193,6 +193,11 @@ pub async fn role_detail( Ok(s) => s, Err(r) => return r, }; + // The page shows the role's trust policy, policies and tags, which the + // management API gives admins only. + if !is_admin(&session.identity) { + return Redirect::to("/console").into_response(); + } let nav = html::nav_bar(&identity_label(&session.identity)); let eid = html::escape(&account_id); @@ -302,7 +307,7 @@ pub async fn delete_role( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; diff --git a/crates/server/src/console/pages/user_pages.rs b/crates/server/src/console/pages/user_pages.rs index f515c8943..afec88096 100755 --- a/crates/server/src/console/pages/user_pages.rs +++ b/crates/server/src/console/pages/user_pages.rs @@ -12,11 +12,14 @@ use axum::http::HeaderMap; use axum::response::{Html, IntoResponse, Redirect, Response}; use serde::Deserialize; +use extenddb_storage::management_store::UserDetail; + use crate::console::ConsoleState; use crate::console::html; +use crate::management::CallerIdentity; -use super::account_scope::require_account_session; -use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf}; +use super::account_scope::{require_account_session, require_user_session}; +use super::{CsrfOnly, SessionData, identity_label, is_admin, op_error_message, require_csrf}; /// GET /console/accounts/{id}/users/new pub async fn new_user_form( @@ -29,7 +32,7 @@ pub async fn new_user_form( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } let nav = html::nav_bar(&identity_label(&session.identity)); @@ -86,7 +89,7 @@ pub async fn create_user( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; @@ -184,21 +187,11 @@ pub async fn user_detail( headers: HeaderMap, Path((account_id, user_name)): Path<(String, String)>, ) -> Response { - let session = match require_account_session(&headers, &state, &account_id).await { + let session = match require_user_session(&headers, &state, &account_id, &user_name).await { Ok(s) => s, Err(r) => return r, }; - let nav = html::nav_bar(&identity_label(&session.identity)); - let eid = html::escape(&account_id); - let eun = html::escape(&user_name); - let crumbs = html::breadcrumb(&[ - ("Dashboard", Some("/console")), - ("Accounts", Some("/console/accounts")), - (&account_id, Some(&format!("/console/accounts/{eid}"))), - (&user_name, None), - ]); - // Fetch user detail via catalog store trait. let detail = state .catalog_store @@ -206,6 +199,61 @@ pub async fn user_detail( .await .unwrap_or(None); + render_user_page(&session, &account_id, &user_name, detail.as_ref()) +} + +/// The breadcrumb trail of a page of the user `user_name`, ending in the +/// user's name, or in `tail` after a link to the user page when `tail` is +/// set. +/// +/// An admin session gets the trail through the account list and the account +/// page. An IAM-user session may open neither, so its trail holds the +/// dashboard and its own user page only. +pub(super) fn user_breadcrumb( + identity: &CallerIdentity, + account_id: &str, + user_name: &str, + tail: Option<&str>, +) -> String { + let eid = html::escape(account_id); + let eun = html::escape(user_name); + let account_url = format!("/console/accounts/{eid}"); + let user_url = format!("/console/accounts/{eid}/users/{eun}"); + let mut items = vec![("Dashboard", Some("/console"))]; + if is_admin(identity) { + items.push(("Accounts", Some("/console/accounts"))); + items.push((account_id, Some(account_url.as_str()))); + } + match tail { + Some(tail) => { + items.push((user_name, Some(user_url.as_str()))); + items.push((tail, None)); + } + None => items.push((user_name, None)), + } + html::breadcrumb(&items) +} + +/// The page of the user `user_name`, or a "User not found" page when +/// `detail` is `None`. +/// +/// An admin session sees the user's access keys, inline policies, groups and +/// tags, with the forms that change them. An IAM-user session, which gets +/// here for its own user only, sees its access keys and the forms that create +/// and delete them. The management API refuses an IAM user the list of its +/// own policies and tags and has no list of its groups, so the page leaves +/// them out. +fn render_user_page( + session: &SessionData, + account_id: &str, + user_name: &str, + detail: Option<&UserDetail>, +) -> Response { + let nav = html::nav_bar(&identity_label(&session.identity)); + let eid = html::escape(account_id); + let eun = html::escape(user_name); + let crumbs = user_breadcrumb(&session.identity, account_id, user_name, None); + let Some(detail) = detail else { let content = format!("{crumbs}{}", html::alert_error("User not found")); return Html(html::layout_csrf( @@ -255,28 +303,33 @@ pub async fn user_detail( } content.push_str("
Account IDName
"); + // The policies, groups and tags sections are for admins. + if !is_admin(&session.identity) { + return Html(html::layout_csrf( + &format!("User {user_name}"), + &nav, + &content, + &session.csrf_token, + )) + .into_response(); + } + // Policies section. let _ = write!(content, "

Policies ({})

", detail.policies.len()); - if is_admin(&session.identity) { - let _ = write!( - content, - r#"
Add Policy"# - ); - } + let _ = write!( + content, + r#"Add Policy"# + ); content.push_str(r""); for pname in &detail.policies { let ep = html::escape(pname); - let delete_btn = if is_admin(&session.identity) { - format!( - r#""); +"# + ); } content.push_str("
Policy Name
{ep} -"# - ) - } else { - String::new() - }; - let _ = write!(content, "
{ep}{delete_btn}
"); @@ -326,7 +379,7 @@ pub async fn delete_user( Err(r) => return r, }; if !is_admin(&session.identity) { - return Redirect::to(&format!("/console/accounts/{account_id}")).into_response(); + return Redirect::to("/console").into_response(); } if let Err(r) = require_csrf(&form.csrf, &session) { return r; @@ -397,3 +450,7 @@ pub async fn delete_user( } } } + +#[cfg(test)] +#[path = "user_pages_tests.rs"] +mod tests; diff --git a/crates/server/src/console/pages/user_pages_tests.rs b/crates/server/src/console/pages/user_pages_tests.rs new file mode 100644 index 000000000..0d21e2603 --- /dev/null +++ b/crates/server/src/console/pages/user_pages_tests.rs @@ -0,0 +1,184 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for the console user page: an IAM-user session sees its own access +//! keys and no link to a page it may not open; an admin session sees the +//! whole page. + +use axum::http::StatusCode; +use axum::response::Response; +use extenddb_storage::management_store::UserDetail; + +use super::{render_user_page, user_breadcrumb}; +use crate::console::pages::SessionData; +use crate::management::CallerIdentity; + +const ACCOUNT: &str = "444455556666"; +const USER: &str = "bob"; +/// Names no page would show by chance, so a test can look for them. +const KEY_ID: &str = "AKIAEXAMPLE000000731"; +const POLICY: &str = "policy-7f3e1c"; +const GROUP: &str = "group-9a42d0"; +const TAG_KEY: &str = "tag-key-51b8"; +const TAG_VALUE: &str = "tag-value-c06e"; + +fn session(identity: CallerIdentity) -> SessionData { + SessionData { + identity, + csrf_token: "csrf-token".to_owned(), + } +} + +fn admin() -> CallerIdentity { + CallerIdentity::Admin("admin".to_owned()) +} + +fn iam_user() -> CallerIdentity { + CallerIdentity::IamUser { + account_id: ACCOUNT.to_owned(), + user_name: USER.to_owned(), + } +} + +fn detail() -> UserDetail { + UserDetail { + keys: vec![(KEY_ID.to_owned(), true)], + policies: vec![POLICY.to_owned()], + tags: vec![(TAG_KEY.to_owned(), TAG_VALUE.to_owned())], + groups: vec![GROUP.to_owned()], + } +} + +async fn body_text(response: Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read response body"); + String::from_utf8(bytes.to_vec()).expect("UTF-8 response body") +} + +/// Every link target (`href="..."`) in `page`. +fn links(page: &str) -> Vec<&str> { + page.split("href=\"") + .skip(1) + .filter_map(|rest| rest.split('"').next()) + .collect() +} + +#[tokio::test] +async fn iam_user_page_shows_its_access_keys_and_the_forms_that_change_them() { + let response = render_user_page(&session(iam_user()), ACCOUNT, USER, Some(&detail())); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_text(response).await; + assert!(body.contains("

User: bob

"), "{body}"); + assert!(body.contains("

Access Keys (1)

"), "{body}"); + assert!(body.contains(&format!("{KEY_ID}")), "{body}"); + assert!( + body.contains(r#"action="/console/accounts/444455556666/users/bob/access-keys/new""#), + "{body}" + ); + assert!( + body.contains(&format!( + r#"action="/console/accounts/444455556666/users/bob/access-keys/{KEY_ID}/delete""# + )), + "{body}" + ); +} + +#[tokio::test] +async fn iam_user_page_leaves_out_policies_groups_and_tags() { + let response = render_user_page(&session(iam_user()), ACCOUNT, USER, Some(&detail())); + + let body = body_text(response).await; + for absent in [ + POLICY, + GROUP, + TAG_KEY, + TAG_VALUE, + "

Policies", + "

Groups", + "

Tags", + "Delete User", + "/policies/", + ] { + assert!(!body.contains(absent), "the page shows {absent:?}: {body}"); + } +} + +#[tokio::test] +async fn iam_user_page_links_to_no_account_page() { + // Every page under /console/accounts other than its own user page + // redirects an IAM-user session, and the page is not linked to itself. + let response = render_user_page(&session(iam_user()), ACCOUNT, USER, Some(&detail())); + + let body = body_text(response).await; + let to_accounts: Vec<&str> = links(&body) + .into_iter() + .filter(|link| link.starts_with("/console/accounts")) + .collect(); + assert!(to_accounts.is_empty(), "{to_accounts:?}: {body}"); + assert!( + body.contains(r#"Dashboard"#), + "{body}" + ); +} + +#[tokio::test] +async fn admin_page_shows_the_whole_user() { + let response = render_user_page(&session(admin()), ACCOUNT, USER, Some(&detail())); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_text(response).await; + for shown in [ + KEY_ID, + POLICY, + TAG_KEY, + TAG_VALUE, + "

Policies (1)

", + "

Groups (1)

", + "

Tags (1)

", + "Delete User", + r#"href="/console/accounts/444455556666/users/bob/policies/new""#, + r#"href="/console/accounts/444455556666/groups/group-9a42d0""#, + r#"action="/console/accounts/444455556666/users/bob/policies/policy-7f3e1c/delete""#, + r#"Accounts › 444455556666 › bob"#, + ] { + assert!(body.contains(shown), "the page lacks {shown:?}: {body}"); + } +} + +#[tokio::test] +async fn a_missing_user_gets_the_not_found_page() { + let response = render_user_page(&session(admin()), ACCOUNT, "nobody", None); + + assert_eq!(response.status(), StatusCode::OK); + assert!(body_text(response).await.contains("User not found")); +} + +#[test] +fn iam_user_breadcrumb_holds_the_dashboard_and_its_own_user_page() { + assert_eq!( + user_breadcrumb(&iam_user(), ACCOUNT, USER, None), + r#""# + ); + assert_eq!( + user_breadcrumb(&iam_user(), ACCOUNT, USER, Some("New Access Key")), + concat!( + r#""#, + ) + ); +} + +#[test] +fn admin_breadcrumb_runs_through_the_account_list_and_the_account() { + assert_eq!( + user_breadcrumb(&admin(), ACCOUNT, USER, Some("New Access Key")), + concat!( + r#""#, + ) + ); +} diff --git a/tests/test_console_account_scope.py b/tests/test_console_account_scope.py index 9a4a50343..b3adb549c 100644 --- a/tests/test_console_account_scope.py +++ b/tests/test_console_account_scope.py @@ -403,15 +403,14 @@ def test_iam_user_manages_its_own_access_keys(world: World) -> None: assert key_id not in world.mgmt.list_access_keys(b.id, user).text -def test_iam_user_reads_its_own_account_page_and_user_page(world: World) -> None: - # #731 tracks which pages of its own account an IAM-user session should - # read, so the pages of the account's other users, groups and roles are - # not checked here either way. +def test_iam_user_reads_its_own_user_page_and_not_its_account_page(world: World) -> None: + # tests/test_console_iam_user_pages.py checks every page of the user's + # own account; here the account page stands for them. b, user = world.b, world.viewer_name resp = world.viewer.get(PAGES["account"](b)) - assert resp.status_code == 200, resp.status_code - assert "location" not in resp.headers - assert b.name in resp.text + assert resp.status_code == 303, resp.status_code + assert resp.headers.get("location") == "/console" + assert b.name not in resp.text resp = world.viewer.get(f"/accounts/{b.id}/users/{user}") assert resp.status_code == 200, resp.status_code @@ -443,7 +442,7 @@ def test_home_page_shows_deployment_counts_to_admins_only(world: World) -> None: assert page.status_code == 200, page.status_code assert "Admin Users" not in page.text assert "

Accounts

" not in page.text - assert f'' in page.text + assert f'' in page.text for name in world.a.names() + [world.a.id]: assert name not in page.text, name diff --git a/tests/test_console_integration.py b/tests/test_console_integration.py index 994a9f6f0..d1dd21930 100755 --- a/tests/test_console_integration.py +++ b/tests/test_console_integration.py @@ -781,15 +781,17 @@ def test_iam_user_sees_own_account_only(self): resp = iam_console.login(f"{self.account_id}/{user}", password) assert resp.status_code == 303 - # Dashboard should be accessible. + # Dashboard should be accessible, with a link to the user's own + # page. resp = iam_console.get("") assert resp.status_code == 200 + assert f"/console/accounts/{self.account_id}/users/{user}" in resp.text - # Account listing: IAM user should see limited view. + # The account list is for admins: an IAM user is redirected to + # the dashboard. resp = iam_console.get("/accounts") - assert resp.status_code == 200 - # The IAM user's own account should be visible. - assert self.account_id in resp.text + assert resp.status_code == 303 + assert resp.headers.get("location") == "/console" finally: self.admin_console.post( f"/accounts/{self.account_id}/users/{user}/delete" diff --git a/tests/test_cross_account_isolation.py b/tests/test_cross_account_isolation.py index cb88bc3d6..06ff1839b 100755 --- a/tests/test_cross_account_isolation.py +++ b/tests/test_cross_account_isolation.py @@ -356,27 +356,29 @@ def _console_login(self, account_id: str, user_name: str, return session def test_alice_console_does_not_show_bob_account(self): - """Alice's console session does not list bob's account.""" + """Alice's console session does not list bob's account: the account + list is for admins and redirects her to the dashboard.""" session = self._console_login(self.alice_acct, "alice", "AlicePass123!") resp = session.get( f"{self.endpoint}/console/accounts", allow_redirects=False, timeout=30, ) - assert resp.status_code == 200 - assert self.alice_acct in resp.text + assert resp.status_code == 303 + assert resp.headers.get("location") == "/console" assert self.bob_acct not in resp.text def test_bob_console_does_not_show_alice_account(self): - """Bob's console session does not list alice's account.""" + """Bob's console session does not list alice's account: the account + list is for admins and redirects him to the dashboard.""" session = self._console_login(self.bob_acct, "bob", "BobPass456!") resp = session.get( f"{self.endpoint}/console/accounts", allow_redirects=False, timeout=30, ) - assert resp.status_code == 200 - assert self.bob_acct in resp.text + assert resp.status_code == 303 + assert resp.headers.get("location") == "/console" assert self.alice_acct not in resp.text From 1e22ad49c920391acab2a331eeb374592ea6e694 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:54:37 +0000 Subject: [PATCH 2/4] test(console): check that an IAM user reads only its own user page and keys tests/test_console_iam_user_pages.py logs in as an IAM user that holds only the self-service policy every user created with a console password gets. The account also holds two other users, a group and a role, each with an inline policy, and one user with an access key and a tag. The tests check that the account list, the account forms and every page and form under the user's account other than its own user page and access key forms redirect it to /console, and that no refused form changes the catalog. They check that no page it opens names another user, group or role of the account or links to a page that redirects it, and that the management API refuses the user the same reads. The user still reads, creates and deletes its own access keys and logs out, and an admin session still reads every page. --- tests/test_console_iam_user_pages.py | 537 +++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 tests/test_console_iam_user_pages.py diff --git a/tests/test_console_iam_user_pages.py b/tests/test_console_iam_user_pages.py new file mode 100644 index 000000000..23ad483ea --- /dev/null +++ b/tests/test_console_iam_user_pages.py @@ -0,0 +1,537 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Console pages of an IAM user's own account serve the user its own user +page and its own access keys only. + +The management API gives an IAM user its own access keys (list, create and +delete) and its own password, and answers every other read and write with +HTTP 403. The console gives an IAM-user session the same: its own user page, +which lists its access keys, and the forms that create and delete them. +Every other page and form under its account redirects it to /console, and no +page it can open names another user, group or role, or links to a page that +redirects it. + +The account holds a user `owner` with an inline policy, an access key and a +tag; a second user `peer`; a group with `owner` as its member and an inline +policy; and a role with an inline policy. The IAM user `viewer` is created +with a console password, so it holds only the self-service policy that every +such user gets. Each name carries a random suffix, so that no page shows it +by chance. + +Prerequisites match tests/test_console_account_scope.py: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD +""" + +from __future__ import annotations + +import json +import os +import re +import uuid +from dataclasses import dataclass +from typing import Callable + +import pytest +import requests + +from management_helpers import ManagementClient + +_CSRF_RE = re.compile(r'(AKIA[^<]+)') + +# The inline policy every user created with a console password holds. +_SELF_SERVICE_POLICY = "SelfServicePolicy" + +_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*"}], +} + +_FULL_ACCESS = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} + + +def _trust_policy(account_id: str) -> dict: + return { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"}, + "Action": "sts:AssumeRole", + }], + } + + +def _require_auth_env() -> tuple[str, str, str]: + endpoint = os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip() + admin_user = os.environ.get("EXTENDDB_ADMIN_USER", "").strip() + admin_pass = os.environ.get("EXTENDDB_ADMIN_PASSWORD", "").strip() + if not endpoint or not admin_user or not admin_pass: + pytest.fail( + "MISCONFIGURED: console IAM user page tests require " + "EXTENDDB_TEST_ENDPOINT, EXTENDDB_ADMIN_USER, and " + "EXTENDDB_ADMIN_PASSWORD. These must be set by devtools/run-tests " + "before test execution." + ) + return endpoint, admin_user, admin_pass + + +class Console: + """One browser: a cookie jar talking to the server's console.""" + + def __init__(self, base_url: str) -> None: + self.base_url = base_url.rstrip("/") + self.http = requests.Session() + # Self-signed certs from ``extenddb init``. The harness sets + # REQUESTS_CA_BUNDLE, which overrides a session-level ``verify``, so + # the environment is ignored and ``verify`` is passed per request. + self.http.trust_env = False + self.verify = not self.base_url.startswith("https://") + self.csrf = "" + + def login(self, username: str, password: str) -> requests.Response: + """Log in and return the home page the login redirects to.""" + resp = self.http.post( + f"{self.base_url}/console/login", + data={"username": username, "password": password}, + allow_redirects=False, + timeout=30, + verify=self.verify, + ) + assert resp.status_code == 303, ( + f"login as {username}: {resp.status_code} {resp.text[:200]}" + ) + assert resp.headers.get("location") == "/console" + home = self.get("") + assert home.status_code == 200, home.status_code + match = _CSRF_RE.search(home.text) + assert match, "the console home page carries no CSRF token" + self.csrf = match.group(1) + return home + + def get(self, path: str) -> requests.Response: + return self.http.get( + f"{self.base_url}/console{path}", + allow_redirects=False, + timeout=30, + verify=self.verify, + ) + + def open(self, url: str) -> requests.Response: + """GET a link target as it appears in a page (`/console/...`).""" + assert url.startswith("/console"), url + return self.get(url[len("/console"):]) + + def post(self, path: str, form: dict[str, str]) -> requests.Response: + return self.http.post( + f"{self.base_url}/console{path}", + data={"_csrf": self.csrf, **form}, + allow_redirects=False, + timeout=30, + verify=self.verify, + ) + + +@dataclass +class World: + """The account, the names in it, and the sessions that read it.""" + + endpoint: str + mgmt: ManagementClient + admin: Console + viewer: Console + account_id: str + account_name: str + owner: str + peer: str + group: str + role: str + user_policy: str + group_policy: str + role_policy: str + owner_key: str + tag_key: str + tag_value: str + viewer_name: str + viewer_password: str + viewer_key: str + missing_user: str # a name that no user has + + def others(self) -> list[str]: + """Every name of the account, its other users, its group and its + role, which no page the viewer opens may show.""" + return [ + self.account_name, self.owner, self.peer, self.group, self.role, + self.user_policy, self.group_policy, self.role_policy, + self.owner_key, self.tag_key, self.tag_value, + ] + + def viewer_auth(self) -> tuple[str, str]: + return (f"{self.account_id}/{self.viewer_name}", self.viewer_password) + + +@pytest.fixture(scope="module") +def world(): + endpoint, admin_user, admin_pass = _require_auth_env() + mgmt = ManagementClient(endpoint, admin_user, admin_pass) + suffix = uuid.uuid4().hex[:10] + account_id = f"{uuid.uuid4().int % 10**12:012d}" + w = World( + endpoint=endpoint, + mgmt=mgmt, + admin=Console(endpoint), + viewer=Console(endpoint), + account_id=account_id, + account_name=f"own-pages-{suffix}", + owner=f"owner-{suffix}", + peer=f"peer-{suffix}", + group=f"group-{suffix}", + role=f"role-{suffix}", + user_policy=f"user-policy-{suffix}", + group_policy=f"group-policy-{suffix}", + role_policy=f"role-policy-{suffix}", + owner_key="", + tag_key=f"tag-key-{suffix}", + tag_value=f"tag-value-{suffix}", + viewer_name=f"viewer-{suffix}", + viewer_password=uuid.uuid4().hex, + viewer_key="", + missing_user=f"nobody-{suffix}", + ) + a = w.account_id + try: + steps: list[Callable[[], requests.Response]] = [ + lambda: mgmt.create_account(a, w.account_name), + lambda: mgmt.create_user(a, w.owner), + lambda: mgmt.create_user(a, w.peer), + lambda: mgmt.create_user(a, w.viewer_name, w.viewer_password), + lambda: mgmt.create_group(a, w.group), + lambda: mgmt.add_group_member(a, w.group, w.owner), + lambda: mgmt.create_role(a, w.role, _trust_policy(a)), + lambda: mgmt.put_user_policy(a, w.owner, w.user_policy, _POLICY), + lambda: mgmt.put_group_policy(a, w.group, w.group_policy, _POLICY), + lambda: mgmt.put_role_policy(a, w.role, w.role_policy, _POLICY), + lambda: mgmt.tag_user(a, w.owner, {w.tag_key: w.tag_value}), + ] + for step in steps: + resp = step() + assert resp.status_code in (200, 201, 204), f"{resp.url}: {resp.text}" + resp = mgmt.create_access_key(a, w.owner) + assert resp.status_code == 201, resp.text + w.owner_key = resp.json()["access_key_id"] + resp = mgmt.create_access_key(a, w.viewer_name) + assert resp.status_code == 201, resp.text + w.viewer_key = resp.json()["access_key_id"] + + w.admin.login(admin_user, admin_pass) + w.viewer.login(*w.viewer_auth()) + yield w + finally: + mgmt.delete_user_policy(a, w.owner, w.user_policy) + mgmt.delete_group_policy(a, w.group, w.group_policy) + mgmt.delete_role_policy(a, w.role, w.role_policy) + mgmt.remove_group_member(a, w.group, w.owner) + mgmt.delete_group(a, w.group) + mgmt.delete_role(a, w.role) + for user in (w.owner, w.peer, w.viewer_name): + keys = mgmt.list_access_keys(a, user) + for key in keys.json() if keys.status_code == 200 else []: + mgmt.delete_access_key(a, user, key["access_key_id"]) + mgmt.delete_user(a, user) + mgmt.delete_account(a) + + +# Console paths that answer GET and redirect an IAM-user session, each named +# by what it shows an admin. +PAGES: dict[str, Callable[[World], str]] = { + "accounts": lambda w: "/accounts", + "new-account-form": lambda w: "/accounts/new", + "account": lambda w: f"/accounts/{w.account_id}", + "owner": lambda w: f"/accounts/{w.account_id}/users/{w.owner}", + "peer": lambda w: f"/accounts/{w.account_id}/users/{w.peer}", + "missing-user": lambda w: f"/accounts/{w.account_id}/users/{w.missing_user}", + "group": lambda w: f"/accounts/{w.account_id}/groups/{w.group}", + "role": lambda w: f"/accounts/{w.account_id}/roles/{w.role}", + "new-user-form": lambda w: f"/accounts/{w.account_id}/users/new", + "new-group-form": lambda w: f"/accounts/{w.account_id}/groups/new", + "new-role-form": lambda w: f"/accounts/{w.account_id}/roles/new", + "own-policy-form": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}/policies/new"), + "owner-policy-form": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/policies/new"), + "group-policy-form": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/policies/new"), + "role-policy-form": lambda w: ( + f"/accounts/{w.account_id}/roles/{w.role}/policies/new"), +} + +# Console forms that redirect an IAM-user session, with a complete form for +# each so that the handler runs. Each one changes the catalog if it is let +# through. Several name the viewer itself: its own user, its own policies and +# its own group membership are for admins to change. +FORMS: dict[str, Callable[[World], tuple[str, dict[str, str]]]] = { + "create-account": lambda w: ( + "/accounts/new", {"account_id": "", "account_name": f"new-{w.account_name}"}), + "delete-account": lambda w: (f"/accounts/{w.account_id}/delete", {}), + "create-user": lambda w: ( + f"/accounts/{w.account_id}/users/new", {"user_name": f"new-{w.peer}", "password": ""}), + "delete-peer": lambda w: (f"/accounts/{w.account_id}/users/{w.peer}/delete", {}), + "delete-self": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}/delete", {}), + "create-owner-key": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/access-keys/new", {}), + "delete-owner-key": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/access-keys/{w.owner_key}/delete", {}), + "create-group": lambda w: ( + f"/accounts/{w.account_id}/groups/new", {"group_name": f"new-{w.group}"}), + "delete-group": lambda w: (f"/accounts/{w.account_id}/groups/{w.group}/delete", {}), + "join-group": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/members/add", + {"user_name": w.viewer_name}), + "remove-member": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/members/{w.owner}/remove", {}), + "create-role": lambda w: ( + f"/accounts/{w.account_id}/roles/new", + {"role_name": f"new-{w.role}", "trust_policy": json.dumps(_trust_policy(w.account_id))}), + "delete-role": lambda w: (f"/accounts/{w.account_id}/roles/{w.role}/delete", {}), + "put-own-policy": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}/policies/new", + {"policy_name": f"self-{w.user_policy}", "policy_document": json.dumps(_FULL_ACCESS)}), + "delete-own-policy": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}/policies/{_SELF_SERVICE_POLICY}/delete", + {}), + "put-owner-policy": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/policies/new", + {"policy_name": f"new-{w.user_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-owner-policy": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/policies/{w.user_policy}/delete", {}), + "put-group-policy": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/policies/new", + {"policy_name": f"new-{w.group_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-group-policy": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/policies/{w.group_policy}/delete", {}), + "put-role-policy": lambda w: ( + f"/accounts/{w.account_id}/roles/{w.role}/policies/new", + {"policy_name": f"new-{w.role_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-role-policy": lambda w: ( + f"/accounts/{w.account_id}/roles/{w.role}/policies/{w.role_policy}/delete", {}), +} + + +def _assert_redirected_to_console(resp: requests.Response, what: str) -> None: + assert resp.status_code == 303, f"{what}: {resp.status_code} {resp.text[:300]}" + assert resp.headers.get("location") == "/console", what + assert resp.content == b"", f"{what}: {resp.text[:300]}" + + +def _state(w: World) -> dict[str, object]: + """What a console form could change: the admin's pages of the account, + each user, the group and the role, and whether the account list holds + this account and the account the create-account form names. The admin's + console session reads them without a password check per request, which + a management API read would need. Other test files create and delete + accounts while this one runs, so the rest of the account list is not + compared.""" + a = w.account_id + paths = [f"/accounts/{a}", f"/accounts/{a}/groups/{w.group}", + f"/accounts/{a}/roles/{w.role}"] + paths += [f"/accounts/{a}/users/{u}" for u in (w.owner, w.peer, w.viewer_name)] + state: dict[str, object] = {} + for path in paths: + page = w.admin.get(path) + assert page.status_code == 200, f"{path}: {page.status_code}" + state[path] = page.text + accounts = w.admin.get("/accounts") + assert accounts.status_code == 200, accounts.status_code + state["/accounts"] = (a in accounts.text, f"new-{w.account_name}" in accounts.text) + return state + + +@pytest.mark.parametrize("page", sorted(PAGES)) +def test_refused_page_redirects_to_console(world: World, page: str) -> None: + resp = world.viewer.get(PAGES[page](world)) + _assert_redirected_to_console(resp, page) + for name in world.others(): + assert name not in str(resp.headers), f"{page} headers name {name}" + + +def test_another_user_and_a_missing_user_get_the_same_answer(world: World) -> None: + other = world.viewer.get(PAGES["owner"](world)) + missing = world.viewer.get(PAGES["missing-user"](world)) + assert (missing.status_code, missing.headers.get("location"), missing.content) == ( + other.status_code, other.headers.get("location"), other.content) + + +@pytest.mark.parametrize("action", sorted(FORMS)) +def test_refused_form_redirects_to_console_and_changes_nothing( + world: World, action: str +) -> None: + path, form = FORMS[action](world) + before = _state(world) + + resp = world.viewer.post(path, form) + + after = _state(world) + _assert_redirected_to_console(resp, action) + changed = [page for page in before if after[page] != before[page]] + assert not changed, f"{action} changed {changed}" + + +def test_viewer_reads_its_own_user_page(world: World) -> None: + a, user = world.account_id, world.viewer_name + resp = world.viewer.get(f"/accounts/{a}/users/{user}") + + assert resp.status_code == 200, resp.status_code + assert f"

User: {user}

" in resp.text + assert f"{world.viewer_key}" in resp.text + assert f'action="/console/accounts/{a}/users/{user}/access-keys/new"' in resp.text + assert (f'action="/console/accounts/{a}/users/{user}/access-keys/' + f'{world.viewer_key}/delete"') in resp.text + # The management API refuses the viewer its own policies and tags, so + # the page leaves out the policies, groups and tags sections. + for absent in ("

Policies", "

Groups", "

Tags", _SELF_SERVICE_POLICY): + assert absent not in resp.text, absent + + +def test_management_api_gives_the_viewer_the_same_reads(world: World) -> None: + """The reads the console gives the viewer, and the ones it refuses, are + the ones the management API gives and refuses the same user.""" + base = f"{world.endpoint.rstrip('/')}/management/accounts/{world.account_id}" + verify = not world.endpoint.startswith("https://") + + def read(path: str) -> requests.Response: + return requests.get(f"{base}{path}", auth=world.viewer_auth(), timeout=30, + verify=verify) + + own_keys = read(f"/users/{world.viewer_name}/access-keys") + assert own_keys.status_code == 200, own_keys.text + assert world.viewer_key in own_keys.text + for path in ( + "/users", + "/groups", + "/roles", + f"/users/{world.owner}/access-keys", + f"/users/{world.owner}/policies", + f"/users/{world.owner}/tags", + f"/users/{world.viewer_name}/policies", + f"/users/{world.viewer_name}/tags", + f"/groups/{world.group}/policies", + f"/roles/{world.role}/policies", + ): + resp = read(path) + assert resp.status_code == 403, f"{path}: {resp.status_code} {resp.text[:200]}" + + +def test_viewer_manages_its_own_access_keys(world: World) -> None: + a, user = world.account_id, world.viewer_name + created = world.viewer.post(f"/accounts/{a}/users/{user}/access-keys/new", {}) + assert created.status_code == 200, created.status_code + match = _KEY_RE.search(created.text) + assert match, created.text[:500] + key_id = match.group(1) + assert f'href="/console/accounts/{a}/users/{user}"' in created.text + assert key_id in world.mgmt.list_access_keys(a, user).text + + page = world.viewer.get(f"/accounts/{a}/users/{user}") + assert f"{key_id}" in page.text + + deleted = world.viewer.post(f"/accounts/{a}/users/{user}/access-keys/{key_id}/delete", {}) + assert deleted.status_code == 303, deleted.status_code + assert deleted.headers.get("location") == f"/console/accounts/{a}/users/{user}" + assert key_id not in world.mgmt.list_access_keys(a, user).text + assert world.viewer_key in world.mgmt.list_access_keys(a, user).text + + +def _viewer_pages(world: World) -> dict[str, str]: + """Every page the viewer opens: the home page, its user page, and the + page that shows a key it creates. The key is deleted again.""" + a, user = world.account_id, world.viewer_name + pages = {} + for name, path in (("home", ""), ("user", f"/accounts/{a}/users/{user}")): + resp = world.viewer.get(path) + assert resp.status_code == 200, f"{name}: {resp.status_code}" + pages[name] = resp.text + created = world.viewer.post(f"/accounts/{a}/users/{user}/access-keys/new", {}) + assert created.status_code == 200, created.status_code + pages["new-key"] = created.text + match = _KEY_RE.search(created.text) + assert match, created.text[:500] + deleted = world.viewer.post( + f"/accounts/{a}/users/{user}/access-keys/{match.group(1)}/delete", {}) + assert deleted.status_code == 303, deleted.status_code + return pages + + +def test_no_page_the_viewer_opens_names_another_principal(world: World) -> None: + for page, text in _viewer_pages(world).items(): + for name in world.others(): + assert name not in text, f"the {page} page shows {name}" + + +def test_viewer_pages_link_to_no_page_that_redirects_it(world: World) -> None: + pages = _viewer_pages(world) + own_page = f"/console/accounts/{world.account_id}/users/{world.viewer_name}" + for page in ("home", "new-key"): + assert f'href="{own_page}"' in pages[page], f"the {page} page has no link to {own_page}" + # The Metrics, Settings and Cache links are outside the account pages, + # and the documentation pages are public, so the check covers the home + # page and the pages under /console/accounts. + for page, text in pages.items(): + for link in sorted(set(_HREF_RE.findall(text))): + if link != "/console" and not link.startswith("/console/accounts"): + continue + resp = world.viewer.open(link) + assert resp.status_code == 200, ( + f"the {page} page links to {link}, which answers {resp.status_code} " + f"{resp.headers.get('location')}") + + +def test_viewer_logs_in_manages_keys_and_logs_out(world: World) -> None: + a, user = world.account_id, world.viewer_name + browser = Console(world.endpoint) + home = browser.login(*world.viewer_auth()) + own_page = f"/console/accounts/{a}/users/{user}" + assert f'' in home.text + + page = browser.open(own_page) + assert page.status_code == 200, page.status_code + assert f"{world.viewer_key}" in page.text + + resp = browser.post("/logout", {}) + assert resp.status_code == 303, resp.status_code + assert resp.headers.get("location") == "/console/login" + resp = browser.open(own_page) + assert resp.status_code == 303, resp.status_code + assert resp.headers.get("location") == "/console/login" + + +# The names each page shows an admin. +SHOWN: dict[str, Callable[[World], tuple[str, list[str]]]] = { + "accounts": lambda w: ("/accounts", [w.account_id, w.account_name]), + "account": lambda w: ( + f"/accounts/{w.account_id}", + [w.account_name, w.owner, w.peer, w.viewer_name, w.group, w.role]), + "owner": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}", + [w.owner_key, w.user_policy, w.group, w.tag_key, w.tag_value]), + "viewer": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}", + [w.viewer_key, _SELF_SERVICE_POLICY]), + "group": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}", [w.owner, w.group_policy]), + "role": lambda w: (f"/accounts/{w.account_id}/roles/{w.role}", [w.role_policy]), +} + + +@pytest.mark.parametrize("page", sorted(SHOWN)) +def test_admin_reads_every_page_of_the_account(world: World, page: str) -> None: + path, names = SHOWN[page](world) + resp = world.admin.get(path) + assert resp.status_code == 200, f"{page}: {resp.status_code}" + for name in names: + assert name in resp.text, f"{page} lacks {name}" From 33680119b89e25054156ee4f4453f0a51c691565 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:54:39 +0000 Subject: [PATCH 3/4] docs: describe the console pages an IAM user opens in its own account The getting started guide, the admin guide, the architecture guide and the security model now say that an IAM-user session opens its own user page and manages its own access keys, and that every other page under its account redirects it to the dashboard. The upgrade manual has a v0.1.15 entry for the change. The earlier v0.1.15 entry for console account scoping no longer says that the home page links an IAM user to its own account, or that the pages of the user's own account are unchanged. --- docs/getting-started.md | 4 ++-- docs/manuals/01-architecture-guide.md | 2 +- docs/manuals/05-admin-guide.md | 12 ++++++------ docs/manuals/07-upgrade-manual.md | 18 ++++++++++++++++-- docs/manuals/10-security-model.md | 2 +- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 712f34cfe..cf78a0403 100755 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1270,7 +1270,7 @@ Navigate to `https://127.0.0.1:18443/console/` in your browser (adjust the host ### Features -- **Dashboard:** Overview of accounts and admin users (admin sessions only; an IAM-user session gets a link to its own account). +- **Dashboard:** Overview of accounts and admin users (admin sessions only; an IAM-user session gets a link to its own user page). - **Account management:** Create, view, and delete accounts (admin only). - **User management:** Create and delete IAM users, view access keys, policies, tags, and group memberships. - **Access key management:** Create and delete access keys. The secret key is shown once at creation time. @@ -1278,7 +1278,7 @@ Navigate to `https://127.0.0.1:18443/console/` in your browser (adjust the host - **Role management:** Create and delete roles, view trust policies. - **Policy management:** Add and delete inline policies for users, groups, and roles. Includes a JSON editor with a default policy template. -Admin users have full access to all management operations. IAM users can view their own details and manage their own access keys. +Admin users have full access to all management operations. IAM users open their own user page, which lists their access keys, and create and delete their own access keys there. These are the access key reads and writes the management API gives an IAM user. The console has no form to change the password or import a key. Every other page under `/console/accounts`, the account list included, redirects an IAM-user session to the dashboard. Sessions expire after 8 hours of inactivity. Click "Logout" to end a session immediately. diff --git a/docs/manuals/01-architecture-guide.md b/docs/manuals/01-architecture-guide.md index 412e37d1a..f7b880ed1 100755 --- a/docs/manuals/01-architecture-guide.md +++ b/docs/manuals/01-architecture-guide.md @@ -260,7 +260,7 @@ The management web console is a server-rendered HTML interface mounted at `/cons ### Features - **Login**: Admin users and IAM users (`account_id/user_name`) -- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own account), version info, quick links +- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own user page), version info, quick links - **Account management**: Create, view, delete accounts - **User management**: Create, delete users; view access keys, policies, tags, group memberships - **Access key management**: Create and delete access keys (secret shown once at creation) diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index e51da206c..9fbe0cae4 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -800,12 +800,12 @@ The management web console is served at `/console/` on the same port as the Dyna ### Features -- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own account), version info -- **Account management**: Create, view, delete accounts (an IAM-user session views its own account only and is redirected to `/console` from the pages of any other account) -- **User management**: Create, delete users; view access keys, policies, tags, group memberships -- **Access key management**: Create and delete access keys (secret shown once) -- **Group management**: Create, delete groups; add/remove members -- **Role management**: Create, delete roles; view trust policies +- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own user page), version info +- **Account management**: Create, view, delete accounts (admin only; an IAM-user session is redirected to `/console` from the account list and from every account page) +- **User management**: Create, delete users; view access keys, policies, tags, group memberships (an IAM-user session opens its own user page only, which lists its access keys and leaves out its policies, groups and tags) +- **Access key management**: Create and delete access keys (secret shown once; an IAM-user session manages its own keys only) +- **Group management**: Create, delete groups; add/remove members (admin only) +- **Role management**: Create, delete roles; view trust policies (admin only) - **Policy management**: Add, delete inline policies with JSON editor ### Authentication diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index b1e3295e3..137ae08bb 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -745,9 +745,23 @@ A policy whose Arn values are literal ARNs, applied to a context value that is a ### v0.1.15: console account pages serve an IAM user its own account only -**What changed.** Every console page and form under `/console/accounts/{account_id}` now redirects an IAM-user session to `/console` when `{account_id}` is not the session's own account, and the answer is the same whether or not an account has that ID. Before this release the account, user, group and role pages checked only that a console session existed. An IAM user who requested another account's ID read that account's name, the names of its users, groups and roles, each user's access key IDs, inline policy names, groups and tags, and each role's trust policy. The forms under that path already refused IAM users, except the access key forms, which accept an IAM user's own keys. A refused form for another account now redirects to `/console`. Before this release 8 of the 17 forms redirected to a page of that account (the forms that create and delete users, groups and roles, and that add and remove group members), the delete account form redirected to `/console/accounts`, and the 2 access key forms and 6 policy forms redirected to `/console`. The console home page now shows the deployment's account count and admin count to admin sessions only, and shows an IAM-user session a link to its own account. +**What changed.** Every console page and form under `/console/accounts/{account_id}` now redirects an IAM-user session to `/console` when `{account_id}` is not the session's own account, and the answer is the same whether or not an account has that ID. Before this release the account, user, group and role pages checked only that a console session existed. An IAM user who requested another account's ID read that account's name, the names of its users, groups and roles, each user's access key IDs, inline policy names, groups and tags, and each role's trust policy. The forms under that path already refused IAM users, except the access key forms, which accept an IAM user's own keys. A refused form for another account now redirects to `/console`. Before this release 8 of the 17 forms redirected to a page of that account (the forms that create and delete users, groups and roles, and that add and remove group members), the delete account form redirected to `/console/accounts`, and the 2 access key forms and 6 policy forms redirected to `/console`. The console home page now shows the deployment's account count and admin count to admin sessions only. -**Who is affected.** IAM users who opened another account's pages in the console. They are redirected to `/console`. The pages of an IAM user's own account and every page an admin session sees are unchanged. +**Who is affected.** IAM users who opened another account's pages in the console. They are redirected to `/console`. Every page an admin session sees is unchanged. + +No catalog migration is required. + +--- + +### v0.1.15: console pages serve an IAM user its own user page and access keys only + +**What changed.** In its own account an IAM-user console session now opens only its own user page and the forms that create and delete its own access keys. These are the access key reads and writes the management API gives the same user. The console has no form to change the password or import a key. Every other page under `/console/accounts/{account_id}` redirects the session to `/console`: the account page, the pages of the account's other users, every group page, every role page, and the new user, new group, new role and policy forms. The account list at `/console/accounts` also redirects it, and the navigation bar shows the Accounts link to admin sessions only. Each handler makes its check before it reads the account, user, group or role in the path, so another user's name and a name that no user has get the same answer. Before this release an IAM-user session read every page of its own account. The account page named every user, group and role, a user page showed that user's access key IDs, inline policy names, groups and tags, a group page showed its members and inline policy names, and a role page showed its trust policy, inline policy names and tags. The account list showed the account's name. The management API answered the same reads with HTTP 403. + +An IAM user's own user page now lists its access keys, with the forms that create and delete them, and leaves out the user's inline policies, groups and tags, which the management API does not give the user either. The console home page now links an IAM-user session to its own user page instead of the account page. + +The forms that change users, groups, roles and policies already refused IAM users. A refused form now redirects to `/console`. Before this release, in an IAM user's own account, the 8 forms that create and delete users, groups and roles and that add and remove group members redirected to the account page or the group page, and the delete account form redirected to `/console/accounts`. The new account form and its submission, `GET` and `POST /console/accounts/new`, also sent an IAM user to `/console/accounts`. The policy forms already redirected to `/console`. + +**Who is affected.** IAM users who opened pages of their own account other than their own user page, or who read their own policies, groups or tags on that page. They are redirected to `/console`, or no longer see those sections. Admin sessions see every page as before. No catalog migration is required. diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index 89da45d36..9a9a5d0a8 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -222,7 +222,7 @@ Replace the certificate and key files, then restart extenddb. There is no hot-re ## Web Console Security -An admin session can read and change every account. Every page and form under `/console/accounts/{account_id}` serves an IAM-user session its own account only. When `{account_id}` is not the session's own account, the handler redirects the session to `/console`. The handler makes this comparison before it looks up the account in the path, so another account's ID and an ID that no account has get the same answer. The accounts list shows an IAM-user session its own account alone, and the console home page shows the deployment's account count and admin count to admin sessions only. In its own account an IAM-user session currently reads every user, group and role page, including other users' access key IDs, policy names and tags, which the management API refuses the same user. Issue #731 tracks this. An IAM-user session can create and delete access keys for its own user, and every other change in the console needs an admin session. Until pull request #727 merges, `/console/metrics-data` serves every console session the table names, index names and traffic metrics of every account, because the metrics table has no account column. +An admin session can read and change every account. Every page and form under `/console/accounts/{account_id}` serves an IAM-user session its own account only. When `{account_id}` is not the session's own account, the handler redirects the session to `/console`. The handler makes this comparison before it looks up the account in the path, so another account's ID and an ID that no account has get the same answer. The accounts list, and the deployment's account count and admin count on the console home page, are for admin sessions only. In its own account an IAM-user session opens its own user page, which lists its access keys, and creates and deletes its own access keys: the access key reads and writes the management API gives the same user. The console has no form to change the password or import a key. Its own user page leaves out the user's inline policies, groups and tags, which the management API does not give it either. Every other page and form under its account redirects it to `/console`: the account page, the pages of the other users, every group and role page, and every form that changes a user, group, role or policy, its own user and its own policies included. The handler compares the user name in the path with the session's own before it reads anything about that user, so another user's name and a name that no user has get the same answer. Until pull request #727 merges, `/console/metrics-data` serves every console session the table names, index names and traffic metrics of every account, because the metrics table has no account column. The management web console (`/console/*`) implements: From 1fc6c2348a642da838563a08c664531be59c6da3 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:35:23 +0000 Subject: [PATCH 4/4] test(console): check store reads on refused pages and CSRF on key forms account_scope_reads_tests.rs runs the account, group, role and user page handlers against a catalog store that holds no data and counts every call to it. An IAM-user session that requests the account page, a group page, a role page or another user's page gets a redirect to /console (HTTP 303). The handler makes no call to the store. An admin request and the IAM user's own user page each make one call. The integration test posts the create and delete access key forms as the IAM user. It posts each form twice, once with an empty CSRF token and once with a wrong token of the same length. Each answer is HTTP 403, and the user's access keys, as the management API lists them, do not change. The IAM user in the integration test now belongs to a second group and has a tag. The test checks that its own page leaves out that group and tag, and that the admin's view of the same page shows them. --- .../server/src/console/pages/account_scope.rs | 4 + .../pages/account_scope_reads_tests.rs | 307 ++++++++++++++++++ tests/test_console_iam_user_pages.py | 73 ++++- 3 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 crates/server/src/console/pages/account_scope_reads_tests.rs diff --git a/crates/server/src/console/pages/account_scope.rs b/crates/server/src/console/pages/account_scope.rs index 56d2e1d29..d7513d57e 100644 --- a/crates/server/src/console/pages/account_scope.rs +++ b/crates/server/src/console/pages/account_scope.rs @@ -250,3 +250,7 @@ mod tests { } } } + +#[cfg(test)] +#[path = "account_scope_reads_tests.rs"] +mod reads_tests; diff --git a/crates/server/src/console/pages/account_scope_reads_tests.rs b/crates/server/src/console/pages/account_scope_reads_tests.rs new file mode 100644 index 000000000..6897015a2 --- /dev/null +++ b/crates/server/src/console/pages/account_scope_reads_tests.rs @@ -0,0 +1,307 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Tests that the handler of a page an IAM-user session may not open +//! redirects the session before it calls the catalog store. +//! +//! The handlers run against a store that holds no data and counts every +//! call to it. The session lives in the console's in-memory session store, +//! so a refused request makes no call at all. An admin request and the IAM +//! user's own user page each make one call, which shows that the store +//! counts the calls the handlers make. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use axum::extract::{Path, State}; +use axum::http::header::{COOKIE, LOCATION}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::Response; +use extenddb_core::metrics::MetricsCollector; +use extenddb_storage::CatalogStore; +use extenddb_storage::authorization_store::{AuthorizationStore, SessionData as RoleSession}; +use extenddb_storage::management_store::{ + AccessKeyCreated, AccountDetail, AdminEntry, AdminStore, GroupDetail, GroupListEntry, + ManagementStore, MetricsRow, MetricsStore, OpError, OpResult, RateLimitStore, RoleDetail, + RoleListEntry, SettingsStore, UserDetail, UserListEntry, +}; +use futures::future::BoxFuture; +use serde_json::Value; +use time::OffsetDateTime; + +use crate::console::ConsoleState; +use crate::console::pages::{account_detail, group_detail, role_detail, user_detail}; +use crate::console::session::SessionStore; +use crate::management::CallerIdentity; + +const ACCOUNT: &str = "444455556666"; +/// The name of the user `iam_user` logs in as. +const OWN_USER: &str = "bob"; + +/// A catalog store that holds no data and counts every call to it. +#[derive(Default)] +struct CountingStore { + calls: AtomicUsize, +} + +impl CountingStore { + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + /// Count a call and answer it with an error. Each page handler renders + /// an error from the store as its "not found" page. + fn call(&self) -> BoxFuture<'_, OpResult> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Err(OpError::Internal("the test store holds no data".to_owned())) }) + } +} + +/// Implements each listed method of a store trait as a counted call. +/// `name(argument types) -> output;` stands for +/// `fn name(&self, ...) -> BoxFuture<'_, OpResult>`. +macro_rules! counted_calls { + ($($name:ident($($arg:ty),*) -> $out:ty;)*) => { + $( + fn $name(&self, $(_: $arg),*) -> BoxFuture<'_, OpResult<$out>> { + self.call() + } + )* + }; +} + +impl ManagementStore for CountingStore { + counted_calls! { + create_account(&str, &str) -> (); + delete_account(&str) -> (); + list_all_accounts() -> Vec<(String, String)>; + default_account_id() -> Option; + list_all_accounts_full() -> Vec<(String, String, OffsetDateTime)>; + list_accounts_for(&str) -> Vec<(String, String)>; + get_account_detail(&str) -> Option; + dashboard_counts() -> (i64, i64); + create_user(&str, &str, Option<&str>) -> (); + delete_user(&str, &str) -> (); + list_users(&str) -> Vec; + get_user_detail(&str, &str) -> Option; + verify_iam_user_password(&str, &str, &str) -> bool; + change_user_password(&str, &str, &str) -> (); + tag_user(&str, &str, &[(String, String)]) -> (); + untag_user(&str, &str, &[String]) -> (); + list_user_tags(&str, &str) -> Vec<(String, String)>; + create_group(&str, &str) -> (); + delete_group(&str, &str) -> (); + list_groups(&str) -> Vec; + get_group_detail(&str, &str) -> Option; + add_group_member(&str, &str, &str) -> (); + remove_group_member(&str, &str, &str) -> (); + create_role(&str, &str, &Value) -> (); + delete_role(&str, &str) -> (); + list_roles(&str) -> Vec; + get_role_detail(&str, &str) -> Option; + get_role_trust_policy(&str, &str) -> Option; + tag_role(&str, &str, &[(String, String)]) -> (); + untag_role(&str, &str, &[String]) -> (); + list_role_tags(&str, &str) -> Vec<(String, String)>; + put_policy(&str, &str, &str, &str, &Value) -> (); + delete_policy(&str, &str, &str, &str) -> (); + list_policies(&str, &str, &str) -> Vec<(String, Value, OffsetDateTime)>; + set_user_boundary(&str, &str, &Value) -> (); + get_user_boundary(&str, &str) -> Option; + delete_user_boundary(&str, &str) -> (); + set_role_boundary(&str, &str, &Value) -> (); + get_role_boundary(&str, &str) -> Option; + delete_role_boundary(&str, &str) -> (); + create_access_key(&str, &str) -> AccessKeyCreated; + delete_access_key(&str, &str, &str) -> (); + list_access_keys(&str, &str) -> Vec<(String, bool, OffsetDateTime)>; + import_access_key(&str, &str, &str, &str) -> (); + store_session( + &str, &str, &[u8], &str, &str, &str, &Option, &Option, OffsetDateTime + ) -> (); + fetch_caller_tags(&str, &str) -> Vec<(String, String)>; + } +} + +impl AdminStore for CountingStore { + counted_calls! { + create_admin(&str, &str) -> (); + list_admins() -> Vec; + delete_admin(&str) -> (); + change_admin_password(&str, &str) -> (); + verify_admin_password(&str, &str) -> Option; + } +} + +impl SettingsStore for CountingStore { + counted_calls! { + get_setting(&str) -> Option; + set_setting(&str, &str) -> (); + list_settings() -> Vec<(String, String)>; + } +} + +impl MetricsStore for CountingStore { + counted_calls! { + insert_metrics(&[MetricsRow]) -> (); + query_metrics( + OffsetDateTime, OffsetDateTime, Option<&str>, Option<&str> + ) -> Vec; + prune_metrics(Duration) -> (); + } +} + +impl RateLimitStore for CountingStore { + counted_calls! { + count_principal_failures(&str, &str, i64) -> i64; + count_ip_failures(&str, i64) -> i64; + } + + fn record_failed_login(&self, _: &str, _: Option<&str>) -> BoxFuture<'_, ()> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async {}) + } + + fn cleanup_old_attempts(&self, _: i64) -> BoxFuture<'_, ()> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async {}) + } +} + +impl AuthorizationStore for CountingStore { + counted_calls! { + fetch_user_policies(&str, &str) -> Vec; + fetch_user_group_policies(&str, &str) -> Vec; + fetch_user_boundary(&str, &str) -> Option; + fetch_role_policies(&str, &str) -> Vec; + fetch_role_boundary(&str, &str) -> Option; + fetch_session_data(&str, &str, &str) -> Option; + fetch_user_tags(&str, &str) -> Vec<(String, String)>; + fetch_role_tags(&str, &str) -> Vec<(String, String)>; + fetch_resource_tags(&str) -> Vec<(String, String)>; + fetch_user_created_at(&str, &str) -> Option; + fetch_role_created_at(&str, &str) -> Option; + } +} + +impl CatalogStore for CountingStore { + fn cached_encryption_key(&self) -> Option { + None + } +} + +fn admin() -> CallerIdentity { + CallerIdentity::Admin("admin".to_owned()) +} + +fn iam_user() -> CallerIdentity { + CallerIdentity::IamUser { + account_id: ACCOUNT.to_owned(), + user_name: OWN_USER.to_owned(), + } +} + +/// A console page of the account `ACCOUNT`. +#[derive(Clone, Copy, Debug)] +enum Page { + Account, + Group, + Role, + User(&'static str), +} + +/// A console whose catalog is a new `CountingStore`, the store, and the +/// cookie header of a session that `identity` holds in the console. +async fn console(identity: CallerIdentity) -> (Arc, Arc, HeaderMap) { + let store = Arc::new(CountingStore::default()); + let state = Arc::new(ConsoleState { + sessions: SessionStore::new(), + version_info: Arc::from("test-version"), + listen_url: "https://127.0.0.1:8000".to_owned(), + config_entries: Vec::new(), + catalog_store: store.clone(), + docs_store: None, + auth_cache: extenddb_auth::AuthCacheRegistry::empty(), + metrics: Arc::new(MetricsCollector::new()), + }); + let (token, _csrf) = state.sessions.create(identity).await; + let mut headers = HeaderMap::new(); + let cookie = HeaderValue::from_str(&format!("extenddb_session={token}")).expect("cookie"); + headers.insert(COOKIE, cookie); + (state, store, headers) +} + +/// Run the handler of `page` for the session in `headers`. +async fn open(state: &Arc, headers: &HeaderMap, page: Page) -> Response { + let (state, headers) = (State(Arc::clone(state)), headers.clone()); + let account = ACCOUNT.to_owned(); + match page { + Page::Account => account_detail(state, headers, Path(account)).await, + Page::Group => group_detail(state, headers, Path((account, "devs".to_owned()))).await, + Page::Role => role_detail(state, headers, Path((account, "deployer".to_owned()))).await, + Page::User(name) => user_detail(state, headers, Path((account, name.to_owned()))).await, + } +} + +/// `page` redirects an IAM-user session to the console home page, and its +/// handler makes no call to the store. +async fn assert_refused_before_the_store_is_called(page: Page) { + let (state, store, headers) = console(iam_user()).await; + + let response = open(&state, &headers, page).await; + + assert_eq!(response.status(), StatusCode::SEE_OTHER, "{page:?}"); + let location = response + .headers() + .get(LOCATION) + .and_then(|v| v.to_str().ok()); + assert_eq!(location, Some("/console"), "{page:?}"); + assert_eq!( + store.calls(), + 0, + "the handler of {page:?} called the store for a refused IAM-user session" + ); +} + +#[tokio::test] +async fn iam_user_is_refused_the_account_page_before_the_store_is_called() { + assert_refused_before_the_store_is_called(Page::Account).await; +} + +#[tokio::test] +async fn iam_user_is_refused_a_group_page_before_the_store_is_called() { + assert_refused_before_the_store_is_called(Page::Group).await; +} + +#[tokio::test] +async fn iam_user_is_refused_a_role_page_before_the_store_is_called() { + assert_refused_before_the_store_is_called(Page::Role).await; +} + +#[tokio::test] +async fn iam_user_is_refused_another_users_page_before_the_store_is_called() { + assert_refused_before_the_store_is_called(Page::User("alice")).await; +} + +#[tokio::test] +async fn iam_user_own_page_calls_the_store_once() { + let (state, store, headers) = console(iam_user()).await; + + let response = open(&state, &headers, Page::User(OWN_USER)).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(store.calls(), 1); +} + +#[tokio::test] +async fn admin_page_calls_the_store_once() { + for page in [Page::Account, Page::Group, Page::Role, Page::User("alice")] { + let (state, store, headers) = console(admin()).await; + + let response = open(&state, &headers, page).await; + + assert_eq!(response.status(), StatusCode::OK, "{page:?}"); + assert_eq!(store.calls(), 1, "{page:?}"); + } +} diff --git a/tests/test_console_iam_user_pages.py b/tests/test_console_iam_user_pages.py index 23ad483ea..7230d9794 100644 --- a/tests/test_console_iam_user_pages.py +++ b/tests/test_console_iam_user_pages.py @@ -16,8 +16,9 @@ tag; a second user `peer`; a group with `owner` as its member and an inline policy; and a role with an inline policy. The IAM user `viewer` is created with a console password, so it holds only the self-service policy that every -such user gets. Each name carries a random suffix, so that no page shows it -by chance. +such user gets. It is the member of a second group and has a tag, which its +own page leaves out. Each name carries a random suffix, so that no page +shows it by chance. Prerequisites match tests/test_console_account_scope.py: - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT @@ -160,17 +161,25 @@ class World: viewer_name: str viewer_password: str viewer_key: str + viewer_group: str # the group the viewer is a member of + viewer_tag_key: str + viewer_tag_value: str missing_user: str # a name that no user has def others(self) -> list[str]: - """Every name of the account, its other users, its group and its + """Every name of the account, its other users, its groups and its role, which no page the viewer opens may show.""" return [ self.account_name, self.owner, self.peer, self.group, self.role, self.user_policy, self.group_policy, self.role_policy, - self.owner_key, self.tag_key, self.tag_value, + self.owner_key, self.tag_key, self.tag_value, self.viewer_group, ] + def viewer_hidden(self) -> list[str]: + """The viewer's group and tag, which the management API does not + give the viewer and its own page leaves out.""" + return [self.viewer_group, self.viewer_tag_key, self.viewer_tag_value] + def viewer_auth(self) -> tuple[str, str]: return (f"{self.account_id}/{self.viewer_name}", self.viewer_password) @@ -201,6 +210,9 @@ def world(): viewer_name=f"viewer-{suffix}", viewer_password=uuid.uuid4().hex, viewer_key="", + viewer_group=f"viewer-group-{suffix}", + viewer_tag_key=f"viewer-tag-key-{suffix}", + viewer_tag_value=f"viewer-tag-value-{suffix}", missing_user=f"nobody-{suffix}", ) a = w.account_id @@ -217,6 +229,9 @@ def world(): lambda: mgmt.put_group_policy(a, w.group, w.group_policy, _POLICY), lambda: mgmt.put_role_policy(a, w.role, w.role_policy, _POLICY), lambda: mgmt.tag_user(a, w.owner, {w.tag_key: w.tag_value}), + lambda: mgmt.create_group(a, w.viewer_group), + lambda: mgmt.add_group_member(a, w.viewer_group, w.viewer_name), + lambda: mgmt.tag_user(a, w.viewer_name, {w.viewer_tag_key: w.viewer_tag_value}), ] for step in steps: resp = step() @@ -237,6 +252,8 @@ def world(): mgmt.delete_role_policy(a, w.role, w.role_policy) mgmt.remove_group_member(a, w.group, w.owner) mgmt.delete_group(a, w.group) + mgmt.remove_group_member(a, w.viewer_group, w.viewer_name) + mgmt.delete_group(a, w.viewer_group) mgmt.delete_role(a, w.role) for user in (w.owner, w.peer, w.viewer_name): keys = mgmt.list_access_keys(a, user) @@ -396,6 +413,8 @@ def test_viewer_reads_its_own_user_page(world: World) -> None: # the page leaves out the policies, groups and tags sections. for absent in ("

Policies", "

Groups", "

Tags", _SELF_SERVICE_POLICY): assert absent not in resp.text, absent + for name in world.viewer_hidden(): + assert name not in resp.text, f"the viewer's own page shows {name}" def test_management_api_gives_the_viewer_the_same_reads(world: World) -> None: @@ -447,6 +466,50 @@ def test_viewer_manages_its_own_access_keys(world: World) -> None: assert world.viewer_key in world.mgmt.list_access_keys(a, user).text +def _viewer_key_ids(w: World) -> set[str]: + """The viewer's access key IDs, as the management API lists them.""" + resp = w.mgmt.list_access_keys(w.account_id, w.viewer_name) + assert resp.status_code == 200, resp.text + return {key["access_key_id"] for key in resp.json()} + + +# A `_csrf` value other than the session's token: an empty one, and one of +# the same length that differs from the token in its last character. +WRONG_CSRF: dict[str, Callable[[str], str]] = { + "empty": lambda token: "", + "altered": lambda token: token[:-1] + ("1" if token.endswith("0") else "0"), +} + + +@pytest.mark.parametrize("csrf", sorted(WRONG_CSRF)) +@pytest.mark.parametrize("form", ["create-key", "delete-key"]) +def test_key_form_with_a_wrong_csrf_token_gets_403_and_changes_no_key( + world: World, form: str, csrf: str +) -> None: + """The two forms an IAM-user session can submit check the session's + CSRF token before they change a key.""" + a, user = world.account_id, world.viewer_name + # A key for the delete form to name, so that the viewer's own key stays + # in place if the form deletes it. + extra = world.mgmt.create_access_key(a, user) + assert extra.status_code == 201, extra.text + path = { + "create-key": f"/accounts/{a}/users/{user}/access-keys/new", + "delete-key": (f"/accounts/{a}/users/{user}/access-keys/" + f"{extra.json()['access_key_id']}/delete"), + }[form] + before = _viewer_key_ids(world) + try: + resp = world.viewer.post(path, {"_csrf": WRONG_CSRF[csrf](world.viewer.csrf)}) + after = _viewer_key_ids(world) + finally: + for key_id in _viewer_key_ids(world) - {world.viewer_key}: + world.mgmt.delete_access_key(a, user, key_id) + + assert resp.status_code == 403, f"{form}: {resp.status_code} {resp.text[:300]}" + assert after == before, f"{form} changed the keys from {before} to {after}" + + def _viewer_pages(world: World) -> dict[str, str]: """Every page the viewer opens: the home page, its user page, and the page that shows a key it creates. The key is deleted again.""" @@ -521,7 +584,7 @@ def test_viewer_logs_in_manages_keys_and_logs_out(world: World) -> None: [w.owner_key, w.user_policy, w.group, w.tag_key, w.tag_value]), "viewer": lambda w: ( f"/accounts/{w.account_id}/users/{w.viewer_name}", - [w.viewer_key, _SELF_SERVICE_POLICY]), + [w.viewer_key, _SELF_SERVICE_POLICY, *w.viewer_hidden()]), "group": lambda w: ( f"/accounts/{w.account_id}/groups/{w.group}", [w.owner, w.group_policy]), "role": lambda w: (f"/accounts/{w.account_id}/roles/{w.role}", [w.role_policy]),