diff --git a/AGENTS.md b/AGENTS.md index 97dbf65bd..83c7ac296 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: https://127.0.0.1:18443/metrics # Syslog (Linux) journalctl -t extenddb -f diff --git a/crates/server/src/console/html.rs b/crates/server/src/console/html.rs index f1d93616a..5fea9a745 100755 --- a/crates/server/src/console/html.rs +++ b/crates/server/src/console/html.rs @@ -163,14 +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 Metrics link +/// is left out for IAM users, because the metrics dashboard is admin only. +pub fn nav_bar(identity: &NavIdentity) -> String { + let metrics_link = if identity.is_admin { + "\nMetrics" + } else { + "" + }; format!( r#""#, - identity = escape(identity_label), + identity = escape(&identity.label), ) } diff --git a/crates/server/src/console/mod.rs b/crates/server/src/console/mod.rs index 76cc752c7..feeed82bb 100755 --- a/crates/server/src/console/mod.rs +++ b/crates/server/src/console/mod.rs @@ -70,11 +70,12 @@ pub fn router() -> Router> { .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) .route("/settings", get(pages::settings_page)) diff --git a/crates/server/src/console/pages/metrics_pages.rs b/crates/server/src/console/pages/metrics_pages.rs index 659939b0a..73f9e55f1 100755 --- a/crates/server/src/console/pages/metrics_pages.rs +++ b/crates/server/src/console/pages/metrics_pages.rs @@ -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 `` — no external deps. +//! availability chart (D16), and CSV/JSON data export (D19). Charts use +//! inline `` 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. @@ -15,13 +19,14 @@ use std::sync::Arc; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; -use axum::response::{Html, IntoResponse, Response}; +use axum::response::{Html, IntoResponse, Redirect, 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_session}; /// Data plane operations (reads + writes on items). const DATA_OPS: &[&str] = &[ @@ -38,23 +43,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>, 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 !is_admin(&session.identity) { + return Redirect::to("/console").into_response(); + } 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() @@ -65,7 +73,6 @@ pub async fn metrics_page(State(state): State>, headers: Heade let content = format!( "{breadcrumb}\n{html}\n", html = metrics_content::METRICS_HTML, js = metrics_content::METRICS_JS, @@ -74,39 +81,62 @@ pub async fn metrics_page(State(state): State>, 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>, headers: HeaderMap, axum::extract::Query(params): axum::extract::Query, ) -> 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, + ¶ms, + ) + .await + } + Err(redirect) => redirect, + } +} + +/// 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, + 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(state.catalog_store.as_ref(), ¶ms) - .await - { + 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 @@ -122,3 +152,7 @@ pub async fn metrics_data( } } } + +#[cfg(test)] +#[path = "metrics_pages_tests.rs"] +mod tests; diff --git a/crates/server/src/console/pages/metrics_pages_tests.rs b/crates/server/src/console/pages/metrics_pages_tests.rs new file mode 100644 index 000000000..e9666e821 --- /dev/null +++ b/crates/server/src/console/pages/metrics_pages_tests.rs @@ -0,0 +1,213 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for the admin-only console metrics dashboard and its data feed. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use axum::http::StatusCode; +use axum::http::header::LOCATION; +use axum::response::Response; +use extenddb_core::metrics::{LatencySegments, MetricsCollector, MetricsQuery, TimeWindow}; +use extenddb_storage::management_store::{MetricsRow, MetricsStore, OpResult}; +use futures::future::BoxFuture; + +use super::{metrics_json, render_metrics_page}; +use crate::console::html; +use crate::console::pages::{SessionData, identity_label}; +use crate::management::CallerIdentity; + +/// A table in each of two accounts. The `metrics` table has no account +/// column, so both accounts' rows come back from one store read. +const TABLES: [&str; 2] = ["orders-account-a", "orders-account-b"]; + +/// The index every fake row is recorded against, so a test can check that a +/// response carries no index name. +const INDEX: &str = "orders-by-status"; + +/// Metrics store holding one latency row per table in `TABLES`. It counts +/// reads, so a test can tell whether the handler reached the store. +#[derive(Default)] +struct FakeMetricsStore { + reads: AtomicUsize, +} + +impl MetricsStore for FakeMetricsStore { + fn insert_metrics(&self, _rows: &[MetricsRow]) -> BoxFuture<'_, OpResult<()>> { + Box::pin(async { Ok(()) }) + } + + fn query_metrics( + &self, + _start: time::OffsetDateTime, + _end: time::OffsetDateTime, + _table_name: Option<&str>, + _metric: Option<&str>, + ) -> BoxFuture<'_, OpResult>> { + self.reads.fetch_add(1, Ordering::SeqCst); + let bucket = time::OffsetDateTime::now_utc() - time::Duration::minutes(2); + let rows = TABLES + .iter() + .map(|table| MetricsRow { + bucket, + metric: "SuccessfulRequestLatency".to_owned(), + table_name: Some((*table).to_owned()), + index_name: Some(INDEX.to_owned()), + operation: Some("GetItem".to_owned()), + sum: 1_500.0, + count: 3, + min: 400.0, + max: 600.0, + }) + .collect(); + Box::pin(async move { Ok(rows) }) + } + + fn prune_metrics(&self, _retention: Duration) -> BoxFuture<'_, OpResult<()>> { + Box::pin(async { Ok(()) }) + } +} + +fn admin() -> CallerIdentity { + CallerIdentity::Admin("admin".to_owned()) +} + +fn iam_user() -> CallerIdentity { + CallerIdentity::IamUser { + account_id: "444455556666".to_owned(), + user_name: "bob".to_owned(), + } +} + +fn session(identity: CallerIdentity) -> SessionData { + SessionData { + identity, + csrf_token: "csrf-token".to_owned(), + } +} + +/// A collector holding one in-memory latency segment, for `GetItem`. +fn collector() -> Arc { + let collector = Arc::new(MetricsCollector::new()); + collector.record_segments( + "GetItem", + Some(TABLES[0]), + LatencySegments { + dispatch_us: 500.0, + total_us: 500.0, + ..LatencySegments::default() + }, + ); + collector +} + +fn all_time() -> MetricsQuery { + MetricsQuery { + window: Some(TimeWindow::AllTime), + ..MetricsQuery::default() + } +} + +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_gets_403_and_no_metrics_from_the_data_feed() { + let store = FakeMetricsStore::default(); + + let response = metrics_json(&iam_user(), &store, &collector(), &all_time()).await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = body_text(response).await; + assert!(!body.contains("TableName"), "403 body: {body}"); + for table in TABLES { + assert!(!body.contains(table), "403 body names {table}: {body}"); + } + assert!(!body.contains(INDEX), "403 body names an index: {body}"); + assert!( + !body.contains("GetItem"), + "403 body names an operation: {body}" + ); + assert_eq!( + store.reads.load(Ordering::SeqCst), + 0, + "the store was read for an IAM-user session" + ); +} + +#[tokio::test] +async fn admin_gets_every_accounts_rows_from_the_data_feed() { + let store = FakeMetricsStore::default(); + + let response = metrics_json(&admin(), &store, &collector(), &all_time()).await; + + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_str(&body_text(response).await).expect("JSON body"); + let tables: Vec<&str> = body["metrics"] + .as_array() + .expect("metrics array") + .iter() + .filter_map(|m| m["dimensions"].as_array()) + .flatten() + .filter_map(|d| d["TableName"].as_str()) + .collect(); + for table in TABLES { + assert!( + tables.contains(&table), + "admin feed lacks {table}: {tables:?}" + ); + } + let indexes: Vec<&str> = body["metrics"] + .as_array() + .expect("metrics array") + .iter() + .filter_map(|m| m["dimensions"].as_array()) + .flatten() + .filter_map(|d| d["GlobalSecondaryIndexName"].as_str()) + .collect(); + assert!( + indexes.contains(&INDEX), + "admin feed lacks {INDEX}: {indexes:?}" + ); + assert_eq!(body["segments"][0]["operation"], "GetItem", "{body}"); +} + +#[test] +fn iam_user_is_redirected_from_the_metrics_page() { + let response = render_metrics_page(&session(iam_user()), "test-version"); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let location = response + .headers() + .get(LOCATION) + .and_then(|v| v.to_str().ok()); + assert_eq!(location, Some("/console")); +} + +#[tokio::test] +async fn admin_gets_the_metrics_page() { + let response = render_metrics_page(&session(admin()), "test-version"); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_text(response).await; + assert!(body.contains("

