diff --git a/crates/server/src/console/pages/access_key_pages.rs b/crates/server/src/console/pages/access_key_pages.rs index c141d709a..a16eeb971 100755 --- a/crates/server/src/console/pages/access_key_pages.rs +++ b/crates/server/src/console/pages/access_key_pages.rs @@ -13,7 +13,8 @@ use axum::response::{Html, IntoResponse, Redirect, Response}; use crate::console::ConsoleState; use crate::console::html; -use super::{CsrfOnly, identity_label, op_error_message, require_csrf, require_session}; +use super::account_scope::require_account_session; +use super::{CsrfOnly, identity_label, op_error_message, require_csrf}; use crate::management::CallerIdentity; @@ -24,7 +25,7 @@ pub async fn create_access_key( Path((account_id, user_name)): Path<(String, String)>, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -119,7 +120,7 @@ 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_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; diff --git a/crates/server/src/console/pages/account_pages.rs b/crates/server/src/console/pages/account_pages.rs index 58c1c2c45..48cd0d9e0 100755 --- a/crates/server/src/console/pages/account_pages.rs +++ b/crates/server/src/console/pages/account_pages.rs @@ -14,28 +14,53 @@ use serde::Deserialize; use crate::console::ConsoleState; use crate::console::html; +use crate::management::CallerIdentity; -use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf, require_session}; +use super::account_scope::require_account_session; +use super::{ + CsrfOnly, SessionData, identity_label, is_admin, op_error_message, require_csrf, + require_session, +}; -/// GET /console/ — dashboard showing account count and quick links. +/// GET /console/: the dashboard with quick links. An admin session also sees +/// the deployment's account and admin counts. pub async fn dashboard(State(state): State>, headers: HeaderMap) -> Response { let session = match require_session(&headers, &state).await { Ok(s) => s, Err(r) => return r, }; - let nav = html::nav_bar(&identity_label(&session.identity)); + // The counts cover every account, so they are read for admins only. + let counts = if is_admin(&session.identity) { + Some( + state + .catalog_store + .dashboard_counts() + .await + .unwrap_or((0, 0)), + ) + } else { + None + }; - let (account_count, admin_count) = state - .catalog_store - .dashboard_counts() - .await - .unwrap_or((0, 0)); + render_dashboard(&session, counts, &state.version_info) +} - let content = format!( - r#"

Dashboard

-
-
+/// 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. +fn render_dashboard( + session: &SessionData, + counts: Option<(i64, i64)>, + version_info: &str, +) -> Response { + let nav = html::nav_bar(&identity_label(&session.identity)); + + let cards = match &session.identity { + CallerIdentity::Admin(_) => { + let (account_count, admin_count) = counts.unwrap_or((0, 0)); + format!( + r#"

{account_count}

Accounts

