Skip to content
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,8 @@ Or use the web console at `https://127.0.0.1:18443/console/`.
# Health check
curl --cacert ~/.extenddb/tls/cert.pem https://127.0.0.1:18443/health

# JSON metrics (DynamoDB CloudWatch-style)
curl --cacert ~/.extenddb/tls/cert.pem https://127.0.0.1:18443/metrics
# JSON metrics (DynamoDB CloudWatch-style), admin credentials required
curl --cacert ~/.extenddb/tls/cert.pem -u admin:<admin-password> https://127.0.0.1:18443/metrics

# Syslog (Linux)
journalctl -t extenddb -f
Expand Down
29 changes: 22 additions & 7 deletions crates/server/src/console/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,23 +163,38 @@ 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,
}

/// The navigation links to the admin-only pages. Each of these pages
/// redirects an IAM-user session to the console dashboard.
const ADMIN_LINKS: &str = concat!(
"\n<a href=\"/console/metrics\">Metrics</a>",
"\n<a href=\"/console/settings\">Settings</a>",
"\n<a href=\"/console/cache\">Cache</a>",
);

/// Build the navigation bar for an authenticated session. The Metrics,
/// Settings and Cache links are left out for IAM users, because those pages
/// are admin only.
pub fn nav_bar(identity: &NavIdentity) -> String {
let admin_links = if identity.is_admin { ADMIN_LINKS } else { "" };
format!(
r#"<nav>
<span class="brand">extenddb Console</span>
<a href="/console">Dashboard</a>
<a href="/console/accounts">Accounts</a>
<a href="/console/metrics">Metrics</a>
<a href="/console/settings">Settings</a>
<a href="/console/cache">Cache</a>
<a href="/console/accounts">Accounts</a>{admin_links}
<span class="spacer"></span>
<span>{identity}</span>
<form class="inline" method="post" action="/console/logout">
<button class="btn btn-sm" type="submit">Logout</button>
</form>
</nav>"#,
identity = escape(identity_label),
identity = escape(&identity.label),
)
}

