refactor: harden audit filtering and management UI

This commit is contained in:
thakares committed 2026-07-24 17:36:14 +05:30
1 parent a969f9c571
commit 3d14061795
38 files changed
+507 -440

No files matched your search

+3
View File
@@ -56,6 +56,7 @@ pub struct AuditQuery {
pub actor: Option<String>,
pub action: Option<String>,
pub resource_type: Option<String>,
pub resource_id: Option<String>,
pub severity: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
@@ -80,6 +81,7 @@ pub async fn list_audit(
actor_user_id: query.actor,
action: query.action,
resource_type: query.resource_type,
resource_id: query.resource_id,
severity: query.severity,
since: query.since,
until: query.until,
@@ -124,6 +126,7 @@ pub async fn export_audit(
actor_user_id: query.actor,
action: query.action,
resource_type: query.resource_type,
resource_id: query.resource_id,
severity: query.severity,
since: query.since,
until: query.until,
+10 -10
View File
@@ -52,16 +52,6 @@ fn is_static_asset(path: &str) -> bool {
/// Serve a static file from the UI dist dir, or SPA fallback for app routes.
pub async fn serve_ui(uri: Uri) -> Response {
let dist = ui_dist_dir();
if !dist.exists() {
return missing_ui_page().into_response();
}
let path = uri.path().trim_start_matches('/');
if path.starts_with("api/") || path == "health" || path == "version" {
return StatusCode::NOT_FOUND.into_response();
}
// Security Hardening: Reject & sanitize any GET request containing credentials in query string.
if let Some(query) = uri.query() {
let q_lower = query.to_ascii_lowercase();
@@ -84,6 +74,16 @@ pub async fn serve_ui(uri: Uri) -> Response {
}
}
let dist = ui_dist_dir();
if !dist.exists() {
return missing_ui_page().into_response();
}
let path = uri.path().trim_start_matches('/');
if path.starts_with("api/") || path == "health" || path == "version" {
return StatusCode::NOT_FOUND.into_response();
}
// Normalize and reject path traversal
if path.contains("..") {
return StatusCode::BAD_REQUEST.into_response();
+1
View File
@@ -44,6 +44,7 @@ pub struct AuditFilter {
pub actor_user_id: Option<String>,
pub action: Option<String>,
pub resource_type: Option<String>,
pub resource_id: Option<String>,
pub severity: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
+70 -107
View File
@@ -1,9 +1,8 @@
use crate::db::models::{AuditFilter, AuditLog};
use crate::db::repository::traits::AuditRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::{AuditFilter, AuditLog};
pub struct PostgresAuditRepository {
pub pool: PgPool,
}
@@ -31,29 +30,13 @@ impl AuditRepository for PostgresAuditRepository {
user_agent: Option<&str>,
metadata_json: Option<&str>,
) -> Result<AuditLog, sqlx::Error> {
sqlx::query_as::<_, AuditLog>(
r#"
INSERT INTO audit_logs (
id, actor_user_id, target_user_id,
action, resource_type, resource_id,
severity, ip_address, user_agent, metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
"#,
)
.bind(id)
.bind(actor_user_id)
.bind(target_user_id)
.bind(action)
.bind(resource_type)
.bind(resource_id)
.bind(severity)
.bind(ip_address)
.bind(user_agent)
.bind(metadata_json)
.fetch_one(&self.pool)
.await
sqlx::query_as::<_, AuditLog>(r#"
INSERT INTO audit_logs (id, actor_user_id, target_user_id, action, resource_type, resource_id, severity, ip_address, user_agent, metadata_json)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *
"#)
.bind(id).bind(actor_user_id).bind(target_user_id).bind(action).bind(resource_type)
.bind(resource_id).bind(severity).bind(ip_address).bind(user_agent).bind(metadata_json)
.fetch_one(&self.pool).await
}
async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
@@ -64,91 +47,71 @@ impl AuditRepository for PostgresAuditRepository {
}
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
let search_like = filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
sqlx::query_as::<_, AuditLog>(
r#"
SELECT * FROM audit_logs
WHERE ($1::text IS NULL OR actor_user_id = $1)
AND ($2::text IS NULL OR action = $2)
AND ($3::text IS NULL OR resource_type = $3)
AND ($4::text IS NULL OR severity = $4)
AND ($5::text IS NULL OR created_at >= $5)
AND ($6::text IS NULL OR created_at <= $6)
AND (
$7::text IS NULL
OR action LIKE $7 ESCAPE '\'
OR resource_type LIKE $7 ESCAPE '\'
OR resource_id LIKE $7 ESCAPE '\'
OR ip_address LIKE $7 ESCAPE '\'
OR metadata_json LIKE $7 ESCAPE '\'
)
AND (
$8::boolean IS NULL
OR ($8::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical')
OR ($8::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))
)
ORDER BY created_at DESC
LIMIT $9 OFFSET $10
"#,
)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.bind(filter.success)
.bind(filter.limit)
.bind(filter.offset)
.fetch_all(&self.pool)
.await
let search_like = search_like(filter);
sqlx::query_as::<_, AuditLog>(FILTER_LIST_SQL)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.resource_id.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.bind(filter.success)
.bind(filter.limit)
.bind(filter.offset)
.fetch_all(&self.pool)
.await
}
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
let search_like = filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM audit_logs
WHERE ($1::text IS NULL OR actor_user_id = $1)
AND ($2::text IS NULL OR action = $2)
AND ($3::text IS NULL OR resource_type = $3)
AND ($4::text IS NULL OR severity = $4)
AND ($5::text IS NULL OR created_at >= $5)
AND ($6::text IS NULL OR created_at <= $6)
AND (
$7::text IS NULL
OR action LIKE $7 ESCAPE '\'
OR resource_type LIKE $7 ESCAPE '\'
OR resource_id LIKE $7 ESCAPE '\'
OR ip_address LIKE $7 ESCAPE '\'
OR metadata_json LIKE $7 ESCAPE '\'
)
AND (
$8::boolean IS NULL
OR ($8::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical')
OR ($8::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))
)
"#,
)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.bind(filter.success)
.fetch_one(&self.pool)
.await?;
let search_like = search_like(filter);
let row: (i64,) = sqlx::query_as(FILTER_COUNT_SQL)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.resource_id.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.bind(filter.success)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
fn search_like(filter: &AuditFilter) -> Option<String> {
filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")))
}
const FILTER_LIST_SQL: &str = r#"
SELECT * FROM audit_logs
WHERE ($1::text IS NULL OR actor_user_id = $1)
AND ($2::text IS NULL OR action = $2)
AND ($3::text IS NULL OR resource_type = $3)
AND ($4::text IS NULL OR resource_id = $4)
AND ($5::text IS NULL OR severity = $5)
AND ($6::text IS NULL OR created_at >= $6)
AND ($7::text IS NULL OR created_at <= $7)
AND ($8::text IS NULL OR action LIKE $8 ESCAPE '\' OR resource_type LIKE $8 ESCAPE '\' OR resource_id LIKE $8 ESCAPE '\' OR ip_address LIKE $8 ESCAPE '\' OR metadata_json LIKE $8 ESCAPE '\')
AND ($9::boolean IS NULL OR ($9::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR ($9::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')))
ORDER BY created_at DESC LIMIT $10 OFFSET $11
"#;
const FILTER_COUNT_SQL: &str = r#"
SELECT COUNT(*) FROM audit_logs
WHERE ($1::text IS NULL OR actor_user_id = $1)
AND ($2::text IS NULL OR action = $2)
AND ($3::text IS NULL OR resource_type = $3)
AND ($4::text IS NULL OR resource_id = $4)
AND ($5::text IS NULL OR severity = $5)
AND ($6::text IS NULL OR created_at >= $6)
AND ($7::text IS NULL OR created_at <= $7)
AND ($8::text IS NULL OR action LIKE $8 ESCAPE '\' OR resource_type LIKE $8 ESCAPE '\' OR resource_id LIKE $8 ESCAPE '\' OR ip_address LIKE $8 ESCAPE '\' OR metadata_json LIKE $8 ESCAPE '\')
AND ($9::boolean IS NULL OR ($9::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR ($9::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')))
"#;
-11
View File
@@ -98,17 +98,6 @@ impl UsersRepository for PostgresUsersRepository {
Ok(())
}
async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET tenant_id = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
)
.bind(tenant_id)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn reassign_user_tenant_with_audit(
&self,
user_id: &str,
+73 -104
View File
@@ -1,23 +1,21 @@
use crate::db::models::{AuditFilter, AuditLog};
use crate::db::repository::traits::AuditRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::{AuditFilter, AuditLog};
pub struct SqliteAuditRepository {
pub pool: SqlitePool,
}
#[async_trait]
impl AuditRepository for SqliteAuditRepository {
/// Count all audit log entries.
async fn count(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM audit_logs")
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
async fn insert(
&self,
@@ -32,29 +30,13 @@ impl AuditRepository for SqliteAuditRepository {
user_agent: Option<&str>,
metadata_json: Option<&str>,
) -> Result<AuditLog, sqlx::Error> {
sqlx::query_as::<_, AuditLog>(
r#"
INSERT INTO audit_logs (
id, actor_user_id, target_user_id,
action, resource_type, resource_id,
severity, ip_address, user_agent, metadata_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(actor_user_id)
.bind(target_user_id)
.bind(action)
.bind(resource_type)
.bind(resource_id)
.bind(severity)
.bind(ip_address)
.bind(user_agent)
.bind(metadata_json)
.fetch_one(&self.pool)
.await
sqlx::query_as::<_, AuditLog>(r#"
INSERT INTO audit_logs (id, actor_user_id, target_user_id, action, resource_type, resource_id, severity, ip_address, user_agent, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *
"#)
.bind(id).bind(actor_user_id).bind(target_user_id).bind(action).bind(resource_type)
.bind(resource_id).bind(severity).bind(ip_address).bind(user_agent).bind(metadata_json)
.fetch_one(&self.pool).await
}
async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
@@ -65,41 +47,46 @@ impl AuditRepository for SqliteAuditRepository {
}
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
let search_like = filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
list_filtered(&self.pool, filter).await
}
sqlx::query_as::<_, AuditLog>(
r#"
SELECT * FROM audit_logs
WHERE (?1 IS NULL OR actor_user_id = ?1)
AND (?2 IS NULL OR action = ?2)
AND (?3 IS NULL OR resource_type = ?3)
AND (?4 IS NULL OR severity = ?4)
AND (?5 IS NULL OR created_at >= ?5)
AND (?6 IS NULL OR created_at <= ?6)
AND (
?7 IS NULL
OR action LIKE ?7 ESCAPE '\'
OR resource_type LIKE ?7 ESCAPE '\'
OR resource_id LIKE ?7 ESCAPE '\'
OR ip_address LIKE ?7 ESCAPE '\'
OR metadata_json LIKE ?7 ESCAPE '\'
)
AND (
?8 IS NULL
OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical')
OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))
)
ORDER BY created_at DESC
LIMIT ?9 OFFSET ?10
"#,
)
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
let search_like = search_like(filter);
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
let row: (i64,) = sqlx::query_as(FILTER_COUNT_SQL)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.resource_id.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.bind(success_val)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
fn search_like(filter: &AuditFilter) -> Option<String> {
filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")))
}
async fn list_filtered(
pool: &SqlitePool,
filter: &AuditFilter,
) -> Result<Vec<AuditLog>, sqlx::Error> {
let search_like = search_like(filter);
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
sqlx::query_as::<_, AuditLog>(FILTER_LIST_SQL)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.resource_id.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
@@ -107,51 +94,33 @@ impl AuditRepository for SqliteAuditRepository {
.bind(success_val)
.bind(filter.limit)
.bind(filter.offset)
.fetch_all(&self.pool)
.fetch_all(pool)
.await
}
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
let search_like = filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM audit_logs
WHERE (?1 IS NULL OR actor_user_id = ?1)
AND (?2 IS NULL OR action = ?2)
AND (?3 IS NULL OR resource_type = ?3)
AND (?4 IS NULL OR severity = ?4)
AND (?5 IS NULL OR created_at >= ?5)
AND (?6 IS NULL OR created_at <= ?6)
AND (
?7 IS NULL
OR action LIKE ?7 ESCAPE '\'
OR resource_type LIKE ?7 ESCAPE '\'
OR resource_id LIKE ?7 ESCAPE '\'
OR ip_address LIKE ?7 ESCAPE '\'
OR metadata_json LIKE ?7 ESCAPE '\'
)
AND (
?8 IS NULL
OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical')
OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))
)
"#,
)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.bind(success_val)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
const FILTER_LIST_SQL: &str = r#"
SELECT * FROM audit_logs
WHERE (?1 IS NULL OR actor_user_id = ?1)
AND (?2 IS NULL OR action = ?2)
AND (?3 IS NULL OR resource_type = ?3)
AND (?4 IS NULL OR resource_id = ?4)
AND (?5 IS NULL OR severity = ?5)
AND (?6 IS NULL OR created_at >= ?6)
AND (?7 IS NULL OR created_at <= ?7)
AND (?8 IS NULL OR action LIKE ?8 ESCAPE '\' OR resource_type LIKE ?8 ESCAPE '\' OR resource_id LIKE ?8 ESCAPE '\' OR ip_address LIKE ?8 ESCAPE '\' OR metadata_json LIKE ?8 ESCAPE '\')
AND (?9 IS NULL OR (?9 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR (?9 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')))
ORDER BY created_at DESC LIMIT ?10 OFFSET ?11
"#;
const FILTER_COUNT_SQL: &str = r#"
SELECT COUNT(*) FROM audit_logs
WHERE (?1 IS NULL OR actor_user_id = ?1)
AND (?2 IS NULL OR action = ?2)
AND (?3 IS NULL OR resource_type = ?3)
AND (?4 IS NULL OR resource_id = ?4)
AND (?5 IS NULL OR severity = ?5)
AND (?6 IS NULL OR created_at >= ?6)
AND (?7 IS NULL OR created_at <= ?7)
AND (?8 IS NULL OR action LIKE ?8 ESCAPE '\' OR resource_type LIKE ?8 ESCAPE '\' OR resource_id LIKE ?8 ESCAPE '\' OR ip_address LIKE ?8 ESCAPE '\' OR metadata_json LIKE ?8 ESCAPE '\')
AND (?9 IS NULL OR (?9 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR (?9 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')))
"#;
-11
View File
@@ -100,17 +100,6 @@ impl UsersRepository for SqliteUsersRepository {
Ok(())
}
async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET tenant_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(tenant_id)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn reassign_user_tenant_with_audit(
&self,
user_id: &str,
-1
View File
@@ -21,7 +21,6 @@ pub trait UsersRepository: Send + Sync {
password_hash: &str,
) -> Result<User, sqlx::Error>;
async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error>;
async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error>;
async fn reassign_user_tenant_with_audit(
&self,
user_id: &str,