View all → @@ -43,7 +68,25 @@ pub async fn dashboard(State(state): State>, headers: HeaderMa

{admin_count}

Admin Users

-
+
"# + ) + } + CallerIdentity::IamUser { account_id, .. } => { + let eid = html::escape(account_id); + format!( + r#"
+

{eid}

+

Your Account

+View → +
"# + ) + } + }; + + let content = format!( + r#"

Dashboard

+
+{cards}
"# ); @@ -51,7 +94,7 @@ pub async fn dashboard(State(state): State>, headers: HeaderMa "Dashboard", &nav, &content, - Some(&state.version_info), + Some(version_info), &session.csrf_token, )) .into_response() @@ -245,7 +288,7 @@ pub async fn account_detail( headers: HeaderMap, Path(account_id): Path, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -364,7 +407,7 @@ pub async fn delete_account( Path(account_id): Path, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -397,3 +440,7 @@ pub async fn delete_account( } } } + +#[cfg(test)] +#[path = "account_pages_tests.rs"] +mod tests; diff --git a/crates/server/src/console/pages/account_pages_tests.rs b/crates/server/src/console/pages/account_pages_tests.rs new file mode 100644 index 000000000..d2429093f --- /dev/null +++ b/crates/server/src/console/pages/account_pages_tests.rs @@ -0,0 +1,85 @@ +// 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. + +use axum::http::StatusCode; +use axum::response::Response; + +use super::render_dashboard; +use crate::console::pages::SessionData; +use crate::management::CallerIdentity; + +/// Counts no test data would produce by chance, so a test can look for them +/// in a page. +const ACCOUNT_COUNT: i64 = 7_351; +const ADMIN_COUNT: i64 = 4_219; + +fn session(identity: CallerIdentity) -> SessionData { + SessionData { + identity, + csrf_token: "csrf-token".to_owned(), + } +} + +fn iam_user() -> CallerIdentity { + CallerIdentity::IamUser { + account_id: "444455556666".to_owned(), + user_name: "bob".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") +} + +#[tokio::test] +async fn iam_user_dashboard_has_no_deployment_count() { + // The counts are passed in to check that the page leaves them out even + // when they are at hand; the handler does not read them for an IAM user. + let response = render_dashboard( + &session(iam_user()), + Some((ACCOUNT_COUNT, ADMIN_COUNT)), + "test-version", + ); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_text(response).await; + assert!(body.contains("

Dashboard

"), "{body}"); + assert!(!body.contains(&ACCOUNT_COUNT.to_string()), "{body}"); + assert!(!body.contains(&ADMIN_COUNT.to_string()), "{body}"); + assert!(!body.contains("Admin Users"), "{body}"); + assert!(!body.contains("

Accounts

"), "{body}"); + assert!( + body.contains(r#""#), + "{body}" + ); +} + +#[tokio::test] +async fn admin_dashboard_shows_the_deployment_counts() { + let response = render_dashboard( + &session(CallerIdentity::Admin("admin".to_owned())), + Some((ACCOUNT_COUNT, ADMIN_COUNT)), + "test-version", + ); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_text(response).await; + assert!( + body.contains(&format!( + "

{ACCOUNT_COUNT}

\n

Accounts

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

{ADMIN_COUNT}

\n

Admin Users

