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
102 changes: 98 additions & 4 deletions api/src/api/http/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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<String>,
pub target_client_id: Option<String>,
pub occurred_after: Option<String>,
pub occurred_before: Option<String>,
pub limit: Option<i64>,
}

#[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<String>,
pub target_client_id: Option<String>,
pub metadata_json: Value,
}

impl From<AdminAuditEventRow> 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<AdminAuditEventView>,
}

fn parse_occurred_bound(s: &str, label: &str) -> Result<DateTime<Utc>, 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<AuthState>,
auth: UcanAuth,
Query(query): Query<ListAdminAuditEventsQuery>,
) -> ApiResult<Json<AdminAuditEventsResponse>> {
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<String>,
Expand Down
1 change: 1 addition & 0 deletions api/src/api/http/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
222 changes: 222 additions & 0 deletions api/tests/admin_audit_events_http_test.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

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<Vec<u8>, KeyManagerError> {
Ok(plaintext_bytes.to_vec())
}

async fn decrypt(
&self,
ciphertext_bytes: &[u8],
) -> Result<Zeroizing<Vec<u8>>, 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<Box<dyn KeyManager>> = 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<ListAdminAuditEventsQuery>| {
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<Body> {
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);
}
Loading
Loading