feat(role): role bind

This commit is contained in:
2026-01-31 17:23:56 +08:00
parent 4dc46659c9
commit 41cdbb5b29
30 changed files with 1773 additions and 52 deletions

View File

@@ -3,8 +3,9 @@ use crate::models::{
AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, App, AppStatusChangeRequest,
ApproveAppStatusChangeRequest, CreateAppRequest, CreateRoleRequest, CreateTenantRequest,
CreateUserRequest, ListAppsQuery, LoginRequest, LoginResponse,
RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, Tenant,
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest,
RefreshTokenRequest, RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, Tenant,
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest, Permission, ListPermissionsQuery,
UpdateRoleRequest, RolePermissionsRequest, RoleUsersRequest,
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse,
};
@@ -137,7 +138,9 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
paths(
handlers::auth::register_handler,
handlers::auth::login_handler,
handlers::auth::refresh_handler,
handlers::authorization::my_permissions_handler,
handlers::permission::list_permissions_handler,
handlers::platform::get_tenant_enabled_apps_handler,
handlers::platform::set_tenant_enabled_apps_handler,
handlers::app::create_app_handler,
@@ -156,6 +159,13 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
handlers::tenant::delete_tenant_handler,
handlers::role::create_role_handler,
handlers::role::list_roles_handler,
handlers::role::get_role_handler,
handlers::role::update_role_handler,
handlers::role::delete_role_handler,
handlers::role::grant_role_permissions_handler,
handlers::role::revoke_role_permissions_handler,
handlers::role::grant_role_users_handler,
handlers::role::revoke_role_users_handler,
handlers::user::list_users_handler,
handlers::user::get_user_handler,
handlers::user::update_user_handler,
@@ -174,9 +184,15 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
UpdateUserRequest,
LoginRequest,
LoginResponse,
RefreshTokenRequest,
Permission,
ListPermissionsQuery,
Role,
CreateRoleRequest,
RoleResponse,
UpdateRoleRequest,
RolePermissionsRequest,
RoleUsersRequest,
Tenant,
TenantResponse,
CreateTenantRequest,
@@ -202,6 +218,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
(name = "Tenant", description = "租户:创建/查询/更新/状态/删除"),
(name = "User", description = "用户:查询/列表/更新/删除(需权限)"),
(name = "Role", description = "角色:创建/列表(需权限)"),
(name = "Permission", description = "权限:列表与检索(需权限)"),
(name = "Me", description = "当前用户:权限自查等"),
(name = "App", description = "应用:应用注册表与生命周期管理(平台级)"),
(name = "Policy", description = "策略预留ABAC/策略引擎后续扩展)")

View File

@@ -1,6 +1,8 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, UserResponse};
use crate::models::{
CreateUserRequest, LoginRequest, LoginResponse, RefreshTokenRequest, UserResponse,
};
use axum::{Json, extract::State};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
@@ -67,3 +69,27 @@ pub async fn login_handler(
Ok(AppResponse::ok(response))
}
/// Refresh access token (rotate refresh token).
/// 刷新访问令牌(同时轮换 refresh_token一次性使用
#[utoipa::path(
post,
path = "/auth/refresh",
tag = "Auth",
request_body = RefreshTokenRequest,
responses(
(status = 200, description = "Token refreshed", body = LoginResponse),
(status = 401, description = "Unauthorized")
)
)]
#[instrument(skip(state, payload))]
pub async fn refresh_handler(
State(state): State<AppState>,
Json(payload): Json<RefreshTokenRequest>,
) -> Result<AppResponse<LoginResponse>, AppError> {
let response = state
.auth_service
.refresh_access_token(payload.refresh_token)
.await?;
Ok(AppResponse::ok(response))
}

View File

