feat(role): role bind
This commit is contained in:
21
src/docs.rs
21
src/docs.rs
@@ -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/策略引擎后续扩展)")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
48
src/handlers/permission.rs
Normal file
48
src/handlers/permission.rs
Normal 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))
|
||||
}
|
||||
@@ -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!({})))
|
||||
}
|
||||
|
||||
44
src/main.rs
44
src/main.rs
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
|
||||
86
src/services/permission.rs
Normal file
86
src/services/permission.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user