From cf5c12ee4c0f5d8b265f31e79a6c4bb9caa58d21 Mon Sep 17 00:00:00 2001 From: "Sergey B." Date: Thu, 7 May 2026 11:56:10 +0300 Subject: [PATCH] feat(admin): expose audit log via GET /admin/audit-events and UI --- api/src/api/http/admin.rs | 102 +++- api/src/api/http/routes.rs | 1 + api/tests/admin_audit_events_http_test.rs | 222 +++++++ core/src/repositories/admin_audit_event.rs | 64 +- core/src/repositories/mod.rs | 5 +- .../admin_audit_event_repository_test.rs | 102 +++- web/src/routes/admin/+page.svelte | 43 +- web/src/routes/support-admin/+page.svelte | 25 +- .../support-admin/audit-events/+page.svelte | 563 ++++++++++++++++++ 9 files changed, 1115 insertions(+), 12 deletions(-) create mode 100644 api/tests/admin_audit_events_http_test.rs create mode 100644 web/src/routes/support-admin/audit-events/+page.svelte diff --git a/api/src/api/http/admin.rs b/api/src/api/http/admin.rs index 09b1d6cf..5578dc52 100644 --- a/api/src/api/http/admin.rs +++ b/api/src/api/http/admin.rs @@ -3,7 +3,7 @@ use axum::extract::{Path, Query, State}; use axum::Json; -use chrono::{Duration, Utc}; +use chrono::{DateTime, Duration, Utc}; use nostr_sdk::{FromBech32, Keys}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -12,9 +12,10 @@ use super::routes::AuthState; use crate::api::error::{ApiError, ApiResult}; use crate::api::extractors::UcanAuth; use keycast_core::repositories::{ - test_redirect_pattern, AdminAuditEventRecord, AdminAuditEventRepository, AuthEventRepository, - ClaimTokenRepository, OAuthAuthorizationRepository, RegisteredClient, - RegisteredClientRepository, RepositoryError, UserRepository, + test_redirect_pattern, AdminAuditEventListFilters, AdminAuditEventRecord, + AdminAuditEventRepository, AdminAuditEventRow, AuthEventRepository, ClaimTokenRepository, + OAuthAuthorizationRepository, RegisteredClient, RegisteredClientRepository, RepositoryError, + UserRepository, }; use keycast_core::types::claim_token::generate_claim_token; @@ -973,6 +974,99 @@ pub async fn get_user_lookup( Ok(Json(UserLookupResponse { results, total })) } +// ============================================================================ +// GET /api/admin/audit-events — read-only forensic log (support + full admin) +// ============================================================================ + +#[derive(Debug, Deserialize, Default)] +pub struct ListAdminAuditEventsQuery { + pub action: Option, + pub target_client_id: Option, + pub occurred_after: Option, + pub occurred_before: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct AdminAuditEventView { + pub id: i64, + pub occurred_at: String, + pub tenant_id: i64, + pub actor_pubkey: String, + pub action: String, + pub target_resource_type: String, + pub target_resource_id: Option, + pub target_client_id: Option, + pub metadata_json: Value, +} + +impl From for AdminAuditEventView { + fn from(row: AdminAuditEventRow) -> Self { + Self { + id: row.id, + occurred_at: row.occurred_at.to_rfc3339(), + tenant_id: row.tenant_id, + actor_pubkey: row.actor_pubkey, + action: row.action, + target_resource_type: row.target_resource_type, + target_resource_id: row.target_resource_id, + target_client_id: row.target_client_id, + metadata_json: row.metadata_json, + } + } +} + +#[derive(Debug, Serialize)] +pub struct AdminAuditEventsResponse { + pub events: Vec, +} + +fn parse_occurred_bound(s: &str, label: &str) -> Result, ApiError> { + DateTime::parse_from_rfc3339(s.trim()) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|_| ApiError::bad_request(format!("Invalid {}: expected RFC3339", label))) +} + +/// List admin audit events for the current tenant with optional filters. +pub async fn list_admin_audit_events( + tenant: crate::api::tenant::TenantExtractor, + State(auth_state): State, + auth: UcanAuth, + Query(query): Query, +) -> ApiResult> { + if !is_support_admin(&auth).await { + return Err(ApiError::forbidden("Admin access required")); + } + + let tenant_id = tenant.0.id; + let occurred_after = match query.occurred_after.as_deref() { + None | Some("") => None, + Some(s) => Some(parse_occurred_bound(s, "occurred_after")?), + }; + let occurred_before = match query.occurred_before.as_deref() { + None | Some("") => None, + Some(s) => Some(parse_occurred_bound(s, "occurred_before")?), + }; + + let filters = AdminAuditEventListFilters { + action: query.action, + target_client_id: query.target_client_id, + occurred_after, + occurred_before, + limit: query.limit, + }; + + let repo = AdminAuditEventRepository::new(auth_state.state.db.clone()); + let rows = repo + .list_filtered(tenant_id, filters) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + + Ok(Json(AdminAuditEventsResponse { + events: rows.into_iter().map(Into::into).collect(), + })) +} + #[derive(Debug, Deserialize)] pub struct AuthDebugQuery { pub email: Option, diff --git a/api/src/api/http/routes.rs b/api/src/api/http/routes.rs index 0578bb4b..fc18283b 100644 --- a/api/src/api/http/routes.rs +++ b/api/src/api/http/routes.rs @@ -204,6 +204,7 @@ pub fn api_routes( ) .route("/admin/auth-debug", get(admin::get_auth_debug)) .route("/admin/user-lookup", get(admin::get_user_lookup)) + .route("/admin/audit-events", get(admin::list_admin_audit_events)) .route( "/admin/support-admins", get(admin::list_support_admins).post(admin::add_support_admin), diff --git a/api/tests/admin_audit_events_http_test.rs b/api/tests/admin_audit_events_http_test.rs new file mode 100644 index 00000000..ac6eacf9 --- /dev/null +++ b/api/tests/admin_audit_events_http_test.rs @@ -0,0 +1,222 @@ +// ABOUTME: HTTP-layer auth tests for GET /admin/audit-events +// ABOUTME: Support and full admins allowed; non-admin forbidden + +#![cfg(feature = "integration-tests")] + +mod common; + +use axum::{ + body::Body, + extract::{Query, State}, + http::{Request, StatusCode}, + routing::get, + Router, +}; +use chrono::Utc; +use http_body_util::BodyExt; +use keycast_api::{ + api::{ + extractors::UcanAuth, + http::{ + admin::{list_admin_audit_events, ListAdminAuditEventsQuery}, + routes::AuthState, + }, + tenant::{Tenant, TenantExtractor}, + }, + bcrypt_queue::BcryptQueue, + handlers::http_rpc_handler::new_http_handler_cache, + state::KeycastState, +}; +use keycast_core::{ + encryption::{KeyManager, KeyManagerError}, + secret_pool::SecretPool, +}; +use moka::future::Cache; +use nostr_sdk::Keys; +use sqlx::PgPool; +use std::sync::Arc; +use tower::ServiceExt; +use zeroize::Zeroizing; + +const TENANT_ID: i64 = 1; + +#[derive(Clone)] +struct AuthConfig { + pubkey: String, + admin_role: Option, +} + +impl AuthConfig { + fn full_admin() -> Self { + Self { + pubkey: Keys::generate().public_key().to_hex(), + admin_role: Some("full".to_string()), + } + } + + fn support_admin() -> Self { + Self { + pubkey: Keys::generate().public_key().to_hex(), + admin_role: Some("support".to_string()), + } + } + + fn non_admin() -> Self { + Self { + pubkey: Keys::generate().public_key().to_hex(), + admin_role: None, + } + } + + fn into_auth(self) -> UcanAuth { + UcanAuth { + pubkey: self.pubkey, + admin_role: self.admin_role, + } + } +} + +fn create_test_tenant() -> TenantExtractor { + TenantExtractor(Arc::new(Tenant { + id: TENANT_ID, + domain: "localhost".to_string(), + name: "Test Tenant".to_string(), + settings: None, + created_at: Utc::now(), + updated_at: Utc::now(), + })) +} + +struct TestKeyManager; + +#[async_trait::async_trait] +impl KeyManager for TestKeyManager { + async fn encrypt(&self, plaintext_bytes: &[u8]) -> Result, KeyManagerError> { + Ok(plaintext_bytes.to_vec()) + } + + async fn decrypt( + &self, + ciphertext_bytes: &[u8], + ) -> Result>, KeyManagerError> { + Ok(Zeroizing::new(ciphertext_bytes.to_vec())) + } +} + +fn create_test_auth_state(pool: PgPool) -> AuthState { + let bcrypt_queue = BcryptQueue::new(); + let secret_pool = SecretPool::new(1); + let tenant_cache = Cache::builder().max_capacity(10).build(); + let key_manager: Arc> = Arc::new(Box::new(TestKeyManager)); + + AuthState { + state: Arc::new(KeycastState { + db: pool, + key_manager, + signer_handlers: None, + http_handler_cache: new_http_handler_cache(), + server_keys: Keys::generate(), + tenant_cache, + bcrypt_sender: bcrypt_queue.sender(), + redis: None, + secret_pool: secret_pool.receiver(), + }), + auth_tx: None, + } +} + +fn build_app(auth_state: AuthState, config: AuthConfig) -> Router { + Router::new().route( + "/admin/audit-events", + get(move |Query(query): Query| { + let state = auth_state.clone(); + let cfg = config.clone(); + async move { + list_admin_audit_events( + create_test_tenant(), + State(state), + cfg.into_auth(), + Query(query), + ) + .await + } + }), + ) +} + +fn request(method: &str, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .unwrap() +} + +async fn read_body_string(response: axum::response::Response) -> String { + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +#[tokio::test] +async fn list_audit_events_allows_full_admin() { + let pool = common::setup_test_db().await; + let app = build_app(create_test_auth_state(pool), AuthConfig::full_admin()); + + let response = app + .oneshot(request("GET", "/admin/audit-events")) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = read_body_string(response).await; + let v: serde_json::Value = serde_json::from_str(&body).expect("JSON"); + assert!(v.get("events").is_some()); +} + +#[tokio::test] +async fn list_audit_events_allows_support_admin() { + let pool = common::setup_test_db().await; + let app = build_app(create_test_auth_state(pool), AuthConfig::support_admin()); + + let response = app + .oneshot(request("GET", "/admin/audit-events")) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn list_audit_events_rejects_non_admin() { + let pool = common::setup_test_db().await; + let app = build_app(create_test_auth_state(pool), AuthConfig::non_admin()); + + let response = app + .oneshot(request("GET", "/admin/audit-events")) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + read_body_string(response) + .await + .contains("Admin access required"), + "non-admin should get the support-admin gate message" + ); +} + +#[tokio::test] +async fn list_audit_events_rejects_bad_occurred_after() { + let pool = common::setup_test_db().await; + let app = build_app(create_test_auth_state(pool), AuthConfig::full_admin()); + + let response = app + .oneshot(request( + "GET", + "/admin/audit-events?occurred_after=not-a-date", + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} diff --git a/core/src/repositories/admin_audit_event.rs b/core/src/repositories/admin_audit_event.rs index a54cc742..804ec4ba 100644 --- a/core/src/repositories/admin_audit_event.rs +++ b/core/src/repositories/admin_audit_event.rs @@ -1,7 +1,8 @@ // ABOUTME: Repository for durable admin-action audit events // ABOUTME: Append-only forensic log capturing actor, action, target, and metadata -use chrono::{DateTime, Utc}; +use chrono::DateTime; +use chrono::Utc; use serde_json::Value; use sqlx::{FromRow, PgPool}; @@ -31,6 +32,17 @@ pub struct AdminAuditEventRow { pub metadata_json: Value, } +/// Optional filters for listing audit events within a single tenant. +#[derive(Debug, Clone, Default)] +pub struct AdminAuditEventListFilters { + pub action: Option, + pub target_client_id: Option, + pub occurred_after: Option>, + pub occurred_before: Option>, + /// When `None`, defaults to 50. Clamped to 1..=200 in [`AdminAuditEventRepository::list_filtered`]. + pub limit: Option, +} + #[derive(Debug, Clone)] pub struct AdminAuditEventRepository { pool: PgPool, @@ -105,4 +117,54 @@ impl AdminAuditEventRepository { .await .map_err(Into::into) } + + pub async fn list_filtered( + &self, + tenant_id: i64, + filters: AdminAuditEventListFilters, + ) -> Result, RepositoryError> { + let limit = filters.limit.unwrap_or(50).clamp(1, 200); + let action = filters + .action + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + let target_client_id = filters + .target_client_id + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + + sqlx::query_as::<_, AdminAuditEventRow>( + "SELECT + id, + occurred_at, + tenant_id, + actor_pubkey, + action, + target_resource_type, + target_resource_id, + target_client_id, + metadata_json + FROM admin_audit_events + WHERE tenant_id = $1 + AND ($2::text IS NULL OR action = $2) + AND ($3::text IS NULL OR target_client_id = $3) + AND ($4::timestamptz IS NULL OR occurred_at >= $4) + AND ($5::timestamptz IS NULL OR occurred_at <= $5) + ORDER BY occurred_at DESC, id DESC + LIMIT $6", + ) + .bind(tenant_id) + .bind(action.as_deref()) + .bind(target_client_id.as_deref()) + .bind(filters.occurred_after) + .bind(filters.occurred_before) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(Into::into) + } } diff --git a/core/src/repositories/mod.rs b/core/src/repositories/mod.rs index f96d46c4..6134d96c 100644 --- a/core/src/repositories/mod.rs +++ b/core/src/repositories/mod.rs @@ -17,7 +17,10 @@ mod stored_key; mod team; mod user; -pub use admin_audit_event::{AdminAuditEventRecord, AdminAuditEventRepository, AdminAuditEventRow}; +pub use admin_audit_event::{ + AdminAuditEventListFilters, AdminAuditEventRecord, AdminAuditEventRepository, + AdminAuditEventRow, +}; pub use atproto_oauth_session::{ AtprotoOAuthSession, AtprotoOAuthSessionRepository, CreateAtprotoOAuthSessionParams, IssueAtprotoTokensParams, diff --git a/core/tests/admin_audit_event_repository_test.rs b/core/tests/admin_audit_event_repository_test.rs index ea9914a5..129d8f8c 100644 --- a/core/tests/admin_audit_event_repository_test.rs +++ b/core/tests/admin_audit_event_repository_test.rs @@ -1,4 +1,7 @@ -use keycast_core::repositories::{AdminAuditEventRecord, AdminAuditEventRepository}; +use chrono::{TimeZone, Utc}; +use keycast_core::repositories::{ + AdminAuditEventListFilters, AdminAuditEventRecord, AdminAuditEventRepository, +}; use serde_json::json; use sqlx::PgPool; use uuid::Uuid; @@ -129,3 +132,100 @@ async fn list_recent_returns_newest_first_and_respects_tenant_scope() { assert_eq!(b_list.len(), 1); assert_eq!(b_list[0].target_client_id.as_deref(), Some("client-b")); } + +#[tokio::test] +async fn list_filtered_by_action_client_and_occurred_range() { + let pool = setup_pool().await; + let repo = AdminAuditEventRepository::new(pool.clone()); + + let tenant_id: i64 = 9_300_000 + (Uuid::new_v4().as_u128() as i64).rem_euclid(1_000_000); + let actor = format!("{:0>64}", Uuid::new_v4().simple()); + + let t_early = Utc.with_ymd_and_hms(2024, 1, 10, 12, 0, 0).unwrap(); + let t_mid = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(); + let t_late = Utc.with_ymd_and_hms(2024, 12, 1, 12, 0, 0).unwrap(); + + for (occurred_at, action, client) in [ + (t_early, "registered_client.create", "client-early"), + (t_mid, "registered_client.update", "client-mid"), + (t_late, "registered_client.delete", "client-mid"), + ] { + sqlx::query( + r#"INSERT INTO admin_audit_events ( + occurred_at, tenant_id, actor_pubkey, action, + target_resource_type, target_resource_id, target_client_id, metadata_json + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"#, + ) + .bind(occurred_at) + .bind(tenant_id) + .bind(&actor) + .bind(action) + .bind("registered_client") + .bind::>(None) + .bind(client) + .bind(json!({})) + .execute(&pool) + .await + .expect("insert audit row"); + } + + let by_action = repo + .list_filtered( + tenant_id, + AdminAuditEventListFilters { + action: Some("registered_client.update".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(by_action.len(), 1); + assert_eq!(by_action[0].action, "registered_client.update"); + + let by_client = repo + .list_filtered( + tenant_id, + AdminAuditEventListFilters { + target_client_id: Some("client-mid".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(by_client.len(), 2); + assert!(by_client + .iter() + .all(|r| r.target_client_id.as_deref() == Some("client-mid"))); + + let t_window_start = Utc.with_ymd_and_hms(2024, 6, 14, 0, 0, 0).unwrap(); + let t_window_end = Utc.with_ymd_and_hms(2024, 6, 16, 23, 59, 59).unwrap(); + + let by_window = repo + .list_filtered( + tenant_id, + AdminAuditEventListFilters { + occurred_after: Some(t_window_start), + occurred_before: Some(t_window_end), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(by_window.len(), 1); + assert_eq!(by_window[0].action, "registered_client.update"); + + let combined = repo + .list_filtered( + tenant_id, + AdminAuditEventListFilters { + action: Some("registered_client.delete".to_string()), + target_client_id: Some("client-mid".to_string()), + limit: Some(10), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(combined.len(), 1); + assert_eq!(combined[0].action, "registered_client.delete"); +} diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 58981604..aed7f91c 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -239,6 +239,10 @@ Registered OAuth Clients + + + Admin audit log + Support Tools @@ -374,10 +378,16 @@ - - - Open Registered Clients - + @@ -730,6 +740,31 @@ cursor: not-allowed; } + .section-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + } + + .btn-secondary { + padding: 0.75rem 1.5rem; + background: transparent; + color: var(--color-divine-text); + border: 1px solid var(--color-divine-border); + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + align-self: flex-start; + } + + .btn-secondary:hover { + border-color: var(--color-divine-green); + color: var(--color-divine-green); + } + /* Documentation styles */ .docs-section { background: var(--color-divine-surface); diff --git a/web/src/routes/support-admin/+page.svelte b/web/src/routes/support-admin/+page.svelte index 0e91baec..5b234bb3 100644 --- a/web/src/routes/support-admin/+page.svelte +++ b/web/src/routes/support-admin/+page.svelte @@ -5,7 +5,7 @@ import { goto } from '$app/navigation'; import Loader from '$lib/components/Loader.svelte'; import InvalidateClaimTokenModal from '$lib/components/InvalidateClaimTokenModal.svelte'; - import { ShieldCheck, Warning, MagnifyingGlass, User, Key, Calendar, Globe, Copy, Check, CheckCircle, XCircle, Link, CaretDown, CaretRight } from 'phosphor-svelte'; + import { ShieldCheck, Warning, MagnifyingGlass, User, Key, Calendar, Globe, Copy, Check, CheckCircle, XCircle, Link, CaretDown, CaretRight, List } from 'phosphor-svelte'; import { nip19 } from 'nostr-tools'; import { toast } from 'svelte-hot-french-toast'; @@ -244,6 +244,10 @@ Support Admin {adminRole === 'full' ? 'Full Admin' : 'Support'} + + + Audit log +
@@ -542,6 +546,25 @@ color: var(--color-divine-purple, #8b5cf6); } + .audit-nav-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + font-weight: 500; + color: var(--color-divine-green, #22c55e); + text-decoration: none; + padding: 0.4rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--color-divine-border); + transition: background 0.15s, border-color 0.15s; + } + + .audit-nav-link:hover { + background: color-mix(in srgb, var(--color-divine-green, #22c55e) 12%, transparent); + border-color: color-mix(in srgb, var(--color-divine-green, #22c55e) 35%, var(--color-divine-border)); + } + .tools-section { margin-bottom: 1.5rem; } diff --git a/web/src/routes/support-admin/audit-events/+page.svelte b/web/src/routes/support-admin/audit-events/+page.svelte new file mode 100644 index 00000000..8ae5e4e6 --- /dev/null +++ b/web/src/routes/support-admin/audit-events/+page.svelte @@ -0,0 +1,563 @@ + + + + Admin audit log - {BRAND.name} + + +{#if status === 'loading'} +
+
Loading...
+
+{:else if status === 'not-admin'} +
+
+ +

Access Denied

+

Admin access is required to view the audit log.

+
+
+{:else} +
+
+ + + Back to Support Admin + +

Admin audit log

+

+ Read-only forensic trail for admin actions on this tenant (e.g. registered OAuth clients). + Updates show before/after snapshots when available. +

+
+ +
+
+
+
+

Filters

+

Scope is always the current tenant. Times use your local timezone; the API receives RFC3339.

+
+
+ +
{ + e.preventDefault(); + fetchEvents(); + }} + > + + + + + + +
+
+ +
+
+
+
+

Events

+

Newest first. Expand a row for full metadata.

+
+
+ + {#if listError} +
+ + {listError} +
+ {/if} + + {#if !isLoading && events.length === 0} +

No events match the current filters.

+ {:else} +
+ + + + + + + + + + + + + {#each events as row (row.id)} + + + + + + + + + {#if expandedId === row.id} + + + + {/if} + {/each} + +
WhenActionActorClientResource
+ + {formatWhen(row.occurred_at)}{row.action}{truncateActor(row.actor_pubkey)}{row.target_client_id ?? '—'} + {row.target_resource_type} + {#if row.target_resource_id} + #{row.target_resource_id} + {/if} +
+ {#if hasBeforeAfter(row.metadata_json)} +
+
+ Before +
{prettyJson(row.metadata_json.before)}
+
+
+ After +
{prettyJson(row.metadata_json.after)}
+
+
+ {:else} + Metadata +
{prettyJson(row.metadata_json)}
+ {/if} +
+
+ {/if} +
+
+{/if} + +