Expand Down
16 changes: 9 additions & 7 deletions crates/server/src/console/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,18 @@ pub fn router() -> Router<Arc<ConsoleState>> {
.route("/docs/{slug}/pdf", get(pages::docs_pdf))
// Dashboard (handle both with and without trailing slash)
.route("/", get(pages::dashboard))
// Metrics
// Metrics (admin-only; an IAM-user session is redirected to /console)
.route("/metrics", get(pages::metrics_page))
// Metrics data (JSON) — session-authed backend for the dashboard's
// client-side charts. Replaces the browser fetch of the top-level
// `/metrics` (now Basic-auth only, for Prometheus).
// Metrics data (JSON) for the dashboard's client-side charts, for
// admin sessions only; an IAM-user session gets 403. Replaces the
// browser fetch of the top-level `/metrics` (now Basic-auth only, for
// Prometheus).
.route("/metrics-data", get(pages::metrics_data))
// Settings (read-only, admin-only)
// Settings (read-only, admin-only; an IAM-user session is redirected
// to /console)
.route("/settings", get(pages::settings_page))
// Cache (admin-only break-glass invalidation).
// See docs/design/12-auth-authz-cache.md §6.1.
// Cache (admin-only break-glass invalidation; an IAM-user session is
// redirected to /console). See docs/design/12-auth-authz-cache.md §6.1.
.route("/cache", get(pages::cache_page))
.route("/cache/invalidate", post(pages::invalidate_cache))
// Accounts
Expand Down
24 changes: 9 additions & 15 deletions crates/server/src/console/pages/cache_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use std::sync::Arc;

use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::http::HeaderMap;
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;

Expand All @@ -21,7 +21,7 @@ use crate::management::cache_invalidate::{
self, InvalidateRequest, Scope, Selectors, apply as apply_invalidation,
};

use super::{identity_label, is_admin, require_csrf, require_session};
use super::{identity_label, require_admin, require_csrf, require_session};

/// Confirmation token the operator must type for `scope=all`. Mirrors
/// the CLI's `--yes` requirement; intentionally distinctive so the
Expand All @@ -34,12 +34,8 @@ pub async fn cache_page(State(state): State<Arc<ConsoleState>>, headers: HeaderM
Ok(s) => s,
Err(redirect) => return redirect,
};
if !is_admin(&session.identity) {
return (
StatusCode::FORBIDDEN,
"Cache controls are visible to admin users only",
)
.into_response();
if let Err(redirect) = require_admin(&session) {
return redirect;
}

let nav = html::nav_bar(&identity_label(&session.identity));
Expand Down Expand Up @@ -180,20 +176,18 @@ pub async fn invalidate_cache(
Ok(s) => s,
Err(r) => return r,
};
if !is_admin(&session.identity) {
return (
StatusCode::FORBIDDEN,
"Cache controls are visible to admin users only",
)
.into_response();
if let Err(redirect) = require_admin(&session) {
return redirect;
}
if let Err(r) = require_csrf(&form.csrf, &session) {
return r;
}

let admin_name = match &session.identity {
crate::management::CallerIdentity::Admin(n) => n.clone(),
crate::management::CallerIdentity::IamUser { .. } => unreachable!("checked by is_admin"),
crate::management::CallerIdentity::IamUser { .. } => {
unreachable!("checked by require_admin")
}
};

let scope = match parse_scope(&form.scope) {
Expand Down
100 changes: 68 additions & 32 deletions crates/server/src/console/pages/metrics_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
//!
//! Renders a dashboard with line charts (D13), latency drill-down with
//! p50/avg/p99 and operation selector (D14), control/data plane split (D15),
//! availability chart (D16), admin vs user views (D17), and CSV/JSON
//! data export (D19). Charts use inline `<canvas>` — no external deps.
//! availability chart (D16), and CSV/JSON data export (D19). Charts use
//! inline `<canvas>` and no external dependencies.
//!
//! The dashboard and its data feed are for admins only. The `metrics` table
//! has no account column, so the feed holds the table names, index names,
//! operations, request counts and latencies of every account.
//!
//! HTML and JS content live in `metrics_content` to stay under the 500-line
//! file limit.
Expand All @@ -16,12 +20,15 @@ use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use extenddb_storage::management_store::MetricsStore;

use crate::console::ConsoleState;
use crate::console::html;
use crate::management::CallerIdentity;

use super::{identity_label, metrics_content, require_session};
use super::{
SessionData, identity_label, is_admin, metrics_content, require_admin, require_session,
};

/// Data plane operations (reads + writes on items).
const DATA_OPS: &[&str] = &[
Expand All @@ -38,23 +45,26 @@ const DATA_OPS: &[&str] = &[
];

/// GET /console/metrics — metrics dashboard.
///
/// Admin sessions only: an IAM-user session is redirected to the console
/// dashboard.
pub async fn metrics_page(State(state): State<Arc<ConsoleState>>, headers: HeaderMap) -> Response {
let session = match require_session(&headers, &state).await {
Ok(s) => s,
Err(redirect) => return redirect,
};
match require_session(&headers, &state).await {
Ok(session) => render_metrics_page(&session, &state.version_info),
Err(redirect) => redirect,
}
}

/// The dashboard page for `session`, or a redirect to the console dashboard
/// when the session is not an admin's.
fn render_metrics_page(session: &SessionData, version_info: &str) -> Response {
if let Err(redirect) = require_admin(session) {
return redirect;
}

let nav = html::nav_bar(&identity_label(&session.identity));
let breadcrumb = html::breadcrumb(&[("Console", Some("/console")), ("Metrics", None)]);

// D17: For non-admin users, inject their account_id so JS can filter.
let account_filter = match &session.identity {
CallerIdentity::IamUser { account_id, .. } => {
format!("const accountFilter = '{}';", html::escape(account_id))
}
CallerIdentity::Admin(_) => "const accountFilter = null;".to_owned(),
};

// Build the data-ops list for JS to classify control vs data plane.
let data_ops_js: String = DATA_OPS
.iter()
Expand All @@ -65,7 +75,6 @@ pub async fn metrics_page(State(state): State<Arc<ConsoleState>>, headers: Heade
let content = format!(
"{breadcrumb}\n{html}\n<script>\n\
const DATA_OPS = new Set([{data_ops_js}]);\n\
{account_filter}\n\
{js}\n</script>",
html = metrics_content::METRICS_HTML,
js = metrics_content::METRICS_JS,
Expand All @@ -74,39 +83,62 @@ pub async fn metrics_page(State(state): State<Arc<ConsoleState>>, headers: Heade
"Metrics",
&nav,
&content,
Some(&state.version_info),
Some(version_info),
&session.csrf_token,
))
.into_response()
}

/// GET /console/metrics-data — session-authed JSON metrics for the dashboard.
/// GET /console/metrics-data: JSON metrics for the dashboard.
///
/// Returns the same payload as the top-level `/metrics` (per-table
/// CloudWatch-style dimensions), but is gated by a console **session** instead
/// of admin Basic auth, so the browser dashboard can fetch it with its session
/// cookie. Both routes require authentication while preserving the per-table
/// dashboard.
/// Returns the same payload as the top-level `/metrics`: per-table
/// CloudWatch-style dimensions for every account. It authenticates with a
/// console session cookie instead of admin Basic auth, so the browser
/// dashboard can fetch it, and like `/metrics` it serves admins only. An
/// IAM-user session gets HTTP 403 and no metric data.
pub async fn metrics_data(
State(state): State<Arc<ConsoleState>>,
headers: HeaderMap,
axum::extract::Query(params): axum::extract::Query<extenddb_core::metrics::MetricsQuery>,
) -> Response {
// Require a valid console session (same gate as the dashboard page).
if let Err(redirect) = require_session(&headers, &state).await {
return redirect;
match require_session(&headers, &state).await {
Ok(session) => {
metrics_json(
&session.identity,
state.catalog_store.as_ref(),
&state.metrics,
&params,
)
.await
}
Err(redirect) => redirect,
}
}

match crate::metrics_endpoint::query_metrics_from_store(state.catalog_store.as_ref(), &params)
.await
{
/// The metrics payload for `identity`, or HTTP 403 when the caller is not an
/// admin. The refusal is returned before the store or the collector is read.
async fn metrics_json(
identity: &CallerIdentity,
store: &dyn MetricsStore,
collector: &Arc<extenddb_core::metrics::MetricsCollector>,
params: &extenddb_core::metrics::MetricsQuery,
) -> Response {
if !is_admin(identity) {
let body = serde_json::json!({
"__type": "AccessDeniedException",
"message": "Metrics are visible to admin users only",
});
return (StatusCode::FORBIDDEN, axum::Json(body)).into_response();
}

match crate::metrics_endpoint::query_metrics_from_store(store, params).await {
Ok(mut response) => {
// Enrich with in-memory latency segments (not persisted to DB) —
// mirrors the top-level `/metrics` handler.
// Add the in-memory latency segments, which are not persisted to
// the database, as the top-level `/metrics` handler does.
let window = params
.window
.unwrap_or(extenddb_core::metrics::TimeWindow::Last5Minutes);
let seg_metrics = state.metrics.clone();
let seg_metrics = Arc::clone(collector);
response.segments =
tokio::task::spawn_blocking(move || seg_metrics.query_segments(window))
.await
Expand All @@ -122,3 +154,7 @@ pub async fn metrics_data(
}
}
}

#[cfg(test)]
#[path = "metrics_pages_tests.rs"]
mod tests;
Loading
Loading