Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions crates/server/src/console/pages/access_key_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -24,7 +25,7 @@ pub async fn create_access_key(
Path((account_id, user_name)): Path<(String, String)>,
Form(form): Form<CsrfOnly>,
) -> 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,
};
Expand Down Expand Up @@ -119,7 +120,7 @@ pub async fn delete_access_key(
Path((account_id, user_name, key_id)): Path<(String, String, String)>,
Form(form): Form<CsrfOnly>,
) -> 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,
};
Expand Down
79 changes: 63 additions & 16 deletions crates/server/src/console/pages/account_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,44 +14,87 @@ 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<Arc<ConsoleState>>, 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#"<h1>Dashboard</h1>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem">
<div class="card">
/// 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#"<div class="card">
<h2 style="margin:0">{account_count}</h2>
<p>Accounts</p>
<a href="/console/accounts">View all &rarr;</a>
</div>
<div class="card">
<h2 style="margin:0">{admin_count}</h2>
<p>Admin Users</p>
</div>
</div>"#
)
}
CallerIdentity::IamUser { account_id, .. } => {
let eid = html::escape(account_id);
format!(
r#"<div class="card">
<h2 style="margin:0">{eid}</h2>
<p>Your Account</p>
<a href="/console/accounts/{eid}">View &rarr;</a>
</div>"#
)
}
};

let content = format!(
r#"<h1>Dashboard</h1>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem">
{cards}
</div>"#
);

Html(html::layout_with_version_csrf(
"Dashboard",
&nav,
&content,
Some(&state.version_info),
Some(version_info),
&session.csrf_token,
))
.into_response()
Expand Down Expand Up @@ -245,7 +288,7 @@ pub async fn account_detail(
headers: HeaderMap,
Path(account_id): Path<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,
};
Expand Down Expand Up @@ -364,7 +407,7 @@ pub async fn delete_account(
Path(account_id): Path<String>,
Form(form): Form<CsrfOnly>,
) -> 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,
};
Expand Down Expand Up @@ -397,3 +440,7 @@ pub async fn delete_account(
}
}
}

#[cfg(test)]
#[path = "account_pages_tests.rs"]
mod tests;
85 changes: 85 additions & 0 deletions crates/server/src/console/pages/account_pages_tests.rs
Original file line number Diff line number Diff line change
@@ -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("<h1>Dashboard</h1>"), "{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("<p>Accounts</p>"), "{body}");
assert!(
body.contains(r#"<a href="/console/accounts/444455556666">"#),
"{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!(
"<h2 style=\"margin:0\">{ACCOUNT_COUNT}</h2>\n<p>Accounts</p>"
)),
"{body}"
);
assert!(
body.contains(&format!(
"<h2 style=\"margin:0\">{ADMIN_COUNT}</h2>\n<p>Admin Users</p>"
)),
"{body}"
);
}
140 changes: 140 additions & 0 deletions crates/server/src/console/pages/account_scope.rs
Original file line number Diff line number Diff line change
@@ -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<ConsoleState>,
account_id: &str,
) -> Result<SessionData, Response> {
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<String>, Vec<u8>) {
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}");
}
}
}
Loading
Loading