Compare commits
2 Commits
d5c9654370
...
22ef5b6d1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ef5b6d1f | ||
|
|
633bf8c62d |
@@ -5,7 +5,7 @@ use axum::response::Response;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::request_info::REQUEST_INFO;
|
||||
use erp_core::request_info::RequestInfo;
|
||||
use erp_core::types::TenantContext;
|
||||
use erp_core::types::{DataScope, TenantContext};
|
||||
|
||||
use crate::service::token_service::TokenService;
|
||||
|
||||
@@ -63,6 +63,12 @@ pub async fn jwt_auth_middleware_fn(
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
// 查询每个权限的数据范围
|
||||
let permission_data_scopes = match &db {
|
||||
Some(conn) => fetch_permission_data_scopes(claims.sub, claims.tid, conn).await,
|
||||
None => std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
// 提取请求来源信息(IP + User-Agent),用于审计日志
|
||||
let request_info = RequestInfo::from_headers(req.headers());
|
||||
|
||||
@@ -72,6 +78,7 @@ pub async fn jwt_auth_middleware_fn(
|
||||
roles: claims.roles,
|
||||
permissions: claims.permissions,
|
||||
department_ids,
|
||||
permission_data_scopes,
|
||||
};
|
||||
|
||||
// Reconstruct the request with the TenantContext injected into extensions.
|
||||
@@ -105,3 +112,58 @@ async fn fetch_user_department_ids(
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
/// 查询用户每个权限的数据范围(从 role_permissions 表)
|
||||
async fn fetch_permission_data_scopes(
|
||||
user_id: uuid::Uuid,
|
||||
tenant_id: uuid::Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> std::collections::HashMap<String, DataScope> {
|
||||
use sea_orm::ConnectionTrait;
|
||||
|
||||
let sql = r#"
|
||||
SELECT p.code, MIN(
|
||||
CASE rp.data_scope
|
||||
WHEN 'all' THEN 0
|
||||
WHEN 'department_tree' THEN 1
|
||||
WHEN 'department' THEN 2
|
||||
WHEN 'self' THEN 3
|
||||
ELSE 0
|
||||
END
|
||||
) AS scope_rank,
|
||||
MIN(rp.data_scope) AS data_scope
|
||||
FROM user_roles ur
|
||||
JOIN role_permissions rp ON ur.role_id = rp.role_id AND ur.tenant_id = rp.tenant_id
|
||||
JOIN permissions p ON rp.permission_id = p.id
|
||||
WHERE ur.user_id = $1
|
||||
AND ur.tenant_id = $2
|
||||
AND ur.deleted_at IS NULL
|
||||
AND rp.deleted_at IS NULL
|
||||
GROUP BY p.code
|
||||
"#;
|
||||
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[user_id.into(), tenant_id.into()],
|
||||
);
|
||||
|
||||
match db.query_all(stmt).await {
|
||||
Ok(rows) => {
|
||||
let mut scopes = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
if let (Ok(code), Ok(scope)) = (
|
||||
row.try_get_by_index::<String>(0),
|
||||
row.try_get_by_index::<String>(2),
|
||||
) {
|
||||
scopes.insert(code, DataScope::from_str(&scope));
|
||||
}
|
||||
}
|
||||
scopes
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "查询权限数据范围失败,默认全部 All");
|
||||
std::collections::HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::audit::AuditLog;
|
||||
use crate::entity::audit_log;
|
||||
use crate::request_info::RequestInfo;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
use sha2::{Sha256, Digest};
|
||||
use tracing;
|
||||
|
||||
/// 持久化审计日志到 audit_logs 表。
|
||||
@@ -10,6 +11,9 @@ use tracing;
|
||||
///
|
||||
/// 自动从 task_local 读取当前请求的 IP 和 User-Agent,
|
||||
/// 如果 AuditLog 中已有 ip_address/user_agent 则不覆盖。
|
||||
///
|
||||
/// 哈希链:查询同租户最新一条记录的 record_hash 作为 prev_hash,
|
||||
/// 计算 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) 作为 record_hash。
|
||||
pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
// 自动填充请求来源信息(仅当调用方未显式设置时)
|
||||
if log.ip_address.is_none() || log.user_agent.is_none() {
|
||||
@@ -23,6 +27,20 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
}
|
||||
}
|
||||
|
||||
// 查询同租户最新一条记录的 record_hash 作为 prev_hash
|
||||
let prev_hash = audit_log::Entity::find()
|
||||
.filter(audit_log::Column::TenantId.eq(log.tenant_id))
|
||||
.filter(audit_log::Column::RecordHash.is_not_null())
|
||||
.order_by_desc(audit_log::Column::CreatedAt)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|m| m.record_hash);
|
||||
|
||||
// 计算当前记录的 record_hash
|
||||
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
|
||||
|
||||
let model = audit_log::ActiveModel {
|
||||
id: Set(log.id),
|
||||
tenant_id: Set(log.tenant_id),
|
||||
@@ -35,9 +53,83 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
ip_address: Set(log.ip_address),
|
||||
user_agent: Set(log.user_agent),
|
||||
created_at: Set(log.created_at),
|
||||
prev_hash: Set(prev_hash),
|
||||
record_hash: Set(Some(record_hash)),
|
||||
};
|
||||
|
||||
if let Err(e) = model.insert(db).await {
|
||||
tracing::warn!(error = %e, "审计日志写入失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算 record_hash: SHA256(id + action + resource_type + resource_id + created_at + prev_hash)
|
||||
fn compute_record_hash(log: &AuditLog, prev_hash: Option<&str>) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(log.id.to_string().as_bytes());
|
||||
hasher.update(log.action.as_bytes());
|
||||
hasher.update(log.resource_type.as_bytes());
|
||||
hasher.update(
|
||||
log.resource_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default()
|
||||
.as_bytes(),
|
||||
);
|
||||
hasher.update(log.created_at.to_rfc3339().as_bytes());
|
||||
hasher.update(prev_hash.unwrap_or("").as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// 验证审计日志哈希链完整性。
|
||||
///
|
||||
/// 检查指定租户的所有含 record_hash 的日志记录,
|
||||
/// 验证每条记录的 prev_hash 是否等于前一条的 record_hash,
|
||||
/// 以及 record_hash 是否可以重新计算验证。
|
||||
///
|
||||
/// 返回 (总记录数, 断链数)。
|
||||
pub async fn verify_hash_chain(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> Result<(usize, usize), sea_orm::DbErr> {
|
||||
use sea_orm::QueryOrder;
|
||||
|
||||
let records = audit_log::Entity::find()
|
||||
.filter(audit_log::Column::TenantId.eq(tenant_id))
|
||||
.filter(audit_log::Column::RecordHash.is_not_null())
|
||||
.order_by_asc(audit_log::Column::CreatedAt)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let total = records.len();
|
||||
let mut broken = 0;
|
||||
let mut prev: Option<String> = None;
|
||||
|
||||
for record in &records {
|
||||
// 验证 prev_hash 指向正确
|
||||
if prev.as_deref() != record.prev_hash.as_deref() {
|
||||
broken += 1;
|
||||
}
|
||||
|
||||
// 验证 record_hash 可重算
|
||||
let log = AuditLog {
|
||||
id: record.id,
|
||||
tenant_id: record.tenant_id,
|
||||
user_id: record.user_id,
|
||||
action: record.action.clone(),
|
||||
resource_type: record.resource_type.clone(),
|
||||
resource_id: record.resource_id,
|
||||
old_value: record.old_value.clone(),
|
||||
new_value: record.new_value.clone(),
|
||||
ip_address: record.ip_address.clone(),
|
||||
user_agent: record.user_agent.clone(),
|
||||
created_at: record.created_at,
|
||||
};
|
||||
let expected = compute_record_hash(&log, record.prev_hash.as_deref());
|
||||
if Some(expected.as_str()) != record.record_hash.as_deref() {
|
||||
broken += 1;
|
||||
}
|
||||
|
||||
prev = record.record_hash.clone();
|
||||
}
|
||||
|
||||
Ok((total, broken))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct Model {
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
/// 哈希链 — 前一条记录的 record_hash
|
||||
pub prev_hash: Option<String>,
|
||||
/// 当前记录的哈希 SHA256(id + action + resource_type + resource_id + created_at + prev_hash)
|
||||
pub record_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::AppError;
|
||||
use crate::types::TenantContext;
|
||||
use crate::types::{DataScope, TenantContext};
|
||||
|
||||
/// Check whether the `TenantContext` includes the specified permission code.
|
||||
///
|
||||
@@ -38,6 +38,16 @@ pub fn require_role(ctx: &TenantContext, role: &str) -> Result<(), AppError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定权限的数据范围。默认 All(向后兼容)。
|
||||
///
|
||||
/// Service 层根据返回值追加对应的查询过滤条件。
|
||||
pub fn get_data_scope(ctx: &TenantContext, permission: &str) -> DataScope {
|
||||
ctx.permission_data_scopes
|
||||
.get(permission)
|
||||
.cloned()
|
||||
.unwrap_or(DataScope::All)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -50,6 +60,7 @@ mod tests {
|
||||
roles: roles.into_iter().map(String::from).collect(),
|
||||
permissions: permissions.into_iter().map(String::from).collect(),
|
||||
department_ids: vec![],
|
||||
permission_data_scopes: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 所有数据库实体的公共字段
|
||||
@@ -114,6 +115,7 @@ mod tests {
|
||||
roles: vec!["admin".to_string()],
|
||||
permissions: vec!["user.read".to_string()],
|
||||
department_ids: vec![],
|
||||
permission_data_scopes: HashMap::new(),
|
||||
};
|
||||
assert_eq!(ctx.roles.len(), 1);
|
||||
assert_eq!(ctx.permissions.len(), 1);
|
||||
@@ -148,6 +150,30 @@ impl<T: Serialize> ApiResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 行级数据权限范围
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataScope {
|
||||
/// 查看所有数据
|
||||
All,
|
||||
/// 仅查看自己创建的数据
|
||||
SelfOnly,
|
||||
/// 仅查看本部门数据
|
||||
Department,
|
||||
/// 查看本部门及下属部门数据
|
||||
DepartmentTree,
|
||||
}
|
||||
|
||||
impl DataScope {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"self" => Self::SelfOnly,
|
||||
"department" => Self::Department,
|
||||
"department_tree" => Self::DepartmentTree,
|
||||
_ => Self::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 租户上下文(中间件注入)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TenantContext {
|
||||
@@ -157,4 +183,6 @@ pub struct TenantContext {
|
||||
pub permissions: Vec<String>,
|
||||
/// 用户所属部门 ID 列表(行级数据权限使用)
|
||||
pub department_ids: Vec<Uuid>,
|
||||
/// 每个权限码对应的数据范围(从 role_permissions.data_scope 加载)
|
||||
pub permission_data_scopes: HashMap<String, DataScope>,
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ mod m20260427_000083_create_follow_up_template;
|
||||
mod m20260427_000084_domain_events_cleanup;
|
||||
mod m20260427_000085_processed_events;
|
||||
mod m20260427_000086_enable_rls_all_tables;
|
||||
mod m20260427_000087_audit_logs_hash_chain;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -179,6 +180,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260427_000084_domain_events_cleanup::Migration),
|
||||
Box::new(m20260427_000085_processed_events::Migration),
|
||||
Box::new(m20260427_000086_enable_rls_all_tables::Migration),
|
||||
Box::new(m20260427_000087_audit_logs_hash_chain::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS prev_hash TEXT",
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS record_hash TEXT",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 为 record_hash 创建索引(用于快速查找最新哈希)
|
||||
conn.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_logs_record_hash
|
||||
ON audit_logs (record_hash) WHERE record_hash IS NOT NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 按 tenant_id + created_at DESC 查找最新哈希的索引
|
||||
conn.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created
|
||||
ON audit_logs (tenant_id, created_at DESC)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
conn.execute_unprepared(
|
||||
"DROP INDEX IF EXISTS idx_audit_logs_tenant_created",
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"DROP INDEX IF EXISTS idx_audit_logs_record_hash",
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE audit_logs DROP COLUMN IF EXISTS record_hash",
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE audit_logs DROP COLUMN IF EXISTS prev_hash",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user