" + )), + "{body}" + ); +} diff --git a/crates/server/src/console/pages/account_scope.rs b/crates/server/src/console/pages/account_scope.rs new file mode 100644 index 000000000..5c9aa6475 --- /dev/null +++ b/crates/server/src/console/pages/account_scope.rs @@ -0,0 +1,140 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The account check for the console pages under +//! `/console/accounts/{account_id}`. +//! +//! An admin session may use the pages of every account. An IAM-user session +//! 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. + +use std::sync::Arc; + +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Redirect, Response}; + +use crate::console::ConsoleState; +use crate::management::CallerIdentity; + +use super::{SessionData, require_session}; + +/// [`require_session`] for a page of the account `account_id`. +/// +/// An IAM-user session whose own account is not `account_id` gets a redirect +/// to the console home page. The check reads nothing from the catalog, so +/// another account's ID and an ID that no account has get the same answer. +#[allow(clippy::result_large_err)] +pub(super) async fn require_account_session( + headers: &HeaderMap, + state: &Arc, + account_id: &str, +) -> Result { + let session = require_session(headers, state).await?; + account_scope(&session.identity, account_id)?; + 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)] +fn account_scope(identity: &CallerIdentity, account_id: &str) -> Result<(), Response> { + match identity { + CallerIdentity::Admin(_) => Ok(()), + CallerIdentity::IamUser { + account_id: own, .. + } if own == account_id => 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 crate::management::CallerIdentity; + + const OWN_ACCOUNT: &str = "444455556666"; + const OTHER_ACCOUNT: &str = "111122223333"; + /// An ID that no account has. The check reads no catalog, so it cannot + /// tell this ID from `OTHER_ACCOUNT`, and the tests need no store. + const MISSING_ACCOUNT: &str = "999999999999"; + + fn admin() -> CallerIdentity { + CallerIdentity::Admin("admin".to_owned()) + } + + fn iam_user() -> CallerIdentity { + CallerIdentity::IamUser { + account_id: OWN_ACCOUNT.to_owned(), + user_name: "bob".to_owned(), + } + } + + /// The status, `Location` header and body of a refusal. + async fn answer(response: Response) -> (StatusCode, Option, Vec) { + let status = response.status(); + let location = response + .headers() + .get(LOCATION) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read response body"); + (status, location, body.to_vec()) + } + + fn refusal(identity: &CallerIdentity, account_id: &str) -> Response { + account_scope(identity, account_id).expect_err("the check let the session in") + } + + #[test] + fn iam_user_may_use_its_own_account() { + assert!(account_scope(&iam_user(), OWN_ACCOUNT).is_ok()); + } + + #[tokio::test] + async fn iam_user_is_redirected_to_the_console_from_another_account() { + let (status, location, body) = answer(refusal(&iam_user(), OTHER_ACCOUNT)).await; + + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!(location.as_deref(), Some("/console")); + assert!(body.is_empty(), "{}", String::from_utf8_lossy(&body)); + } + + #[tokio::test] + async fn another_account_and_a_missing_account_get_the_same_answer() { + let other = answer(refusal(&iam_user(), OTHER_ACCOUNT)).await; + let missing = answer(refusal(&iam_user(), MISSING_ACCOUNT)).await; + + assert_eq!(other, missing); + } + + #[test] + fn iam_user_is_refused_an_id_that_only_resembles_its_own() { + for id in [ + "44445555666", + "4444555566660", + "0444455556666", + " 444455556666", + "444455556666/", + "", + ] { + assert!( + account_scope(&iam_user(), id).is_err(), + "an IAM user of {OWN_ACCOUNT} was let into {id:?}" + ); + } + } + + #[test] + fn admin_may_use_every_account() { + for id in [OWN_ACCOUNT, OTHER_ACCOUNT, MISSING_ACCOUNT] { + assert!(account_scope(&admin(), id).is_ok(), "admin refused {id}"); + } + } +} diff --git a/crates/server/src/console/pages/group_pages.rs b/crates/server/src/console/pages/group_pages.rs index f667f848c..7f417238e 100755 --- a/crates/server/src/console/pages/group_pages.rs +++ b/crates/server/src/console/pages/group_pages.rs @@ -15,7 +15,8 @@ use serde::Deserialize; use crate::console::ConsoleState; use crate::console::html; -use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf, require_session}; +use super::account_scope::require_account_session; +use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf}; /// GET /console/accounts/{id}/groups/new pub async fn new_group_form( @@ -23,7 +24,7 @@ pub async fn new_group_form( headers: HeaderMap, Path(account_id): Path, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -77,7 +78,7 @@ pub async fn create_group( Path(account_id): Path, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -121,7 +122,7 @@ pub async fn group_detail( headers: HeaderMap, Path((account_id, group_name)): Path<(String, String)>, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -244,7 +245,7 @@ pub async fn delete_group( Path((account_id, group_name)): Path<(String, String)>, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -317,7 +318,7 @@ pub async fn add_group_member( Path((account_id, group_name)): Path<(String, String)>, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -372,7 +373,7 @@ pub async fn remove_group_member( Path((account_id, group_name, user_name)): Path<(String, String, String)>, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; diff --git a/crates/server/src/console/pages/mod.rs b/crates/server/src/console/pages/mod.rs index 3de352c49..dc8be747a 100755 --- a/crates/server/src/console/pages/mod.rs +++ b/crates/server/src/console/pages/mod.rs @@ -9,6 +9,7 @@ mod access_key_pages; mod account_pages; +mod account_scope; mod auth_pages; mod cache_pages; mod docs_page; diff --git a/crates/server/src/console/pages/policy_pages.rs b/crates/server/src/console/pages/policy_pages.rs index 54c3e23d8..db0a94e15 100755 --- a/crates/server/src/console/pages/policy_pages.rs +++ b/crates/server/src/console/pages/policy_pages.rs @@ -14,7 +14,8 @@ use serde::Deserialize; use crate::console::ConsoleState; use crate::console::html; -use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf, require_session}; +use super::account_scope::require_account_session; +use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf}; // ── User policies ────────────────────────────────────────────────────── @@ -190,7 +191,7 @@ async fn policy_form( principal_type: &str, principal_name: &str, ) -> Response { - let session = match require_session(headers, state).await { + let session = match require_account_session(headers, state, account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -256,7 +257,7 @@ async fn put_policy( form: PolicyForm, redirect_url: &str, ) -> Response { - let session = match require_session(headers, state).await { + let session = match require_account_session(headers, state, account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -351,7 +352,7 @@ async fn delete_policy( redirect_url: &str, csrf_token: &str, ) -> Response { - let session = match require_session(headers, state).await { + let session = match require_account_session(headers, state, account_id).await { Ok(s) => s, Err(r) => return r, }; diff --git a/crates/server/src/console/pages/role_pages.rs b/crates/server/src/console/pages/role_pages.rs index f2ffe4697..d58e95ad1 100755 --- a/crates/server/src/console/pages/role_pages.rs +++ b/crates/server/src/console/pages/role_pages.rs @@ -15,7 +15,8 @@ use serde::Deserialize; use crate::console::ConsoleState; use crate::console::html; -use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf, require_session}; +use super::account_scope::require_account_session; +use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf}; /// GET /console/accounts/{id}/roles/new pub async fn new_role_form( @@ -23,7 +24,7 @@ pub async fn new_role_form( headers: HeaderMap, Path(account_id): Path, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -87,7 +88,7 @@ pub async fn create_role( Path(account_id): Path, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -170,7 +171,7 @@ pub async fn role_detail( headers: HeaderMap, Path((account_id, role_name)): Path<(String, String)>, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -278,7 +279,7 @@ pub async fn delete_role( Path((account_id, role_name)): Path<(String, String)>, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; diff --git a/crates/server/src/console/pages/user_pages.rs b/crates/server/src/console/pages/user_pages.rs index 96b00a71b..f515c8943 100755 --- a/crates/server/src/console/pages/user_pages.rs +++ b/crates/server/src/console/pages/user_pages.rs @@ -15,7 +15,8 @@ use serde::Deserialize; use crate::console::ConsoleState; use crate::console::html; -use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf, require_session}; +use super::account_scope::require_account_session; +use super::{CsrfOnly, identity_label, is_admin, op_error_message, require_csrf}; /// GET /console/accounts/{id}/users/new pub async fn new_user_form( @@ -23,7 +24,7 @@ pub async fn new_user_form( headers: HeaderMap, Path(account_id): Path, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -80,7 +81,7 @@ pub async fn create_user( Path(account_id): Path, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -183,7 +184,7 @@ pub async fn user_detail( headers: HeaderMap, Path((account_id, user_name)): Path<(String, String)>, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; @@ -320,7 +321,7 @@ pub async fn delete_user( Path((account_id, user_name)): Path<(String, String)>, Form(form): Form, ) -> Response { - let session = match require_session(&headers, &state).await { + let session = match require_account_session(&headers, &state, &account_id).await { Ok(s) => s, Err(r) => return r, }; diff --git a/docs/getting-started.md b/docs/getting-started.md index 8632ad33d..70e7fba2a 100755 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1267,7 +1267,7 @@ Navigate to `https://127.0.0.1:18443/console/` in your browser (adjust the host ### Features -- **Dashboard:** Overview of accounts and admin users. +- **Dashboard:** Overview of accounts and admin users (admin sessions only; an IAM-user session gets a link to its own account). - **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. diff --git a/docs/manuals/01-architecture-guide.md b/docs/manuals/01-architecture-guide.md index bd05b6552..1b3d404b9 100755 --- a/docs/manuals/01-architecture-guide.md +++ b/docs/manuals/01-architecture-guide.md @@ -261,7 +261,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, version info, quick links +- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own account), 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 a0cf56e7e..35f3d3676 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -800,8 +800,8 @@ The management web console is served at `/console/` on the same port as the Dyna ### Features -- **Dashboard**: Account and admin user counts, version info -- **Account management**: Create, view, delete accounts +- **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 diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index f4451a0f5..68b6697cf 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -649,6 +649,16 @@ 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. + +**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. + +No catalog migration is required. + +--- + ### v0.1.15: IAM tag condition keys apply to TagResource, UntagResource and CreateTable **What changed.** `aws:TagKeys` and `aws:RequestTag/` now resolve on the three operations that carry a tag set, as the AWS service authorization reference lists them. `TagResource` and `CreateTable` populate both keys from `Tags`: `aws:RequestTag/` holds the value of the tag named ``, and `aws:TagKeys` lists the tag keys in request order. `UntagResource` populates `aws:TagKeys` from `TagKeys` and leaves `aws:RequestTag/` absent. Every other operation leaves both keys absent, `ListTagsOfResource` included, and so does a request whose tag list is empty. Before this release both keys were absent on every data-plane request, so a statement that named either key did not depend on the tags of a tag operation. diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index bb7a1c75d..f6c5028d9 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -166,6 +166,8 @@ 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. + The management web console (`/console/*`) implements: - **CSRF protection**: Unique tokens generated per session, injected into all forms via JavaScript, validated on every POST handler diff --git a/tests/test_console_account_scope.py b/tests/test_console_account_scope.py new file mode 100644 index 000000000..9a4a50343 --- /dev/null +++ b/tests/test_console_account_scope.py @@ -0,0 +1,453 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Console pages under /console/accounts/{account_id} serve an IAM user its +own account only. + +An admin session may read and change every account. An IAM-user session that +names another account's ID in the path is redirected to /console before the +handler looks up the account in the path, so the answer is the same for an +account that exists and for an ID that no account has, and it names nothing +in the other account. The console home page shows the deployment's account +and admin counts to admin sessions only. + +Account A holds users, a group, a role, inline policies, an access key and a +tag, each named with a random suffix so that no other page shows the name by +chance. Account B holds the same, plus the IAM user whose console session the +tests use. + +Prerequisites match tests/test_cross_account_isolation.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, field +from typing import Callable + +import pytest +import requests + +from management_helpers import ManagementClient + +_CSRF_RE = re.compile(r' 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 account scope 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) -> None: + 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]}" + ) + 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) + + 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 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 Account: + """An account and the IAM entities created in it.""" + + id: str + 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 + + def names(self) -> list[str]: + """Every name in the account that a console page can show.""" + return [ + self.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 _new_account(label: str) -> Account: + """The names of an account that `_populate` creates.""" + suffix = uuid.uuid4().hex[:10] + account_id = f"{uuid.uuid4().int % 10**12:012d}" + return Account( + id=account_id, + name=f"scope-{label}-{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}", + ) + + +def _populate(mgmt: ManagementClient, acct: Account) -> None: + """Create the account with two users, a group with the first user as its + member, a role, an inline policy on each, an access key and a tag on the + first user.""" + steps: list[Callable[[], requests.Response]] = [ + lambda: mgmt.create_account(acct.id, acct.name), + lambda: mgmt.create_user(acct.id, acct.owner), + lambda: mgmt.create_user(acct.id, acct.peer), + lambda: mgmt.create_group(acct.id, acct.group), + lambda: mgmt.add_group_member(acct.id, acct.group, acct.owner), + lambda: mgmt.create_role(acct.id, acct.role, _trust_policy(acct.id)), + lambda: mgmt.put_user_policy(acct.id, acct.owner, acct.user_policy, _POLICY), + lambda: mgmt.put_group_policy(acct.id, acct.group, acct.group_policy, _POLICY), + lambda: mgmt.put_role_policy(acct.id, acct.role, acct.role_policy, _POLICY), + lambda: mgmt.tag_user(acct.id, acct.owner, {acct.tag_key: acct.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(acct.id, acct.owner) + assert resp.status_code == 201, resp.text + acct.owner_key = resp.json()["access_key_id"] + + +def _delete_account(mgmt: ManagementClient, acct: Account, users: list[str]) -> None: + """Best-effort cleanup; a failure here must not hide a test result.""" + mgmt.delete_user_policy(acct.id, acct.owner, acct.user_policy) + mgmt.delete_group_policy(acct.id, acct.group, acct.group_policy) + mgmt.delete_role_policy(acct.id, acct.role, acct.role_policy) + mgmt.remove_group_member(acct.id, acct.group, acct.owner) + mgmt.delete_group(acct.id, acct.group) + mgmt.delete_role(acct.id, acct.role) + for user in users: + keys = mgmt.list_access_keys(acct.id, user) + for key in keys.json() if keys.status_code == 200 else []: + mgmt.delete_access_key(acct.id, user, key["access_key_id"]) + mgmt.delete_user(acct.id, user) + mgmt.delete_account(acct.id) + + +@dataclass +class World: + mgmt: ManagementClient + admin: Console + viewer: Console # the console session of account B's IAM user + viewer_name: str + a: Account + b: Account + missing: str # an account ID that no account has + # The last state read of each account, by account ID (see _assert_unchanged). + states: dict[str, dict[str, object]] = field(default_factory=dict) + + +@pytest.fixture(scope="module") +def world(): + endpoint, admin_user, admin_pass = _require_auth_env() + mgmt = ManagementClient(endpoint, admin_user, admin_pass) + a, b = _new_account("a"), _new_account("b") + viewer_name, viewer_password = f"viewer-{uuid.uuid4().hex[:10]}", uuid.uuid4().hex + created = [(a, [a.owner, a.peer]), (b, [b.owner, b.peer, viewer_name])] + try: + _populate(mgmt, a) + _populate(mgmt, b) + resp = mgmt.create_user(b.id, viewer_name, viewer_password) + assert resp.status_code == 201, resp.text + + accounts = mgmt.list_accounts() + assert accounts.status_code == 200, accounts.text + missing = a.id + while missing in accounts.text: + missing = f"{uuid.uuid4().int % 10**12:012d}" + + admin = Console(endpoint) + admin.login(admin_user, admin_pass) + viewer = Console(endpoint) + viewer.login(f"{b.id}/{viewer_name}", viewer_password) + w = World(mgmt, admin, viewer, viewer_name, a, b, missing) + for acct in (a, b): + w.states[acct.id] = _state(w, acct) + yield w + finally: + for acct, users in created: + _delete_account(mgmt, acct, users) + + +# Console paths under /console/accounts/{account_id} that answer GET. +PAGES: dict[str, Callable[[Account], str]] = { + "account": lambda a: f"/accounts/{a.id}", + "user": lambda a: f"/accounts/{a.id}/users/{a.owner}", + "group": lambda a: f"/accounts/{a.id}/groups/{a.group}", + "role": lambda a: f"/accounts/{a.id}/roles/{a.role}", + "new-user-form": lambda a: f"/accounts/{a.id}/users/new", + "new-group-form": lambda a: f"/accounts/{a.id}/groups/new", + "new-role-form": lambda a: f"/accounts/{a.id}/roles/new", + "user-policy-form": lambda a: f"/accounts/{a.id}/users/{a.owner}/policies/new", + "group-policy-form": lambda a: f"/accounts/{a.id}/groups/{a.group}/policies/new", + "role-policy-form": lambda a: f"/accounts/{a.id}/roles/{a.role}/policies/new", +} + +# Console paths under /console/accounts/{account_id} that answer POST, with a +# complete form for each, so that the handler runs. Each one changes the +# account if it is let through. +FORMS: dict[str, Callable[[Account], tuple[str, dict[str, str]]]] = { + "delete-account": lambda a: (f"/accounts/{a.id}/delete", {}), + "create-user": lambda a: ( + f"/accounts/{a.id}/users/new", {"user_name": f"new-{a.owner}", "password": ""}), + "delete-user": lambda a: (f"/accounts/{a.id}/users/{a.peer}/delete", {}), + "create-access-key": lambda a: ( + f"/accounts/{a.id}/users/{a.owner}/access-keys/new", {}), + "delete-access-key": lambda a: ( + f"/accounts/{a.id}/users/{a.owner}/access-keys/{a.owner_key}/delete", {}), + "create-group": lambda a: ( + f"/accounts/{a.id}/groups/new", {"group_name": f"new-{a.group}"}), + "delete-group": lambda a: (f"/accounts/{a.id}/groups/{a.group}/delete", {}), + "add-group-member": lambda a: ( + f"/accounts/{a.id}/groups/{a.group}/members/add", {"user_name": a.peer}), + "remove-group-member": lambda a: ( + f"/accounts/{a.id}/groups/{a.group}/members/{a.owner}/remove", {}), + "create-role": lambda a: ( + f"/accounts/{a.id}/roles/new", + {"role_name": f"new-{a.role}", "trust_policy": json.dumps(_trust_policy(a.id))}), + "delete-role": lambda a: (f"/accounts/{a.id}/roles/{a.role}/delete", {}), + "put-user-policy": lambda a: ( + f"/accounts/{a.id}/users/{a.owner}/policies/new", + {"policy_name": f"new-{a.user_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-user-policy": lambda a: ( + f"/accounts/{a.id}/users/{a.owner}/policies/{a.user_policy}/delete", {}), + "put-group-policy": lambda a: ( + f"/accounts/{a.id}/groups/{a.group}/policies/new", + {"policy_name": f"new-{a.group_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-group-policy": lambda a: ( + f"/accounts/{a.id}/groups/{a.group}/policies/{a.group_policy}/delete", {}), + "put-role-policy": lambda a: ( + f"/accounts/{a.id}/roles/{a.role}/policies/new", + {"policy_name": f"new-{a.role_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-role-policy": lambda a: ( + f"/accounts/{a.id}/roles/{a.role}/policies/{a.role_policy}/delete", {}), +} + + +def _answer(resp: requests.Response) -> tuple[int, str | None, bytes]: + return resp.status_code, resp.headers.get("location"), resp.content + + +def _state(w: World, acct: Account) -> dict[str, object]: + """What a console POST could change in `acct`, read as admin.""" + reads = { + "accounts": w.mgmt.list_accounts(), + "users": w.mgmt.list_users(acct.id), + "groups": w.mgmt.list_groups(acct.id), + "roles": w.mgmt.list_roles(acct.id), + "owner_keys": w.mgmt.list_access_keys(acct.id, acct.owner), + "user_policies": w.mgmt.list_user_policies(acct.id, acct.owner), + "group_policies": w.mgmt.list_group_policies(acct.id, acct.group), + "role_policies": w.mgmt.list_role_policies(acct.id, acct.role), + } + state: dict[str, object] = {} + for what, resp in reads.items(): + assert resp.status_code == 200, f"{what}: {resp.status_code} {resp.text[:200]}" + state[what] = resp.json() + state["accounts"] = acct.id in json.dumps(state["accounts"]) + page = w.admin.get(f"/accounts/{acct.id}/groups/{acct.group}") + assert page.status_code == 200, page.status_code + state["members"] = sorted(re.findall( + rf'
', page.text)) + return state + + +def _assert_unchanged(w: World, acct: Account) -> None: + """Read `acct`'s state and compare it with the last state read of it. + + The fixture reads each account's state once, before the first form test, + and each form test reads it once after its POST. The new state replaces + the stored one before the comparison, so when a form is let through only + its own test fails. + """ + after = _state(w, acct) + before, w.states[acct.id] = w.states[acct.id], after + assert after == before + + +@pytest.mark.parametrize("page", sorted(PAGES)) +def test_iam_user_gets_no_name_of_another_account(world: World, page: str) -> None: + resp = world.viewer.get(PAGES[page](world.a)) + assert resp.status_code == 303, f"{resp.status_code} {resp.text[:300]}" + assert resp.headers.get("location") == "/console" + for name in world.a.names(): + assert name not in resp.text, f"{page} shows {name}" + assert name not in str(resp.headers), f"{page} headers name {name}" + + +@pytest.mark.parametrize("page", sorted(PAGES)) +def test_missing_account_gets_the_same_answer_as_another_account( + world: World, page: str +) -> None: + path = PAGES[page](world.a) + other = world.viewer.get(path) + missing = world.viewer.get(path.replace(world.a.id, world.missing, 1)) + assert _answer(missing) == _answer(other) + + +@pytest.mark.parametrize("action", sorted(FORMS)) +def test_iam_user_cannot_change_another_account(world: World, action: str) -> None: + path, form = FORMS[action](world.a) + + resp = world.viewer.post(path, form) + missing = world.viewer.post(path.replace(world.a.id, world.missing, 1), form) + + _assert_unchanged(world, world.a) + assert resp.status_code == 303, f"{resp.status_code} {resp.text[:300]}" + assert resp.headers.get("location") == "/console" + assert _answer(missing) == _answer(resp) + + +@pytest.mark.parametrize("action", sorted(FORMS)) +def test_iam_user_cannot_change_its_own_account(world: World, action: str) -> None: + """Every change in an IAM user's own account is for admins, except the + user's own access keys (the access key forms here name another user).""" + path, form = FORMS[action](world.b) + + resp = world.viewer.post(path, form) + + _assert_unchanged(world, world.b) + assert resp.status_code == 303, f"{resp.status_code} {resp.text[:300]}" + + +def test_iam_user_manages_its_own_access_keys(world: World) -> None: + b, user = world.b, world.viewer_name + created = world.viewer.post(f"/accounts/{b.id}/users/{user}/access-keys/new", {}) + assert created.status_code == 200, created.status_code + match = re.search(r'
(AKIA[^<]+)
', created.text) + assert match, created.text[:500] + key_id = match.group(1) + assert key_id in world.mgmt.list_access_keys(b.id, user).text + + deleted = world.viewer.post( + f"/accounts/{b.id}/users/{user}/access-keys/{key_id}/delete", {}) + assert deleted.status_code == 303, deleted.status_code + assert deleted.headers.get("location") == f"/console/accounts/{b.id}/users/{user}" + 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. + 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 + + resp = world.viewer.get(f"/accounts/{b.id}/users/{user}") + assert resp.status_code == 200, resp.status_code + assert "location" not in resp.headers + assert f"

User: {user}

" in resp.text + + +# The names each detail page shows an admin, which the IAM-user tests above +# check are absent. +SHOWN = { + "account": lambda a: [a.name, a.owner, a.peer, a.group, a.role], + "user": lambda a: [a.owner, a.owner_key, a.user_policy, a.group, a.tag_key, a.tag_value], + "group": lambda a: [a.group, a.owner, a.group_policy], + "role": lambda a: [a.role, a.role_policy], +} + + +@pytest.mark.parametrize("page", sorted(SHOWN)) +def test_admin_reads_every_account(world: World, page: str) -> None: + for acct in (world.a, world.b): + resp = world.admin.get(PAGES[page](acct)) + assert resp.status_code == 200, f"{page} {acct.id}: {resp.status_code}" + for name in SHOWN[page](acct): + assert name in resp.text, f"{page} {acct.id} lacks {name}" + + +def test_home_page_shows_deployment_counts_to_admins_only(world: World) -> None: + page = world.viewer.get("") + 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 + for name in world.a.names() + [world.a.id]: + assert name not in page.text, name + + page = world.admin.get("") + assert page.status_code == 200, page.status_code + assert "

Admin Users

" in page.text + assert "

Accounts

" in page.text