@@ -1,13 +1,15 @@
pub mod app;
pub mod auth;
pub mod authorization;
pub mod permission;
pub mod platform;
pub mod role;
pub mod tenant;
pub mod user;
use crate::services::{
AppService, AuthService, AuthorizationService, RoleService, TenantService, UserService,
AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService,
UserService,
};
pub use app::{
@@ -15,10 +17,15 @@ pub use app::{
list_app_status_change_requests_handler, list_apps_handler, reject_app_status_change_handler,
request_app_status_change_handler, update_app_handler,
};
pub use auth::{login_handler, register_handler};
pub use auth::{login_handler, refresh_handler, register_handler};
pub use authorization::my_permissions_handler;
pub use permission::list_permissions_handler;
pub use platform::{get_tenant_enabled_apps_handler, set_tenant_enabled_apps_handler};
pub use role::{create_role_handler, list_roles_handler};
pub use role::{
create_role_handler, delete_role_handler, get_role_handler, grant_role_permissions_handler,
grant_role_users_handler, list_roles_handler, revoke_role_permissions_handler,
revoke_role_users_handler, update_role_handler,
};
pub use tenant::{
create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler,
update_tenant_status_handler,
@@ -38,4 +45,5 @@ pub struct AppState {
pub tenant_service: TenantService,
pub authorization_service: AuthorizationService,
pub app_service: AppService,
pub permission_service: PermissionService,
}

View File

@@ -0,0 +1,48 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use crate::models::{ListPermissionsQuery, Permission};
use axum::extract::{Query, State};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
#[utoipa::path(
get,
path = "/permissions",
tag = "Permission",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Permission list", body = [Permission]),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
ListPermissionsQuery
)
)]
#[instrument(skip(state))]
pub async fn list_permissions_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Query(query): Query<ListPermissionsQuery>,
) -> Result<AppResponse<Vec<Permission>>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:read")
.await?;
let rows = state.permission_service.list_permissions(query).await?;
Ok(AppResponse::ok(rows))
}

View File