Metrics

"), "{body}"); + assert!(body.contains("/console/metrics-data"), "{body}"); + assert!(!body.contains("accountFilter"), "{body}"); +} + +#[test] +fn nav_bar_links_the_metrics_page_for_admins_only() { + let link = r#"Metrics"#; + + assert!(html::nav_bar(&identity_label(&admin())).contains(link)); + let user_nav = html::nav_bar(&identity_label(&iam_user())); + assert!(!user_nav.contains(link), "{user_nav}"); + assert!(user_nav.contains("444455556666/bob"), "{user_nav}"); +} diff --git a/crates/server/src/console/pages/mod.rs b/crates/server/src/console/pages/mod.rs index 3de352c49..41b27fb78 100755 --- a/crates/server/src/console/pages/mod.rs +++ b/crates/server/src/console/pages/mod.rs @@ -45,6 +45,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. @@ -119,9 +120,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, @@ -129,6 +131,10 @@ fn identity_label(identity: &CallerIdentity) -> String { } => { format!("{account_id}/{user_name}") } + }; + html::NavIdentity { + label, + is_admin: is_admin(identity), } } diff --git a/docs/getting-started.md b/docs/getting-started.md index 8632ad33d..b64ecb509 100755 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1274,6 +1274,7 @@ Navigate to `https://127.0.0.1:18443/console/` in your browser (adjust the host - **Group management:** Create and delete groups, add and remove members. - **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. +- **Metrics dashboard:** Operation metrics for every account, with charts (admin only). Admin users have full access to all management operations. IAM users can view their own details and manage their own access keys. diff --git a/docs/manuals/01-architecture-guide.md b/docs/manuals/01-architecture-guide.md index bd05b6552..802e52793 100755 --- a/docs/manuals/01-architecture-guide.md +++ b/docs/manuals/01-architecture-guide.md @@ -268,7 +268,7 @@ The management web console is a server-rendered HTML interface mounted at `/cons - **Group management**: Create, delete groups; add/remove members - **Role management**: Create, delete roles; view trust policies - **Policy management**: Add, delete inline policies for users, groups, and roles (JSON editor with template) -- **Metrics dashboard**: Real-time and historical operation metrics with interactive charts +- **Metrics dashboard**: Real-time and historical operation metrics for every account, with interactive charts (admin only) - **Settings viewer**: Read-only display of runtime settings and static configuration (admin only) - **Documentation browser**: All project manuals rendered as HTML, with PDF download (`/console/docs`) diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index a0cf56e7e..896cf4156 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -807,6 +807,7 @@ The management web console is served at `/console/` on the same port as the Dyna - **Group management**: Create, delete groups; add/remove members - **Role management**: Create, delete roles; view trust policies - **Policy management**: Add, delete inline policies with JSON editor +- **Metrics dashboard**: Operation metrics for every account, with charts (admin only; an IAM-user session is redirected to the console home page, `/console`) ### Authentication diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index f4451a0f5..db29bc074 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -667,6 +667,16 @@ No catalog migration is required. Before upgrading a deployment that restricts t --- +### v0.1.15: the console metrics dashboard is admin only + +**What changed.** The console metrics dashboard (`/console/metrics`) and its data feed (`/console/metrics-data`) now serve admin sessions only. An IAM-user session that opens the dashboard is redirected to `/console`, the data feed answers it with HTTP 403 and no metric data, and the navigation bar no longer shows IAM users the Metrics link. Before this release any console session could read the feed. The metrics table has no account column, so the feed held the table names, index names, operations, request counts and latencies of every account. The page put an IAM user's account id in a script variable that no script read, and the server sent every row. + +**Who is affected.** IAM users who opened the console's metrics dashboard. They lose the dashboard. Admin sessions get the same dashboard and data as before, and the admin-authenticated `/metrics` endpoint is unchanged. + +No catalog migration is required. + +--- + ## License Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index bb7a1c75d..8f21ecbe7 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -173,6 +173,7 @@ The management web console (`/console/*`) implements: - **Security headers**: X-Content-Type-Options (nosniff), X-Frame-Options (DENY), Referrer-Policy (strict-origin-when-cross-origin) - **HSTS**: Sent automatically (TLS is always enabled) - **Login rate limiting**: Failed login attempts are tracked per user. Excessive failures trigger account lockout. +- **Admin-only metrics**: The metrics dashboard (`/console/metrics`) and its data feed (`/console/metrics-data`) serve admin sessions only. The metrics table has no account column, so the feed carries the table names, index names and traffic of every account. An IAM-user session is redirected from the page and gets HTTP 403 with no metric data from the feed. ## Provisioned Throughput Throttling diff --git a/docs/manuals/13-event-ticketing-demo.md b/docs/manuals/13-event-ticketing-demo.md index ba29418f0..ec03f45d6 100755 --- a/docs/manuals/13-event-ticketing-demo.md +++ b/docs/manuals/13-event-ticketing-demo.md @@ -513,7 +513,7 @@ Verify that extenddb is recording operational metrics: # Request counts and latencies curl -s --cacert ~/.extenddb/tls/cert.pem https://127.0.0.1:**18443**/metrics | python3 -m json.tool -# Management console metrics (open in browser) +# Management console metrics (open in a browser and log in as the admin user) echo "Open: https://127.0.0.1:**18443**/console/metrics" ``` diff --git a/tests/test_console_metrics_admin_only.py b/tests/test_console_metrics_admin_only.py new file mode 100644 index 000000000..d063950db --- /dev/null +++ b/tests/test_console_metrics_admin_only.py @@ -0,0 +1,261 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""The console metrics dashboard and its data feed serve admins only. + +The metrics table behind the dashboard has no account column, so its feed +carries the table names and traffic of every account. An IAM user who logs in +to the console gets neither the page nor the feed. An admin gets both, with +every account's tables. + +Two accounts each create a table and send it traffic. The tests wait until +the metrics flush has written both tables' rows, as seen through the admin's +feed, and then check what an IAM user of the second account receives. + +Prerequisites match tests/test_console_integration.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 os +import time +import uuid +from dataclasses import dataclass +from typing import Any + +import boto3 +import pytest +import requests +from botocore.config import Config as BotoConfig + +from conftest import wait_for_active, wait_for_deleted +from management_helpers import ManagementClient + +# The flush worker persists a data point once it is older than the worker's +# 60-second interval, at the next tick, so a point can take up to two +# intervals to reach the metrics table. +FLUSH_DEADLINE_SECONDS = 180 +POLL_SECONDS = 5 + +# The module fixture waits for the metrics flush. +pytestmark = pytest.mark.slow + + +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 metrics 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://") + + 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]}" + ) + + def get(self, path: str, **params: str) -> requests.Response: + return self.http.get( + f"{self.base_url}/console{path}", + params=params, + allow_redirects=False, + timeout=30, + verify=self.verify, + ) + + +@dataclass +class Account: + account_id: str + user_name: str + password: str + access_key_id: str + ddb: Any + table: str + + +def _ddb_client(endpoint: str, access_key_id: str, secret_access_key: str) -> Any: + kwargs: dict = dict( + service_name="dynamodb", + endpoint_url=endpoint, + aws_access_key_id=access_key_id, + aws_secret_access_key=secret_access_key, + region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"), + config=BotoConfig(retries={"max_attempts": 0}), + ) + if endpoint.startswith("https://"): + kwargs["verify"] = False + return boto3.client(**kwargs) + + +def _create_account(mgmt: ManagementClient, endpoint: str, label: str) -> Account: + """An account with one IAM user (console password, access key, full + DynamoDB access) and one table that has seen reads and writes.""" + account_id = f"{uuid.uuid4().int % 10**12:012d}" + user_name, password = "metrics-viewer", uuid.uuid4().hex + resp = mgmt.create_account(account_id, f"metrics-{label}-{account_id}") + assert resp.status_code == 201, resp.text + resp = mgmt.create_user(account_id, user_name, password) + assert resp.status_code == 201, resp.text + resp = mgmt.create_access_key(account_id, user_name) + assert resp.status_code == 201, resp.text + creds = resp.json() + policy = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}], + } + resp = mgmt.put_user_policy(account_id, user_name, "full", policy) + assert resp.status_code == 204, resp.text + + ddb = _ddb_client(endpoint, creds["access_key_id"], creds["secret_access_key"]) + table = f"metrics-scope-{label}-{uuid.uuid4().hex[:8]}" + ddb.create_table( + TableName=table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(ddb, table) + for i in range(3): + ddb.put_item(TableName=table, Item={"pk": {"S": f"item-{i}"}}) + ddb.get_item(TableName=table, Key={"pk": {"S": f"item-{i}"}}) + return Account(account_id, user_name, password, creds["access_key_id"], ddb, table) + + +def _delete_account(mgmt: ManagementClient, account: Account) -> None: + """Best-effort cleanup; a failure here must not hide a test result.""" + try: + account.ddb.delete_table(TableName=account.table) + wait_for_deleted(account.ddb, account.table) + except Exception: + pass + mgmt.delete_user_policy(account.account_id, account.user_name, "full") + mgmt.delete_access_key(account.account_id, account.user_name, account.access_key_id) + mgmt.delete_user(account.account_id, account.user_name) + mgmt.delete_account(account.account_id) + + +def _table_names(feed: dict) -> set[str]: + names = set() + for metric in feed.get("metrics", []): + for dimension in metric.get("dimensions", []): + if "TableName" in dimension: + names.add(dimension["TableName"]) + return names + + +@dataclass +class Flushed: + admin: Console + user_b: Console + tables: tuple[str, str] + + +@pytest.fixture(scope="module") +def flushed(): + """Two accounts with traffic, persisted by the metrics flush, an admin + console session and a console session for the IAM user of account B.""" + endpoint, admin_user, admin_pass = _require_auth_env() + mgmt = ManagementClient(endpoint, admin_user, admin_pass) + accounts: list[Account] = [] + try: + accounts.append(_create_account(mgmt, endpoint, "a")) + accounts.append(_create_account(mgmt, endpoint, "b")) + account_a, account_b = accounts + admin = Console(endpoint) + admin.login(admin_user, admin_pass) + tables = (account_a.table, account_b.table) + + deadline = time.monotonic() + FLUSH_DEADLINE_SECONDS + seen: set[str] = set() + while True: + resp = admin.get("/metrics-data", window="LastHour") + assert resp.status_code == 200, f"admin feed: {resp.status_code} {resp.text[:200]}" + seen = _table_names(resp.json()) + if set(tables) <= seen or time.monotonic() > deadline: + break + time.sleep(POLL_SECONDS) + assert set(tables) <= seen, ( + f"the metrics flush did not persist {tables} within " + f"{FLUSH_DEADLINE_SECONDS}s; the admin feed holds {sorted(seen)}" + ) + + user_b = Console(endpoint) + user_b.login(f"{account_b.account_id}/{account_b.user_name}", account_b.password) + yield Flushed(admin, user_b, tables) + finally: + for account in accounts: + _delete_account(mgmt, account) + + +# Query strings for the feed, built from the two accounts' table names. +FEED_QUERIES = { + "default": lambda tables: {}, + "last-hour": lambda tables: {"window": "LastHour"}, + "all-time": lambda tables: {"window": "AllTime"}, + "account-a-table": lambda tables: {"table_name": tables[0]}, +} + + +@pytest.mark.parametrize("query", sorted(FEED_QUERIES)) +def test_iam_user_gets_403_and_no_metrics_from_the_feed(flushed: Flushed, query: str) -> None: + params = FEED_QUERIES[query](flushed.tables) + resp = flushed.user_b.get("/metrics-data", **params) + assert resp.status_code == 403, f"{resp.status_code} {resp.text[:300]}" + assert "TableName" not in resp.text, resp.text + for table in flushed.tables: + assert table not in resp.text, resp.text + + +def test_iam_user_is_redirected_from_the_metrics_page(flushed: Flushed) -> None: + resp = flushed.user_b.get("/metrics") + assert resp.status_code == 303, f"{resp.status_code} {resp.text[:300]}" + assert resp.headers.get("location") == "/console" + + +def test_navigation_links_the_metrics_page_for_admins_only(flushed: Flushed) -> None: + link = 'href="/console/metrics"' + user_dashboard = flushed.user_b.get("") + assert user_dashboard.status_code == 200, user_dashboard.status_code + assert link not in user_dashboard.text + admin_dashboard = flushed.admin.get("") + assert admin_dashboard.status_code == 200, admin_dashboard.status_code + assert link in admin_dashboard.text + + +def test_admin_gets_the_page_and_every_accounts_tables(flushed: Flushed) -> None: + page = flushed.admin.get("/metrics") + assert page.status_code == 200, page.status_code + assert "

Metrics

" in page.text + assert "accountFilter" not in page.text + + resp = flushed.admin.get("/metrics-data", window="LastHour") + assert resp.status_code == 200, resp.status_code + seen = _table_names(resp.json()) + assert set(flushed.tables) <= seen, sorted(seen) diff --git a/tests/test_cross_account_isolation.py b/tests/test_cross_account_isolation.py index cb88bc3d6..7d014407d 100755 --- a/tests/test_cross_account_isolation.py +++ b/tests/test_cross_account_isolation.py @@ -583,9 +583,9 @@ def test_console_metrics_data_requires_session(self, auth_env): "unauthenticated /console/metrics-data should redirect to console login" ) - # 2. With a valid console session, the route returns metrics JSON + # 2. With an admin console session, the route returns metrics JSON # (per-table dimensions), gated by the session cookie rather than - # admin Basic auth. + # admin Basic auth. An IAM-user session gets 403. s = requests.Session() # The harness sets REQUESTS_CA_BUNDLE/SSL_CERT_FILE, which overrides # session.verify; disable env trust and pass verify=False per request so