From a44c8ebf0834c2367db7ccfdef9b8d4fd4360f15 Mon Sep 17 00:00:00 2001 From: voson Date: Sat, 21 Mar 2026 09:48:52 +0800 Subject: [PATCH] feat(mcp): persist login audit for OAuth and API key - Add audit::log_login in secrets-core (audit_log detail: user_id, provider, client_ip, user_agent) - Log web Google OAuth success after session established - Log MCP Bearer API key auth success in middleware - Bump secrets-mcp to 0.1.6 (tag 0.1.5 existed) Made-with: Cursor --- Cargo.lock | 2 +- crates/secrets-core/src/audit.rs | 69 ++++++++++++++++++++++- crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/auth.rs | 20 +++++++ crates/secrets-mcp/src/web.rs | 95 ++++++++++++++++++++++++++------ 5 files changed, 168 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61f1cb1..665a8db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1949,7 +1949,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/audit.rs b/crates/secrets-core/src/audit.rs index dc97dfe..f290f2d 100644 --- a/crates/secrets-core/src/audit.rs +++ b/crates/secrets-core/src/audit.rs @@ -1,11 +1,60 @@ -use serde_json::Value; -use sqlx::{Postgres, Transaction}; +use serde_json::{Value, json}; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +pub const ACTION_LOGIN: &str = "login"; +pub const NAMESPACE_AUTH: &str = "auth"; /// Return the current OS user as the audit actor (falls back to empty string). pub fn current_actor() -> String { std::env::var("USER").unwrap_or_default() } +fn login_detail( + user_id: Uuid, + provider: &str, + client_ip: Option<&str>, + user_agent: Option<&str>, +) -> Value { + json!({ + "user_id": user_id, + "provider": provider, + "client_ip": client_ip, + "user_agent": user_agent, + }) +} + +/// Write a login audit entry without requiring an explicit transaction. +pub async fn log_login( + pool: &PgPool, + kind: &str, + provider: &str, + user_id: Uuid, + client_ip: Option<&str>, + user_agent: Option<&str>, +) { + let actor = current_actor(); + let detail = login_detail(user_id, provider, client_ip, user_agent); + let result: Result<_, sqlx::Error> = sqlx::query( + "INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \ + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(ACTION_LOGIN) + .bind(NAMESPACE_AUTH) + .bind(kind) + .bind(provider) + .bind(&detail) + .bind(&actor) + .execute(pool) + .await; + + if let Err(e) = result { + tracing::warn!(error = %e, kind, provider, "failed to write login audit log"); + } else { + tracing::debug!(kind, provider, ?user_id, actor, "login audit logged"); + } +} + /// Write an audit entry within an existing transaction. pub async fn log_tx( tx: &mut Transaction<'_, Postgres>, @@ -35,3 +84,19 @@ pub async fn log_tx( tracing::debug!(action, namespace, kind, name, actor, "audit logged"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn login_detail_includes_expected_fields() { + let user_id = Uuid::nil(); + let detail = login_detail(user_id, "google", Some("127.0.0.1"), Some("Mozilla/5.0")); + + assert_eq!(detail["user_id"], json!(user_id)); + assert_eq!(detail["provider"], "google"); + assert_eq!(detail["client_ip"], "127.0.0.1"); + assert_eq!(detail["user_agent"], "Mozilla/5.0"); + } +} diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index c776749..cadd30f 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.1.5" +version = "0.1.6" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/auth.rs b/crates/secrets-mcp/src/auth.rs index 304f05e..3b0ded5 100644 --- a/crates/secrets-mcp/src/auth.rs +++ b/crates/secrets-mcp/src/auth.rs @@ -9,6 +9,7 @@ use axum::{ use sqlx::PgPool; use uuid::Uuid; +use secrets_core::audit::log_login; use secrets_core::service::api_key::validate_api_key; /// Injected into request extensions after Bearer token validation. @@ -34,6 +35,15 @@ fn log_client_ip(req: &Request) -> Option { .map(|c| c.ip().to_string()) } +fn log_user_agent(req: &Request) -> Option { + req.headers() + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + /// Axum middleware that validates Bearer API keys for the /mcp route. /// Passes all non-MCP paths through without authentication. pub async fn bearer_auth_middleware( @@ -44,6 +54,7 @@ pub async fn bearer_auth_middleware( let path = req.uri().path(); let method = req.method().as_str(); let client_ip = log_client_ip(&req); + let user_agent = log_user_agent(&req); // Only authenticate /mcp paths if !path.starts_with("/mcp") { @@ -84,6 +95,15 @@ pub async fn bearer_auth_middleware( match validate_api_key(&pool, raw_key).await { Ok(Some(user_id)) => { + log_login( + &pool, + "api_key", + "bearer", + user_id, + client_ip.as_deref(), + user_agent.as_deref(), + ) + .await; tracing::debug!(?user_id, "api key authenticated"); let mut req = req; req.extensions_mut().insert(AuthUser { user_id }); diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 57d0ac5..6b5914c 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -1,9 +1,11 @@ use askama::Template; +use std::net::SocketAddr; + use axum::{ Json, Router, body::Body, - extract::{Path, Query, State}, - http::{StatusCode, header}, + extract::{ConnectInfo, Path, Query, State}, + http::{HeaderMap, StatusCode, header}, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; @@ -11,6 +13,7 @@ use serde::{Deserialize, Serialize}; use tower_sessions::Session; use uuid::Uuid; +use secrets_core::audit::log_login; use secrets_core::crypto::hex; use secrets_core::service::{ api_key::{ensure_api_key, regenerate_api_key}, @@ -62,6 +65,30 @@ async fn current_user_id(session: &Session) -> Option { .and_then(|s| Uuid::parse_str(&s).ok()) } +fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo) -> Option { + if let Some(first) = headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + { + let value = first.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + + Some(connect_info.ip().to_string()) +} + +fn request_user_agent(headers: &HeaderMap) -> Option { + headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + // ── Routes ──────────────────────────────────────────────────────────────────── pub fn web_router() -> Router { @@ -141,16 +168,28 @@ struct OAuthCallbackQuery { async fn auth_google_callback( State(state): State, + connect_info: ConnectInfo, + headers: HeaderMap, session: Session, Query(params): Query, ) -> Result { - handle_oauth_callback(&state, &session, params, "google", |s, cfg, code| { - Box::pin(crate::oauth::google::exchange_code( - &s.http_client, - cfg, - code, - )) - }) + let client_ip = request_client_ip(&headers, connect_info); + let user_agent = request_user_agent(&headers); + handle_oauth_callback( + &state, + &session, + params, + "google", + client_ip.as_deref(), + user_agent.as_deref(), + |s, cfg, code| { + Box::pin(crate::oauth::google::exchange_code( + &s.http_client, + cfg, + code, + )) + }, + ) .await } @@ -161,6 +200,8 @@ async fn handle_oauth_callback( session: &Session, params: OAuthCallbackQuery, provider: &str, + client_ip: Option<&str>, + user_agent: Option<&str>, exchange_fn: F, ) -> Result where @@ -274,6 +315,16 @@ where .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + log_login( + &state.pool, + "oauth", + provider, + user.id, + client_ip, + user_agent, + ) + .await; + Ok(Redirect::to("/dashboard").into_response()) } @@ -342,16 +393,28 @@ async fn account_bind_google( async fn account_bind_google_callback( State(state): State, + connect_info: ConnectInfo, + headers: HeaderMap, session: Session, Query(params): Query, ) -> Result { - handle_oauth_callback(&state, &session, params, "google", |s, cfg, code| { - Box::pin(crate::oauth::google::exchange_code( - &s.http_client, - cfg, - code, - )) - }) + let client_ip = request_client_ip(&headers, connect_info); + let user_agent = request_user_agent(&headers); + handle_oauth_callback( + &state, + &session, + params, + "google", + client_ip.as_deref(), + user_agent.as_deref(), + |s, cfg, code| { + Box::pin(crate::oauth::google::exchange_code( + &s.http_client, + cfg, + code, + )) + }, + ) .await }