@@ -1,10 +1,16 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use crate::models::{CreateRoleRequest, RoleResponse};
use axum::{Json, extract::State};
use crate::models::{
CreateRoleRequest, RolePermissionsRequest, RoleResponse, RoleUsersRequest, UpdateRoleRequest,
};
use axum::{
Json,
extract::{Path, State},
};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
use uuid::Uuid;
#[utoipa::path(
post,
@@ -62,7 +68,10 @@ pub async fn create_role_handler(
.require_permission(tenant_id, user_id, "role:write")
.await?;
let role = state.role_service.create_role(tenant_id, payload).await?;
let role = state
.role_service
.create_role(tenant_id, payload, user_id)
.await?;
Ok(AppResponse::created(RoleResponse {
id: role.id,
name: role.name,
@@ -132,3 +141,331 @@ pub async fn list_roles_handler(
.collect();
Ok(AppResponse::ok(response))
}
#[utoipa::path(
get,
path = "/roles/{id}",
tag = "Role",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "角色详情", body = RoleResponse),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state))]
pub async fn get_role_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
) -> Result<AppResponse<RoleResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:read")
.await?;
let role = state.role_service.get_role(tenant_id, role_id).await?;
Ok(AppResponse::ok(RoleResponse {
id: role.id,
name: role.name,
description: role.description,
}))
}
#[utoipa::path(
patch,
path = "/roles/{id}",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = UpdateRoleRequest,
responses(
(status = 200, description = "角色更新成功", body = RoleResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn update_role_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<UpdateRoleRequest>,
) -> Result<AppResponse<RoleResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:write")
.await?;
let role = state
.role_service
.update_role(tenant_id, role_id, payload, user_id)
.await?;
Ok(AppResponse::ok(RoleResponse {
id: role.id,
name: role.name,
description: role.description,
}))
}
#[utoipa::path(
delete,
path = "/roles/{id}",
tag = "Role",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "角色删除成功"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state))]
pub async fn delete_role_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:write")
.await?;
state
.role_service
.delete_role(tenant_id, role_id, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/roles/{id}/permissions/grant",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = RolePermissionsRequest,
responses(
(status = 200, description = "绑定权限成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn grant_role_permissions_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<RolePermissionsRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:write")
.await?;
state
.role_service
.grant_permissions_to_role(tenant_id, role_id, payload.permission_codes, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/roles/{id}/permissions/revoke",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = RolePermissionsRequest,
responses(
(status = 200, description = "解绑权限成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn revoke_role_permissions_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<RolePermissionsRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:write")
.await?;
state
.role_service
.revoke_permissions_from_role(tenant_id, role_id, payload.permission_codes, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/roles/{id}/users/grant",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = RoleUsersRequest,
responses(
(status = 200, description = "批量授予角色成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn grant_role_users_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<RoleUsersRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "user:write")
.await?;
state
.role_service
.grant_role_to_users(tenant_id, role_id, payload.user_ids, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/roles/{id}/users/revoke",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = RoleUsersRequest,
responses(
(status = 200, description = "批量回收角色成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn revoke_role_users_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<RoleUsersRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "user:write")
.await?;
state
.role_service
.revoke_role_from_users(tenant_id, role_id, payload.user_ids, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}

View File

@@ -16,17 +16,20 @@ use axum::{
use config::AppConfig;
use handlers::{
AppState, approve_app_status_change_handler, create_app_handler, create_role_handler,
create_tenant_handler, delete_app_handler, delete_tenant_handler, delete_user_handler,
get_app_handler, get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler,
list_app_status_change_requests_handler, list_apps_handler, list_roles_handler,
list_user_roles_handler, list_users_handler, login_handler, my_permissions_handler,
register_handler, reject_app_status_change_handler, request_app_status_change_handler,
reset_my_password_handler, reset_user_password_handler, set_tenant_enabled_apps_handler,
set_user_roles_handler, update_app_handler, update_tenant_handler,
create_tenant_handler, delete_app_handler, delete_role_handler, delete_tenant_handler,
delete_user_handler, get_app_handler, get_role_handler, get_tenant_enabled_apps_handler,
get_tenant_handler, get_user_handler, grant_role_permissions_handler, grant_role_users_handler,
list_app_status_change_requests_handler, list_apps_handler, list_permissions_handler,
list_roles_handler, list_user_roles_handler, list_users_handler, login_handler,
my_permissions_handler, refresh_handler, register_handler, reject_app_status_change_handler,
request_app_status_change_handler, reset_my_password_handler, reset_user_password_handler,
revoke_role_permissions_handler, revoke_role_users_handler, set_tenant_enabled_apps_handler,
set_user_roles_handler, update_app_handler, update_role_handler, update_tenant_handler,
update_tenant_status_handler, update_user_handler,
};
use services::{
AppService, AuthService, AuthorizationService, RoleService, TenantService, UserService,
AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService,
UserService,
};
use std::net::SocketAddr;
use utoipa::OpenApi;
@@ -79,6 +82,7 @@ async fn main() {
let tenant_service = TenantService::new(pool.clone());
let authorization_service = AuthorizationService::new(pool.clone());
let app_service = AppService::new(pool.clone());
let permission_service = PermissionService::new(pool.clone());
let state = AppState {
auth_service,
@@ -87,6 +91,7 @@ async fn main() {
tenant_service,
authorization_service,
app_service,
permission_service,
};
// 5. 构建路由
@@ -111,9 +116,16 @@ async fn main() {
.layer(middleware::rate_limit::login_rate_limiter())
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.route(
"/auth/refresh",
post(refresh_handler)
.layer(middleware::rate_limit::login_rate_limiter())
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.route("/me/permissions", get(my_permissions_handler))
.route("/users", get(list_users_handler))
.route("/users/me/password/reset", post(reset_my_password_handler))
.route("/permissions", get(list_permissions_handler))
.route(
"/users/{id}",
get(get_user_handler)
@@ -129,6 +141,22 @@ async fn main() {
get(list_user_roles_handler).put(set_user_roles_handler),
)
.route("/roles", get(list_roles_handler).post(create_role_handler))
.route(
"/roles/{id}",
get(get_role_handler)
.patch(update_role_handler)
.delete(delete_role_handler),
)
.route(
"/roles/{id}/permissions/grant",
post(grant_role_permissions_handler),
)
.route(
"/roles/{id}/permissions/revoke",
post(revoke_role_permissions_handler),
)
.route("/roles/{id}/users/grant", post(grant_role_users_handler))
.route("/roles/{id}/users/revoke", post(revoke_role_users_handler))
.layer(from_fn(middleware::resolve_tenant))
.layer(from_fn(middleware::auth::authenticate))
.layer(from_fn(

View File

@@ -21,6 +21,7 @@ pub async fn authenticate(mut req: Request, next: Next) -> Result<Response, AppE
|| path == "/tenants/register"
|| path == "/auth/register"
|| path == "/auth/login"
|| path == "/auth/refresh"
{
return Ok(next.run(req).await);
}

View File

@@ -14,7 +14,7 @@ pub struct TenantId(pub Uuid);
pub async fn resolve_tenant(mut req: Request, next: Next) -> Result<Response, AppError> {
let path = req.uri().path();
if path.starts_with("/scalar") || path == "/tenants/register" {
if path.starts_with("/scalar") || path == "/tenants/register" || path == "/auth/refresh" {
return Ok(next.run(req).await);
}

View File

@@ -352,3 +352,58 @@ pub struct AdminResetUserPasswordResponse {
#[serde(default)]
pub temporary_password: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct RefreshTokenRequest {
#[schema(default = "", example = "opaque_refresh_token")]
#[serde(default)]
pub refresh_token: String,
}
#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)]
pub struct Permission {
#[serde(default = "default_uuid")]
pub id: Uuid,
#[serde(default)]
pub code: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub resource: Option<String>,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub created_at: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ListPermissionsQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub search: Option<String>,
pub app_code: Option<String>,
pub resource: Option<String>,
pub action: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct UpdateRoleRequest {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct RolePermissionsRequest {
#[serde(default)]
pub permission_codes: Vec<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct RoleUsersRequest {
#[serde(default)]
pub user_ids: Vec<Uuid>,
}

View File

@@ -2,7 +2,9 @@ use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User};
use crate::utils::authz::filter_permissions_by_enabled_apps;
use crate::utils::{hash_password, sign, verify_password};
use common_telemetry::AppError;
use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::Sha256;
use sqlx::PgPool;
use tracing::instrument;
use uuid::Uuid;
@@ -11,6 +13,7 @@ use uuid::Uuid;
pub struct AuthService {
pool: PgPool,
// jwt_secret removed, using RS256 keys
refresh_token_pepper: String,
}
impl AuthService {
@@ -19,7 +22,17 @@ impl AuthService {
/// 说明:
/// - 当前实现使用 RS256 密钥对进行 JWT 签发与校验,因此 `_jwt_secret` 参数仅为兼容保留。
pub fn new(pool: PgPool, _jwt_secret: String) -> Self {
Self { pool }
Self {
pool,
refresh_token_pepper: _jwt_secret,
}
}
fn refresh_token_fingerprint(&self, refresh_token: &str) -> Result<String, AppError> {
let mut mac = Hmac::<Sha256>::new_from_slice(self.refresh_token_pepper.as_bytes())
.map_err(|_| AppError::ConfigError("Invalid JWT_SECRET".into()))?;
mac.update(refresh_token.as_bytes());
Ok(hex::encode(mac.finalize().into_bytes()))
}
// 注册业务
@@ -176,15 +189,17 @@ impl AuthService {
// Hash refresh token for storage
let refresh_token_hash =
hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
let refresh_token_fingerprint = self.refresh_token_fingerprint(&refresh_token)?;
// 5. 存储 Refresh Token (30天过期)
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
sqlx::query(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
"INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at) VALUES ($1, $2, $3, $4)",
)
.bind(user.id)
.bind(refresh_token_hash)
.bind(refresh_token_fingerprint)
.bind(expires_at)
.execute(&self.pool)
.await?;
@@ -244,4 +259,179 @@ impl AuthService {
Ok(())
}
#[instrument(skip(self, refresh_token))]
pub async fn refresh_access_token(
&self,
refresh_token: String,
) -> Result<LoginResponse, AppError> {
if refresh_token.trim().is_empty() {
return Err(AppError::BadRequest("refresh_token is required".into()));
}
let fingerprint = self.refresh_token_fingerprint(refresh_token.trim())?;
let mut tx = self.pool.begin().await?;
let row = sqlx::query_as::<
_,
(
Uuid,
String,
chrono::DateTime<chrono::Utc>,
bool,
Option<String>,
),
>(
r#"
SELECT user_id, token_hash, expires_at, is_revoked, replaced_by_token_hash
FROM refresh_tokens
WHERE token_fingerprint = $1
FOR UPDATE
"#,
)
.bind(&fingerprint)
.fetch_optional(&mut *tx)
.await?;
let Some((user_id, token_hash, expires_at, is_revoked, replaced_by)) = row else {
return Err(AppError::AuthError("Invalid refresh token".into()));
};
if is_revoked {
let msg = if replaced_by.is_some() {
"Refresh token already used"
} else {
"Refresh token revoked"
};
return Err(AppError::AuthError(msg.into()));
}
if chrono::Utc::now() >= expires_at {
sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE token_fingerprint = $1")
.bind(&fingerprint)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Err(AppError::RefreshTokenExpired);
}
if !verify_password(refresh_token.trim(), &token_hash) {
return Err(AppError::AuthError("Invalid refresh token".into()));
}
let tenant_id: Uuid = sqlx::query_scalar("SELECT tenant_id FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
if tenant_id == Uuid::nil() {
return Err(AppError::NotFound("User not found".into()));
}
let mut refresh_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut refresh_bytes);
let new_refresh_token = hex::encode(refresh_bytes);
let new_refresh_token_hash = hash_password(&new_refresh_token)
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
let new_fingerprint = self.refresh_token_fingerprint(&new_refresh_token)?;
sqlx::query(
r#"
UPDATE refresh_tokens
SET is_revoked = TRUE,
replaced_by_token_hash = $1
WHERE token_fingerprint = $2
"#,
)
.bind(&new_fingerprint)
.bind(&fingerprint)
.execute(&mut *tx)
.await?;
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
sqlx::query(
r#"
INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(user_id)
.bind(new_refresh_token_hash)
.bind(&new_fingerprint)
.bind(expires_at)
.execute(&mut *tx)
.await?;
let roles = sqlx::query_scalar::<_, String>(
r#"
SELECT r.name
FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE r.tenant_id = $1 AND ur.user_id = $2
"#,
)
.bind(tenant_id)
.bind(user_id)
.fetch_all(&mut *tx)
.await?;
let permissions = sqlx::query_scalar::<_, String>(
r#"
SELECT DISTINCT p.code
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
JOIN roles r ON r.id = ur.role_id
WHERE r.tenant_id = $1 AND ur.user_id = $2
"#,
)
.bind(tenant_id)
.bind(user_id)
.fetch_all(&mut *tx)
.await?;
let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec<String>, i32)>(
r#"
SELECT enabled_apps, version
FROM tenant_entitlements
WHERE tenant_id = $1
"#,
)
.bind(tenant_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or_else(|| (vec![], 0));
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
let access_token = sign(
user_id,
tenant_id,
roles,
permissions,
enabled_apps,
apps_version,
)?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'auth.token.refresh', 'auth', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(user_id)
.bind(serde_json::json!({}))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(LoginResponse {
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
expires_in: 15 * 60,
})
}
}

View File

@@ -1,4 +1,4 @@
use crate::utils::authz::filter_permissions_by_enabled_apps;
use crate::utils::authz::{filter_permissions_by_enabled_apps, matches_permission};
use common_telemetry::AppError;
use sqlx::PgPool;
use tracing::instrument;
@@ -78,7 +78,10 @@ impl AuthorizationService {
permission_code: &str,
) -> Result<(), AppError> {
let permissions = self.list_permissions_for_user(tenant_id, user_id).await?;
if permissions.iter().any(|p| p == permission_code) {
if permissions
.iter()
.any(|p| matches_permission(p.as_str(), permission_code))
{
Ok(())
} else {
Err(AppError::PermissionDenied(permission_code.to_string()))

View File

@@ -1,13 +1,15 @@
pub mod auth;
pub mod app;
pub mod auth;
pub mod authorization;
pub mod permission;
pub mod role;
pub mod tenant;
pub mod user;
pub use auth::AuthService;
pub use app::AppService;
pub use auth::AuthService;
pub use authorization::AuthorizationService;
pub use permission::PermissionService;
pub use role::RoleService;
pub use tenant::TenantService;
pub use user::UserService;

View File

@@ -0,0 +1,86 @@
use crate::models::{ListPermissionsQuery, Permission};
use common_telemetry::AppError;
use sqlx::PgPool;
use tracing::instrument;
#[derive(Clone)]
pub struct PermissionService {
pool: PgPool,
}
impl PermissionService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
#[instrument(skip(self))]
pub async fn list_permissions(&self, query: ListPermissionsQuery) -> Result<Vec<Permission>, AppError> {
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
if page == 0 || page_size == 0 || page_size > 200 {
return Err(AppError::BadRequest("Invalid pagination parameters".into()));
}
let offset = (page - 1) as i64 * page_size as i64;
let search = query
.search
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let app_code = query
.app_code
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let resource = query
.resource
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let action = query
.action
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let sort_by = query.sort_by.unwrap_or_else(|| "code".to_string());
let sort_order = query.sort_order.unwrap_or_else(|| "asc".to_string());
let sort_by = match sort_by.as_str() {
"code" => "code",
"created_at" => "created_at",
_ => "code",
};
let sort_order = match sort_order.to_ascii_lowercase().as_str() {
"desc" => "DESC",
_ => "ASC",
};
let sql = format!(
r#"
SELECT
id,
code,
description,
resource,
action,
created_at::text as created_at
FROM permissions
WHERE ($1::text IS NULL OR code ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')
AND ($2::text IS NULL OR split_part(code, ':', 1) = $2)
AND ($3::text IS NULL OR COALESCE(resource, '') = $3)
AND ($4::text IS NULL OR COALESCE(action, '') = $4)
ORDER BY {sort_by} {sort_order}
LIMIT $5 OFFSET $6
"#
);
let rows = sqlx::query_as::<_, Permission>(&sql)
.bind(search)
.bind(app_code)
.bind(resource)
.bind(action)
.bind(page_size as i64)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
}

View File

@@ -1,4 +1,4 @@
use crate::models::{CreateRoleRequest, Role};
use crate::models::{CreateRoleRequest, Role, UpdateRoleRequest};
use common_telemetry::AppError;
use sqlx::PgPool;
use tracing::instrument;
@@ -27,20 +27,44 @@ impl RoleService {
&self,
tenant_id: Uuid,
req: CreateRoleRequest,
actor_user_id: Uuid,
) -> Result<Role, AppError> {
let mut tx = self.pool.begin().await?;
let query = r#"
INSERT INTO roles (tenant_id, name, description)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, description
INSERT INTO roles (tenant_id, name, description)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, description
"#;
// Note: 'roles' table needs to be created in DB
sqlx::query_as::<_, Role>(query)
let role = sqlx::query_as::<_, Role>(query)
.bind(tenant_id)
.bind(req.name)
.bind(req.description)
.fetch_one(&self.pool)
.fetch_one(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))
.map_err(|e| {
if let sqlx::Error::Database(db) = &e {
if db.is_unique_violation() {
return AppError::AlreadyExists("Role name already exists".into());
}
}
AppError::DbError(e)
})?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.create', 'role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role.id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(role)
}
#[instrument(skip(self))]
@@ -49,7 +73,7 @@ impl RoleService {
/// 异常:
/// - 数据库查询失败
pub async fn list_roles(&self, tenant_id: Uuid) -> Result<Vec<Role>, AppError> {
let query = "SELECT * FROM roles WHERE tenant_id = $1";
let query = "SELECT id, tenant_id, name, description FROM roles WHERE tenant_id = $1";
sqlx::query_as::<_, Role>(query)
.bind(tenant_id)
.fetch_all(&self.pool)
@@ -57,6 +81,427 @@ impl RoleService {
.map_err(|e| AppError::DbError(e))
}
#[instrument(skip(self))]
pub async fn get_role(&self, tenant_id: Uuid, role_id: Uuid) -> Result<Role, AppError> {
let query =
"SELECT id, tenant_id, name, description FROM roles WHERE tenant_id = $1 AND id = $2";
sqlx::query_as::<_, Role>(query)
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&self.pool)
.await
.map_err(AppError::DbError)?
.ok_or_else(|| AppError::NotFound("Role not found".into()))
}
#[instrument(skip(self, req))]
pub async fn update_role(
&self,
tenant_id: Uuid,
role_id: Uuid,
req: UpdateRoleRequest,
actor_user_id: Uuid,
) -> Result<Role, AppError> {
let is_system: Option<bool> =
sqlx::query_scalar("SELECT is_system FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&self.pool)
.await
.map_err(AppError::DbError)?;
let Some(is_system) = is_system else {
return Err(AppError::NotFound("Role not found".into()));
};
if is_system {
return Err(AppError::PermissionDenied("role:system_immutable".into()));
}
let mut tx = self.pool.begin().await?;
let role = sqlx::query_as::<_, Role>(
r#"
UPDATE roles
SET name = COALESCE($1, name),
description = COALESCE($2, description),
updated_at = NOW()
WHERE tenant_id = $3 AND id = $4
RETURNING id, tenant_id, name, description
"#,
)
.bind(req.name)
.bind(req.description)
.bind(tenant_id)
.bind(role_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
if let sqlx::Error::Database(db) = &e {
if db.is_unique_violation() {
return AppError::AlreadyExists("Role name already exists".into());
}
}
AppError::DbError(e)
})?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.update', 'role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(role)
}
#[instrument(skip(self))]
pub async fn delete_role(
&self,
tenant_id: Uuid,
role_id: Uuid,
actor_user_id: Uuid,
) -> Result<(), AppError> {
let is_system: Option<bool> =
sqlx::query_scalar("SELECT is_system FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&self.pool)
.await
.map_err(AppError::DbError)?;
let Some(is_system) = is_system else {
return Err(AppError::NotFound("Role not found".into()));
};
if is_system {
return Err(AppError::PermissionDenied("role:system_immutable".into()));
}
let mut tx = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.execute(&mut *tx)
.await
.map_err(AppError::DbError)?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Role not found".into()));
}
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.delete', 'role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, permission_codes))]
pub async fn grant_permissions_to_role(
&self,
tenant_id: Uuid,
role_id: Uuid,
permission_codes: Vec<String>,
actor_user_id: Uuid,
) -> Result<(), AppError> {
if permission_codes.is_empty() {
return Ok(());
}
let unique: Vec<String> = {
let mut s = std::collections::HashSet::new();
let mut out = Vec::new();
for c in permission_codes {
let code = c.trim().to_string();
if code.is_empty() {
continue;
}
if s.insert(code.clone()) {
out.push(code);
}
}
out
};
if unique.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await?;
let is_system: Option<bool> = sqlx::query_scalar(
"SELECT is_system FROM roles WHERE tenant_id = $1 AND id = $2 FOR UPDATE",
)
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&mut *tx)
.await?;
let Some(is_system) = is_system else {
return Err(AppError::NotFound("Role not found".into()));
};
if is_system {
return Err(AppError::PermissionDenied("role:system_immutable".into()));
}
let permission_ids: Vec<Uuid> =
sqlx::query_scalar("SELECT id FROM permissions WHERE code = ANY($1)")
.bind(&unique)
.fetch_all(&mut *tx)
.await?;
if permission_ids.len() != unique.len() {
return Err(AppError::BadRequest("Invalid permission_codes".into()));
}
sqlx::query(
r#"
INSERT INTO role_permissions (role_id, permission_id)
SELECT $1, UNNEST($2::uuid[])
ON CONFLICT DO NOTHING
"#,
)
.bind(role_id)
.bind(&permission_ids)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.permissions.grant', 'role_permission', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id, "permission_codes": unique }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, permission_codes))]
pub async fn revoke_permissions_from_role(
&self,
tenant_id: Uuid,
role_id: Uuid,
permission_codes: Vec<String>,
actor_user_id: Uuid,
) -> Result<(), AppError> {
if permission_codes.is_empty() {
return Ok(());
}
let unique: Vec<String> = {
let mut s = std::collections::HashSet::new();
let mut out = Vec::new();
for c in permission_codes {
let code = c.trim().to_string();
if code.is_empty() {
continue;
}
if s.insert(code.clone()) {
out.push(code);
}
}
out
};
if unique.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await?;
let is_system: Option<bool> = sqlx::query_scalar(
"SELECT is_system FROM roles WHERE tenant_id = $1 AND id = $2 FOR UPDATE",
)
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&mut *tx)
.await?;
let Some(is_system) = is_system else {
return Err(AppError::NotFound("Role not found".into()));
};
if is_system {
return Err(AppError::PermissionDenied("role:system_immutable".into()));
}
sqlx::query(
r#"
DELETE FROM role_permissions rp
USING permissions p
WHERE rp.permission_id = p.id
AND rp.role_id = $1
AND p.code = ANY($2)
"#,
)
.bind(role_id)
.bind(&unique)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.permissions.revoke', 'role_permission', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id, "permission_codes": unique }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, user_ids))]
pub async fn grant_role_to_users(
&self,
tenant_id: Uuid,
role_id: Uuid,
user_ids: Vec<Uuid>,
actor_user_id: Uuid,
) -> Result<(), AppError> {
if user_ids.is_empty() {
return Ok(());
}
let unique: Vec<Uuid> = {
let mut s = std::collections::HashSet::new();
let mut out = Vec::new();
for id in user_ids {
if s.insert(id) {
out.push(id);
}
}
out
};
let mut tx = self.pool.begin().await?;
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("Role not found".into()));
}
let count: i64 =
sqlx::query_scalar("SELECT COUNT(1) FROM users WHERE tenant_id = $1 AND id = ANY($2)")
.bind(tenant_id)
.bind(&unique)
.fetch_one(&mut *tx)
.await?;
if count != unique.len() as i64 {
return Err(AppError::BadRequest("Invalid user_ids".into()));
}
sqlx::query(
r#"
INSERT INTO user_roles (user_id, role_id)
SELECT UNNEST($1::uuid[]), $2
ON CONFLICT DO NOTHING
"#,
)
.bind(&unique)
.bind(role_id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.users.grant', 'user_role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id, "user_ids": unique }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, user_ids))]
pub async fn revoke_role_from_users(
&self,
tenant_id: Uuid,
role_id: Uuid,
user_ids: Vec<Uuid>,
actor_user_id: Uuid,
) -> Result<(), AppError> {
if user_ids.is_empty() {
return Ok(());
}
let unique: Vec<Uuid> = {
let mut s = std::collections::HashSet::new();
let mut out = Vec::new();
for id in user_ids {
if s.insert(id) {
out.push(id);
}
}
out
};
let mut tx = self.pool.begin().await?;
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("Role not found".into()));
}
sqlx::query(
r#"
DELETE FROM user_roles ur
USING users u
WHERE ur.user_id = u.id
AND u.tenant_id = $1
AND ur.role_id = $2
AND ur.user_id = ANY($3)
"#,
)
.bind(tenant_id)
.bind(role_id)
.bind(&unique)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.users.revoke', 'user_role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id, "user_ids": unique }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, role_ids))]
pub async fn list_roles_by_ids(
&self,
@@ -115,13 +560,12 @@ impl RoleService {
let mut tx = self.pool.begin().await?;
let exists: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM users WHERE tenant_id = $1 AND id = $2",
)
.bind(tenant_id)
.bind(target_user_id)
.fetch_optional(&mut *tx)
.await?;
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM users WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(target_user_id)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("User not found".into()));
}

View File

@@ -204,23 +204,21 @@ impl UserService {
let mut tx = self.pool.begin().await?;
let updated: i64 = sqlx::query_scalar(
let result = sqlx::query(
r#"
UPDATE users
SET password_hash = $1, updated_at = NOW()
WHERE tenant_id = $2 AND id = $3
RETURNING 1
"#,
)
.bind(&new_hash)
.bind(tenant_id)
.bind(target_user_id)
.fetch_optional(&mut *tx)
.execute(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?
.unwrap_or(0);
.map_err(|e| AppError::DbError(e))?;
if updated == 0 {
if result.rows_affected() == 0 {
return Err(AppError::NotFound("User not found".into()));
}

View File

@@ -20,3 +20,35 @@ pub fn filter_permissions_by_enabled_apps(
.collect()
}
pub fn matches_permission(granted: &str, required: &str) -> bool {
if granted == required {
return true;
}
let g: Vec<&str> = granted.split(':').collect();
let r: Vec<&str> = required.split(':').collect();
if g.len() != r.len() {
return false;
}
for (gs, rs) in g.iter().zip(r.iter()) {
if *gs == "*" {
continue;
}
if gs != rs {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::matches_permission;
#[test]
fn wildcard_matches_three_segments() {
assert!(matches_permission("cms:*:*", "cms:article:create"));
assert!(matches_permission("cms:article:*", "cms:article:publish"));
assert!(!matches_permission("cms:article:*", "cms:media:manage"));
assert!(!matches_permission("cms:*:*", "user:read"));
}
}