feat(mcp): persist login audit for OAuth and API key
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m16s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m32s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m33s
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m16s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m32s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m33s
- 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
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1949,7 +1949,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
@@ -1,11 +1,60 @@
|
|||||||
use serde_json::Value;
|
use serde_json::{Value, json};
|
||||||
use sqlx::{Postgres, Transaction};
|
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).
|
/// Return the current OS user as the audit actor (falls back to empty string).
|
||||||
pub fn current_actor() -> String {
|
pub fn current_actor() -> String {
|
||||||
std::env::var("USER").unwrap_or_default()
|
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.
|
/// Write an audit entry within an existing transaction.
|
||||||
pub async fn log_tx(
|
pub async fn log_tx(
|
||||||
tx: &mut Transaction<'_, Postgres>,
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
@@ -35,3 +84,19 @@ pub async fn log_tx(
|
|||||||
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use axum::{
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use secrets_core::audit::log_login;
|
||||||
use secrets_core::service::api_key::validate_api_key;
|
use secrets_core::service::api_key::validate_api_key;
|
||||||
|
|
||||||
/// Injected into request extensions after Bearer token validation.
|
/// Injected into request extensions after Bearer token validation.
|
||||||
@@ -34,6 +35,15 @@ fn log_client_ip(req: &Request) -> Option<String> {
|
|||||||
.map(|c| c.ip().to_string())
|
.map(|c| c.ip().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log_user_agent(req: &Request) -> Option<String> {
|
||||||
|
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.
|
/// Axum middleware that validates Bearer API keys for the /mcp route.
|
||||||
/// Passes all non-MCP paths through without authentication.
|
/// Passes all non-MCP paths through without authentication.
|
||||||
pub async fn bearer_auth_middleware(
|
pub async fn bearer_auth_middleware(
|
||||||
@@ -44,6 +54,7 @@ pub async fn bearer_auth_middleware(
|
|||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
let method = req.method().as_str();
|
let method = req.method().as_str();
|
||||||
let client_ip = log_client_ip(&req);
|
let client_ip = log_client_ip(&req);
|
||||||
|
let user_agent = log_user_agent(&req);
|
||||||
|
|
||||||
// Only authenticate /mcp paths
|
// Only authenticate /mcp paths
|
||||||
if !path.starts_with("/mcp") {
|
if !path.starts_with("/mcp") {
|
||||||
@@ -84,6 +95,15 @@ pub async fn bearer_auth_middleware(
|
|||||||
|
|
||||||
match validate_api_key(&pool, raw_key).await {
|
match validate_api_key(&pool, raw_key).await {
|
||||||
Ok(Some(user_id)) => {
|
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");
|
tracing::debug!(?user_id, "api key authenticated");
|
||||||
let mut req = req;
|
let mut req = req;
|
||||||
req.extensions_mut().insert(AuthUser { user_id });
|
req.extensions_mut().insert(AuthUser { user_id });
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{ConnectInfo, Path, Query, State},
|
||||||
http::{StatusCode, header},
|
http::{HeaderMap, StatusCode, header},
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
@@ -11,6 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use secrets_core::audit::log_login;
|
||||||
use secrets_core::crypto::hex;
|
use secrets_core::crypto::hex;
|
||||||
use secrets_core::service::{
|
use secrets_core::service::{
|
||||||
api_key::{ensure_api_key, regenerate_api_key},
|
api_key::{ensure_api_key, regenerate_api_key},
|
||||||
@@ -62,6 +65,30 @@ async fn current_user_id(session: &Session) -> Option<Uuid> {
|
|||||||
.and_then(|s| Uuid::parse_str(&s).ok())
|
.and_then(|s| Uuid::parse_str(&s).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
|
||||||
|
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<String> {
|
||||||
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn web_router() -> Router<AppState> {
|
pub fn web_router() -> Router<AppState> {
|
||||||
@@ -141,16 +168,28 @@ struct OAuthCallbackQuery {
|
|||||||
|
|
||||||
async fn auth_google_callback(
|
async fn auth_google_callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
connect_info: ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
session: Session,
|
session: Session,
|
||||||
Query(params): Query<OAuthCallbackQuery>,
|
Query(params): Query<OAuthCallbackQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
handle_oauth_callback(&state, &session, params, "google", |s, cfg, code| {
|
let client_ip = request_client_ip(&headers, connect_info);
|
||||||
Box::pin(crate::oauth::google::exchange_code(
|
let user_agent = request_user_agent(&headers);
|
||||||
&s.http_client,
|
handle_oauth_callback(
|
||||||
cfg,
|
&state,
|
||||||
code,
|
&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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +200,8 @@ async fn handle_oauth_callback<F>(
|
|||||||
session: &Session,
|
session: &Session,
|
||||||
params: OAuthCallbackQuery,
|
params: OAuthCallbackQuery,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
|
client_ip: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
exchange_fn: F,
|
exchange_fn: F,
|
||||||
) -> Result<Response, StatusCode>
|
) -> Result<Response, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -274,6 +315,16 @@ where
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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())
|
Ok(Redirect::to("/dashboard").into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,16 +393,28 @@ async fn account_bind_google(
|
|||||||
|
|
||||||
async fn account_bind_google_callback(
|
async fn account_bind_google_callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
connect_info: ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
session: Session,
|
session: Session,
|
||||||
Query(params): Query<OAuthCallbackQuery>,
|
Query(params): Query<OAuthCallbackQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
handle_oauth_callback(&state, &session, params, "google", |s, cfg, code| {
|
let client_ip = request_client_ip(&headers, connect_info);
|
||||||
Box::pin(crate::oauth::google::exchange_code(
|
let user_agent = request_user_agent(&headers);
|
||||||
&s.http_client,
|
handle_oauth_callback(
|
||||||
cfg,
|
&state,
|
||||||
code,
|
&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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user