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
23 changes: 18 additions & 5 deletions crates/server/src/console/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
"\n<a href=\"/console/accounts\">Accounts</a>"
} else {
""
};
format!(
r#"<nav>
<span class="brand">extenddb Console</span>
<a href="/console">Dashboard</a>
<a href="/console/accounts">Accounts</a>
<a href="/console">Dashboard</a>{accounts_link}
<a href="/console/metrics">Metrics</a>
<a href="/console/settings">Settings</a>
<a href="/console/cache">Cache</a>
Expand All @@ -179,7 +192,7 @@ pub fn nav_bar(identity_label: &str) -> String {
<button class="btn btn-sm" type="submit">Logout</button>
</form>
</nav>"#,
identity = escape(identity_label),
identity = escape(&identity.label),
)
}

Expand Down
51 changes: 12 additions & 39 deletions crates/server/src/console/pages/access_key_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,22 @@ 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<Arc<ConsoleState>>,
headers: HeaderMap,
Path((account_id, user_name)): Path<(String, String)>,
Form(form): Form<CsrfOnly>,
) -> 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;
}
Expand All @@ -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
Expand Down Expand Up @@ -120,22 +104,11 @@ 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_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;
}
Expand Down
47 changes: 27 additions & 20 deletions crates/server/src/console/pages/account_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub async fn dashboard(State(state): State<Arc<ConsoleState>>, 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)>,
Expand All @@ -71,13 +71,17 @@ fn render_dashboard(
</div>"#
)
}
CallerIdentity::IamUser { account_id, .. } => {
CallerIdentity::IamUser {
account_id,
user_name,
} => {
let eid = html::escape(account_id);
let eun = html::escape(user_name);
format!(
r#"<div class="card">
<h2 style="margin:0">{eid}</h2>
<p>Your Account</p>
<a href="/console/accounts/{eid}">View &rarr;</a>
<h2 style="margin:0">{eun}</h2>
<p>Your user in account {eid}</p>
<a href="/console/accounts/{eid}/users/{eun}">Access keys &rarr;</a>
</div>"#
)
}
Expand Down Expand Up @@ -106,22 +110,20 @@ pub async fn list_accounts(State(state): State<Arc<ConsoleState>>, 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"<table><thead><tr><th>Account ID</th><th>Name</th><th></th></tr></thead><tbody>",
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 23 additions & 3 deletions crates/server/src/console/pages/account_pages_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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("<p>Accounts</p>"), "{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#"<a href="/console/accounts/444455556666">"#),
body.contains(r#"<a href="/console/accounts/444455556666/users/bob">"#),
"{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</a>"), "{body}");
}

#[tokio::test]
Expand All @@ -82,4 +98,8 @@ async fn admin_dashboard_shows_the_deployment_counts() {
)),
"{body}"
);
assert!(
body.contains("<a href=\"/console\">Dashboard</a>\n<a href=\"/console/accounts\">Accounts</a>\n<a href=\"/console/metrics\">"),
"{body}"
);
}
Loading
Loading