perf(struct): ddd
This commit is contained in:
2
src/application/mod.rs
Normal file
2
src/application/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod services;
|
||||
pub mod use_cases;
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::models::{
|
||||
App, AppStatusChangeRequest, CreateAppRequest, ListAppsQuery, UpdateAppRequest,
|
||||
App, AppStatusChangeRequest, CreateAppRequest, ListAppsQuery, RequestAppStatusChangeRequest,
|
||||
UpdateAppRequest,
|
||||
};
|
||||
use common_telemetry::AppError;
|
||||
use sqlx::PgPool;
|
||||
@@ -109,10 +110,10 @@ impl AppService {
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(db) = &e {
|
||||
if db.is_unique_violation() {
|
||||
return AppError::AlreadyExists("App already exists".into());
|
||||
}
|
||||
if let sqlx::Error::Database(db) = &e
|
||||
&& db.is_unique_violation()
|
||||
{
|
||||
return AppError::AlreadyExists("App already exists".into());
|
||||
}
|
||||
e.into()
|
||||
})?;
|
||||
@@ -16,15 +16,10 @@ pub struct AuthService {
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
/// 创建认证服务实例。
|
||||
///
|
||||
/// 说明:
|
||||
/// - Access Token 使用 RS256 密钥对进行签发与校验,不使用对称密钥(HS256)。
|
||||
/// - 但仍需要一个服务端 Secret 作为 Refresh Token 指纹(HMAC)pepper,因此保留 `_jwt_secret` 入参(对齐环境变量名 `JWT_SECRET`)。
|
||||
pub fn new(pool: PgPool, _jwt_secret: String) -> Self {
|
||||
pub fn new(pool: PgPool, jwt_secret: String) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
refresh_token_pepper: _jwt_secret,
|
||||
refresh_token_pepper: jwt_secret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +78,6 @@ impl AuthService {
|
||||
.unwrap_or_else(|| (vec![], 0));
|
||||
|
||||
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
|
||||
|
||||
let access_token = sign_with_ttl(
|
||||
user_id,
|
||||
tenant_id,
|
||||
@@ -121,48 +115,24 @@ impl AuthService {
|
||||
})
|
||||
}
|
||||
|
||||
// 注册业务
|
||||
#[instrument(skip(self, req))]
|
||||
/// 在指定租户下注册新用户,并在首次注册时自动引导初始化租户管理员权限。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 用户必须绑定到 `tenant_id`,禁止跨租户注册写入。
|
||||
/// - 密码以安全哈希形式存储,不回传明文。
|
||||
/// - 若该租户用户数为 1(首个用户),自动创建/获取 `Admin` 系统角色并授予全量权限,同时绑定到该用户。
|
||||
///
|
||||
/// 输入:
|
||||
/// - `tenant_id`:目标租户
|
||||
/// - `req.email` / `req.password`:注册信息
|
||||
///
|
||||
/// 输出:
|
||||
/// - 返回创建后的 `User` 记录(包含 `id/tenant_id/email` 等字段)
|
||||
///
|
||||
/// 异常:
|
||||
/// - 数据库写入失败(如唯一约束冲突、连接错误等)
|
||||
/// - 密码哈希失败
|
||||
pub async fn register(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
req: CreateUserRequest,
|
||||
) -> Result<User, AppError> {
|
||||
pub async fn register(&self, tenant_id: Uuid, req: CreateUserRequest) -> Result<User, AppError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// 1. 哈希密码
|
||||
let hashed =
|
||||
hash_password(&req.password).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
|
||||
|
||||
// 2. 存入数据库 (带上 tenant_id)
|
||||
let query = r#"
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
INSERT INTO users (tenant_id, email, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, tenant_id, email, password_hash
|
||||
"#;
|
||||
let user = sqlx::query_as::<_, User>(query)
|
||||
.bind(tenant_id)
|
||||
.bind(&req.email)
|
||||
.bind(&hashed)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(&req.email)
|
||||
.bind(&hashed)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(1) FROM users WHERE tenant_id = $1")
|
||||
.bind(tenant_id)
|
||||
@@ -178,33 +148,11 @@ impl AuthService {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
// 登录业务
|
||||
#[instrument(skip(self, req))]
|
||||
/// 在指定租户内完成用户认证并签发访问令牌与刷新令牌。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅在当前租户内按 `email` 查找用户,防止跨租户登录。
|
||||
/// - 密码校验失败返回 `InvalidCredentials`。
|
||||
/// - 登录成功后生成:
|
||||
/// - `access_token`:JWT(包含租户、用户、角色与权限)
|
||||
/// - `refresh_token`:随机生成并哈希后入库(默认 30 天过期)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `LoginResponse`(token_type 固定为 `Bearer`,`expires_in` 当前为 15 分钟)
|
||||
///
|
||||
/// 异常:
|
||||
/// - 用户不存在(404)
|
||||
/// - 密码错误(401)
|
||||
/// - Token 签发失败或数据库写入失败
|
||||
pub async fn login(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
req: LoginRequest,
|
||||
) -> Result<LoginResponse, AppError> {
|
||||
pub async fn login(&self, tenant_id: Uuid, req: LoginRequest) -> Result<LoginResponse, AppError> {
|
||||
let user = self
|
||||
.verify_user_credentials(tenant_id, req.email, req.password)
|
||||
.await?;
|
||||
|
||||
self.issue_tokens_for_user(user.tenant_id, user.id, 15 * 60).await
|
||||
}
|
||||
|
||||
@@ -220,8 +168,7 @@ impl AuthService {
|
||||
return Err(AppError::BadRequest("email and password are required".into()));
|
||||
}
|
||||
|
||||
let query = "SELECT * FROM users WHERE tenant_id = $1 AND email = $2";
|
||||
let user = sqlx::query_as::<_, User>(query)
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE tenant_id = $1 AND email = $2")
|
||||
.bind(tenant_id)
|
||||
.bind(&email)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -303,7 +250,6 @@ impl AuthService {
|
||||
let fingerprint = self.refresh_token_fingerprint(refresh_token.trim())?;
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -356,7 +302,6 @@ impl AuthService {
|
||||
.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()));
|
||||
}
|
||||
@@ -467,3 +412,4 @@ impl AuthService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ClientService {
|
||||
prev_ttl_days: u32,
|
||||
}
|
||||
|
||||
pub type ClientListItem = (String, Option<String>, Vec<String>, String, String);
|
||||
|
||||
impl ClientService {
|
||||
pub fn new(pool: PgPool, prev_ttl_days: u32) -> Self {
|
||||
Self {
|
||||
@@ -97,6 +99,7 @@ impl ClientService {
|
||||
let redirect_uris_json =
|
||||
Value::Array(redirect_uris.into_iter().map(Value::String).collect());
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let inserted = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO oauth_clients (client_id, name, secret_hash, redirect_uris)
|
||||
@@ -108,7 +111,7 @@ impl ClientService {
|
||||
.bind(name)
|
||||
.bind(secret_hash)
|
||||
.bind(redirect_uris_json)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
@@ -116,6 +119,19 @@ impl ClientService {
|
||||
return Err(AppError::BadRequest("clientId already exists".into()));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO tenant_oauth_clients (tenant_id, client_id)
|
||||
SELECT id, $1
|
||||
FROM tenants
|
||||
ON CONFLICT (tenant_id, client_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(&client_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
@@ -307,21 +323,18 @@ impl ClientService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let (Some(prev_hash), Some(prev_expires_at)) = (prev_hash, prev_expires_at) {
|
||||
if chrono::Utc::now() < prev_expires_at
|
||||
&& verify_password(client_secret.trim(), &prev_hash)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if let (Some(prev_hash), Some(prev_expires_at)) = (prev_hash, prev_expires_at)
|
||||
&& chrono::Utc::now() < prev_expires_at
|
||||
&& verify_password(client_secret.trim(), &prev_hash)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(AppError::AuthError("Invalid client credentials".into()))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_clients(
|
||||
&self,
|
||||
) -> Result<Vec<(String, Option<String>, Vec<String>, String, String)>, AppError> {
|
||||
pub async fn list_clients(&self) -> Result<Vec<ClientListItem>, AppError> {
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -363,3 +376,4 @@ impl ClientService {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod app;
|
||||
#![allow(unused_imports)]
|
||||
|
||||
pub mod auth;
|
||||
pub mod app;
|
||||
pub mod authorization;
|
||||
pub mod client;
|
||||
pub mod permission;
|
||||
@@ -10,7 +12,7 @@ pub mod user;
|
||||
pub use app::AppService;
|
||||
pub use auth::AuthService;
|
||||
pub use authorization::AuthorizationService;
|
||||
pub use client::ClientService;
|
||||
pub use client::{ClientListItem, ClientService};
|
||||
pub use permission::PermissionService;
|
||||
pub use role::RoleService;
|
||||
pub use tenant::TenantService;
|
||||
@@ -14,7 +14,10 @@ impl PermissionService {
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_permissions(&self, query: ListPermissionsQuery) -> Result<Vec<Permission>, AppError> {
|
||||
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 {
|
||||
@@ -43,10 +43,10 @@ impl RoleService {
|
||||
.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());
|
||||
}
|
||||
if let sqlx::Error::Database(db) = &e
|
||||
&& db.is_unique_violation()
|
||||
{
|
||||
return AppError::AlreadyExists("Role name already exists".into());
|
||||
}
|
||||
AppError::DbError(e)
|
||||
})?;
|
||||
@@ -78,7 +78,7 @@ impl RoleService {
|
||||
.bind(tenant_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))
|
||||
.map_err(AppError::DbError)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
@@ -135,10 +135,10 @@ impl RoleService {
|
||||
.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());
|
||||
}
|
||||
if let sqlx::Error::Database(db) = &e
|
||||
&& db.is_unique_violation()
|
||||
{
|
||||
return AppError::AlreadyExists("Role name already exists".into());
|
||||
}
|
||||
AppError::DbError(e)
|
||||
})?;
|
||||
@@ -503,6 +503,7 @@ impl RoleService {
|
||||
}
|
||||
|
||||
#[instrument(skip(self, role_ids))]
|
||||
#[allow(dead_code)]
|
||||
pub async fn list_roles_by_ids(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
@@ -517,7 +518,7 @@ impl RoleService {
|
||||
.bind(role_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))
|
||||
.map_err(AppError::DbError)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
@@ -537,7 +538,7 @@ impl RoleService {
|
||||
.bind(target_user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))
|
||||
.map_err(AppError::DbError)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, role_ids))]
|
||||
@@ -24,7 +24,6 @@ pub struct TenantService {
|
||||
}
|
||||
|
||||
impl TenantService {
|
||||
/// 创建租户服务实例。
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
@@ -33,17 +32,6 @@ impl TenantService {
|
||||
}
|
||||
|
||||
#[instrument(skip(self, req))]
|
||||
/// 创建新租户并初始化默认状态与配置。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 默认 `status=active`。
|
||||
/// - `config` 未提供时默认 `{}`。
|
||||
///
|
||||
/// 输出:
|
||||
/// - 返回新建租户记录(含 `id`)
|
||||
///
|
||||
/// 异常:
|
||||
/// - 数据库写入失败(如连接异常、约束失败等)
|
||||
pub async fn create_tenant(&self, req: CreateTenantRequest) -> Result<Tenant, AppError> {
|
||||
let mut config = req
|
||||
.config
|
||||
@@ -58,17 +46,18 @@ impl TenantService {
|
||||
Value::Number(serde_json::Number::from(0)),
|
||||
);
|
||||
}
|
||||
let query = r#"
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let tenant = sqlx::query_as::<_, Tenant>(
|
||||
r#"
|
||||
INSERT INTO tenants (name, status, config)
|
||||
VALUES ($1, 'active', $2)
|
||||
RETURNING id, name, status, config
|
||||
"#;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let tenant = sqlx::query_as::<_, Tenant>(query)
|
||||
.bind(req.name)
|
||||
.bind(config)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
"#,
|
||||
)
|
||||
.bind(req.name)
|
||||
.bind(config)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -86,13 +75,8 @@ impl TenantService {
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
/// 根据租户 ID 查询租户信息。
|
||||
///
|
||||
/// 异常:
|
||||
/// - 若租户不存在,返回 `NotFound("Tenant not found")`。
|
||||
pub async fn get_tenant(&self, tenant_id: Uuid) -> Result<Tenant, AppError> {
|
||||
let query = "SELECT id, name, status, config FROM tenants WHERE id = $1";
|
||||
sqlx::query_as::<_, Tenant>(query)
|
||||
sqlx::query_as::<_, Tenant>("SELECT id, name, status, config FROM tenants WHERE id = $1")
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
@@ -100,19 +84,13 @@ impl TenantService {
|
||||
}
|
||||
|
||||
#[instrument(skip(self, req))]
|
||||
/// 更新租户基础信息(名称 / 配置)。
|
||||
///
|
||||
/// 说明:
|
||||
/// - 仅更新 `UpdateTenantRequest` 中提供的字段,未提供字段保持不变。
|
||||
///
|
||||
/// 异常:
|
||||
/// - 若租户不存在,返回 `NotFound("Tenant not found")`。
|
||||
pub async fn update_tenant(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
req: UpdateTenantRequest,
|
||||
) -> Result<Tenant, AppError> {
|
||||
let query = r#"
|
||||
sqlx::query_as::<_, Tenant>(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET
|
||||
name = COALESCE($1, name),
|
||||
@@ -120,47 +98,40 @@ impl TenantService {
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, name, status, config
|
||||
"#;
|
||||
sqlx::query_as::<_, Tenant>(query)
|
||||
.bind(req.name)
|
||||
.bind(req.config)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Tenant not found".into()))
|
||||
"#,
|
||||
)
|
||||
.bind(req.name)
|
||||
.bind(req.config)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Tenant not found".into()))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, req))]
|
||||
/// 更新租户状态字段(如 active / disabled)。
|
||||
///
|
||||
/// 异常:
|
||||
/// - 若租户不存在,返回 `NotFound("Tenant not found")`。
|
||||
pub async fn update_tenant_status(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
req: UpdateTenantStatusRequest,
|
||||
) -> Result<Tenant, AppError> {
|
||||
let query = r#"
|
||||
sqlx::query_as::<_, Tenant>(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET
|
||||
status = $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING id, name, status, config
|
||||
"#;
|
||||
sqlx::query_as::<_, Tenant>(query)
|
||||
.bind(req.status)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Tenant not found".into()))
|
||||
"#,
|
||||
)
|
||||
.bind(req.status)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Tenant not found".into()))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
/// 删除指定租户。
|
||||
///
|
||||
/// 异常:
|
||||
/// - 若租户不存在,返回 `NotFound("Tenant not found")`。
|
||||
pub async fn delete_tenant(&self, tenant_id: Uuid) -> Result<(), AppError> {
|
||||
let result = sqlx::query("DELETE FROM tenants WHERE id = $1")
|
||||
.bind(tenant_id)
|
||||
@@ -184,10 +155,9 @@ impl TenantService {
|
||||
.await
|
||||
.get(&tenant_id)
|
||||
.cloned()
|
||||
&& hit.expires_at > now
|
||||
{
|
||||
if hit.expires_at > now {
|
||||
return Ok((hit.enabled_apps, hit.version, hit.updated_at));
|
||||
}
|
||||
return Ok((hit.enabled_apps, hit.version, hit.updated_at));
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, (Vec<String>, i32, chrono::DateTime<chrono::Utc>)>(
|
||||
@@ -242,7 +212,6 @@ impl TenantService {
|
||||
self.validate_apps_exist(&normalized).await?;
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let current = sqlx::query_as::<_, (Vec<String>, i32)>(
|
||||
r#"
|
||||
SELECT enabled_apps, version
|
||||
@@ -276,12 +245,12 @@ impl TenantService {
|
||||
}
|
||||
|
||||
let (before_apps, before_version) = current.unwrap_or_else(|| (vec![], 0));
|
||||
if let Some(ev) = expected_version {
|
||||
if ev != before_version {
|
||||
return Err(AppError::AlreadyExists(
|
||||
"enabled_apps:version_conflict".into(),
|
||||
));
|
||||
}
|
||||
if let Some(ev) = expected_version
|
||||
&& ev != before_version
|
||||
{
|
||||
return Err(AppError::AlreadyExists(
|
||||
"enabled_apps:version_conflict".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let (new_version, updated_at): (i32, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
|
||||
@@ -397,3 +366,4 @@ fn normalize_apps(enabled_apps: Vec<String>) -> Vec<String> {
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ impl UserService {
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?
|
||||
.map_err(AppError::DbError)?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ impl UserService {
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))
|
||||
.map_err(AppError::DbError)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
@@ -87,7 +87,7 @@ impl UserService {
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?
|
||||
.map_err(AppError::DbError)?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ impl UserService {
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?;
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("User not found".into()));
|
||||
@@ -135,7 +135,7 @@ impl UserService {
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?;
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
let Some(stored_hash) = stored_hash else {
|
||||
return Err(AppError::NotFound("User not found".into()));
|
||||
@@ -156,13 +156,13 @@ impl UserService {
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?;
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?;
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -175,7 +175,7 @@ impl UserService {
|
||||
.bind(json!({ "target_user_id": user_id }))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?;
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
@@ -216,7 +216,7 @@ impl UserService {
|
||||
.bind(target_user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?;
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("User not found".into()));
|
||||
@@ -226,7 +226,7 @@ impl UserService {
|
||||
.bind(target_user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?;
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -239,7 +239,7 @@ impl UserService {
|
||||
.bind(json!({ "target_user_id": target_user_id }))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))?;
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(temp)
|
||||
138
src/application/use_cases/exchange_code.rs
Normal file
138
src/application/use_cases/exchange_code.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use async_trait::async_trait;
|
||||
use redis::Script;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::DomainError;
|
||||
use crate::models::Code2TokenRequest;
|
||||
use crate::application::services::AuthService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExchangeCodeUseCase {
|
||||
pub auth_service: AuthService,
|
||||
pub redis: redis::aio::ConnectionManager,
|
||||
pub auth_code_jwt_secret: String,
|
||||
}
|
||||
|
||||
pub struct ExchangeCodeResult {
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: usize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Execute {
|
||||
async fn execute(&self, req: Code2TokenRequest) -> Result<ExchangeCodeResult, DomainError>;
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AuthCodeClaims {
|
||||
sub: String,
|
||||
tenant_id: String,
|
||||
client_id: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
exp: usize,
|
||||
#[allow(dead_code)]
|
||||
iat: usize,
|
||||
#[allow(dead_code)]
|
||||
iss: String,
|
||||
jti: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AuthCodeRedisValue {
|
||||
user_id: String,
|
||||
tenant_id: String,
|
||||
client_id: Option<String>,
|
||||
}
|
||||
|
||||
fn redis_key(jti: &str) -> String {
|
||||
format!("iam:auth_code:{}", jti)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Execute for ExchangeCodeUseCase {
|
||||
async fn execute(&self, req: Code2TokenRequest) -> Result<ExchangeCodeResult, DomainError> {
|
||||
if req.code.trim().is_empty() {
|
||||
return Err(DomainError::InvalidArgument("code".into()));
|
||||
}
|
||||
|
||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||
validation.set_issuer(&["iam-front", "iam-service"]);
|
||||
|
||||
let token_data = jsonwebtoken::decode::<AuthCodeClaims>(
|
||||
req.code.trim(),
|
||||
&jsonwebtoken::DecodingKey::from_secret(self.auth_code_jwt_secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(|_| DomainError::Unauthorized)?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
if let Some(cid) = &claims.client_id
|
||||
&& cid != req.client_id.trim()
|
||||
{
|
||||
return Err(DomainError::Unauthorized);
|
||||
}
|
||||
|
||||
let jti = claims.jti.trim();
|
||||
if jti.is_empty() {
|
||||
return Err(DomainError::Unauthorized);
|
||||
}
|
||||
|
||||
let script = Script::new(
|
||||
r#"
|
||||
local v = redis.call('GET', KEYS[1])
|
||||
if v then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return v
|
||||
"#,
|
||||
);
|
||||
|
||||
let key = redis_key(jti);
|
||||
let mut conn = self.redis.clone();
|
||||
let val: Option<String> = script
|
||||
.key(key)
|
||||
.invoke_async(&mut conn)
|
||||
.await
|
||||
.map_err(|_| DomainError::Unexpected)?;
|
||||
|
||||
let Some(val) = val else {
|
||||
return Err(DomainError::Unauthorized);
|
||||
};
|
||||
|
||||
let stored: AuthCodeRedisValue =
|
||||
serde_json::from_str(&val).map_err(|_| DomainError::Unauthorized)?;
|
||||
|
||||
if let Some(cid) = stored.client_id.as_deref()
|
||||
&& cid != req.client_id.trim()
|
||||
{
|
||||
return Err(DomainError::Unauthorized);
|
||||
}
|
||||
|
||||
if stored.user_id != claims.sub || stored.tenant_id != claims.tenant_id {
|
||||
return Err(DomainError::Unauthorized);
|
||||
}
|
||||
|
||||
let user_id = Uuid::parse_str(&stored.user_id).map_err(|_| DomainError::Unauthorized)?;
|
||||
let tenant_id =
|
||||
Uuid::parse_str(&stored.tenant_id).map_err(|_| DomainError::Unauthorized)?;
|
||||
|
||||
let tokens = self
|
||||
.auth_service
|
||||
.issue_tokens_for_user(tenant_id, user_id, 7200)
|
||||
.await
|
||||
.map_err(|_| DomainError::Unexpected)?;
|
||||
|
||||
Ok(ExchangeCodeResult {
|
||||
tenant_id,
|
||||
user_id,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_type: tokens.token_type,
|
||||
expires_in: tokens.expires_in,
|
||||
})
|
||||
}
|
||||
}
|
||||
2
src/application/use_cases/mod.rs
Normal file
2
src/application/use_cases/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod exchange_code;
|
||||
|
||||
2
src/constants.rs
Normal file
2
src/constants.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub const CANONICAL_BASE: &str = "/api/v1";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::handlers;
|
||||
use crate::presentation::http::handlers;
|
||||
use crate::models::{
|
||||
AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, App, AppStatusChangeRequest,
|
||||
ApproveAppStatusChangeRequest, CreateAppRequest, CreateRoleRequest, CreateTenantRequest,
|
||||
@@ -137,6 +137,9 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
|
||||
version = "0.1.0",
|
||||
description = include_str!("../docs/SCALAR_GUIDE.md")
|
||||
),
|
||||
servers(
|
||||
(url = "/api/v1", description = "Canonical API base")
|
||||
),
|
||||
|
||||
paths(
|
||||
handlers::auth::register_handler,
|
||||
@@ -145,6 +148,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
|
||||
handlers::auth::refresh_handler,
|
||||
handlers::auth::logout_handler,
|
||||
handlers::sso::code2token_handler,
|
||||
handlers::sso::internal_code2token_handler,
|
||||
handlers::client::create_client_handler,
|
||||
handlers::client::rotate_client_secret_handler,
|
||||
handlers::client::list_clients_handler,
|
||||
|
||||
1
src/domain/aggregates/mod.rs
Normal file
1
src/domain/aggregates/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
src/domain/entities/mod.rs
Normal file
1
src/domain/entities/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
13
src/domain/mod.rs
Normal file
13
src/domain/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod aggregates;
|
||||
pub mod entities;
|
||||
pub mod repositories;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DomainError {
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("invalid argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
#[error("unexpected error")]
|
||||
Unexpected,
|
||||
}
|
||||
2
src/domain/repositories/mod.rs
Normal file
2
src/domain/repositories/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod tenant_config_repo;
|
||||
|
||||
15
src/domain/repositories/tenant_config_repo.rs
Normal file
15
src/domain/repositories/tenant_config_repo.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::DomainError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait TenantConfigRepo: Send + Sync {
|
||||
async fn validate_client_pair(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod authorization;
|
||||
pub mod client;
|
||||
pub mod jwks;
|
||||
pub mod permission;
|
||||
pub mod platform;
|
||||
pub mod role;
|
||||
pub mod sso;
|
||||
pub mod tenant;
|
||||
pub mod user;
|
||||
|
||||
use crate::services::{
|
||||
AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService,
|
||||
TenantService, UserService,
|
||||
};
|
||||
use redis::aio::ConnectionManager;
|
||||
|
||||
pub use app::{
|
||||
approve_app_status_change_handler, create_app_handler, delete_app_handler, get_app_handler,
|
||||
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, logout_handler, refresh_handler, register_handler};
|
||||
pub use authorization::{authorization_check_handler, my_permissions_handler};
|
||||
pub use client::{
|
||||
create_client_handler, list_clients_handler, rotate_client_secret_handler,
|
||||
update_client_redirect_uris_handler,
|
||||
};
|
||||
pub use jwks::jwks_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, 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 sso::{code2token_handler, login_code_handler};
|
||||
pub use tenant::{
|
||||
create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler,
|
||||
update_tenant_status_handler,
|
||||
};
|
||||
pub use user::{
|
||||
delete_user_handler, get_user_handler, list_user_roles_handler, list_users_handler,
|
||||
reset_my_password_handler, reset_user_password_handler, set_user_roles_handler,
|
||||
update_user_handler,
|
||||
};
|
||||
|
||||
// 状态对象,包含 Service
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub auth_service: AuthService,
|
||||
pub client_service: ClientService,
|
||||
pub user_service: UserService,
|
||||
pub role_service: RoleService,
|
||||
pub tenant_service: TenantService,
|
||||
pub authorization_service: AuthorizationService,
|
||||
pub app_service: AppService,
|
||||
pub permission_service: PermissionService,
|
||||
pub redis: ConnectionManager,
|
||||
pub auth_code_jwt_secret: String,
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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))]
|
||||
/// 查询权限列表(Permission Catalog)。
|
||||
///
|
||||
/// 返回全局权限目录(`permissions` 表)的分页结果,用于:
|
||||
/// - 后台展示可分配权限;
|
||||
/// - 角色绑定权限前的检索与校验;
|
||||
/// - 按应用(`app_code`)/资源(`resource`)/动作(`action`)筛选。
|
||||
///
|
||||
/// 权限编码规范为 `${app_code}:${resource}:${action}`,例如 `cms:article:publish`。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID(用于鉴权与跨租户隔离)。
|
||||
/// - `State(state): State<AppState>`:应用状态,包含 `PermissionService`/`AuthorizationService`。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
|
||||
/// - `Query(query): Query<ListPermissionsQuery>`:查询参数(分页/搜索/筛选/排序)。
|
||||
/// - `page: Option<u32>`:页码(默认 1)。
|
||||
/// - `page_size: Option<u32>`:每页条数(默认 20,范围 1..=200)。
|
||||
/// - `search: Option<String>`:按 `code` 或 `description` 模糊搜索。
|
||||
/// - `app_code/resource/action`:精确筛选(用于权限目录分组)。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<Vec<Permission>>)`:成功返回 `200`,`data` 为权限列表。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("role:read"))`:调用方缺少 `role:read` 权限(用于控制“查看权限目录”的能力)。
|
||||
/// - `Err(AppError::BadRequest(_))`:分页参数非法(如 `page_size>200` 或为 0)。
|
||||
/// - `Err(AppError::DbError(_))`:数据库查询失败。
|
||||
///
|
||||
/// ## Example
|
||||
/// ```rust,ignore
|
||||
/// // GET /permissions?page=1&page_size=20&app_code=cms&search=article
|
||||
/// // Headers:
|
||||
/// // Authorization: Bearer <access_token>
|
||||
/// // X-Tenant-ID: <tenant_uuid> // 可选,但若提供必须与 token 中 tenant_id 一致
|
||||
/// ```
|
||||
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))
|
||||
}
|
||||
2
src/infrastructure/mod.rs
Normal file
2
src/infrastructure/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod repositories;
|
||||
|
||||
2
src/infrastructure/repositories/mod.rs
Normal file
2
src/infrastructure/repositories/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod tenant_config_repo;
|
||||
|
||||
67
src/infrastructure/repositories/tenant_config_repo.rs
Normal file
67
src/infrastructure/repositories/tenant_config_repo.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::repositories::tenant_config_repo::TenantConfigRepo;
|
||||
use crate::domain::DomainError;
|
||||
use crate::utils::verify_password;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TenantConfigRepoPg {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl TenantConfigRepoPg {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TenantConfigRepo for TenantConfigRepoPg {
|
||||
async fn validate_client_pair(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> Result<bool, DomainError> {
|
||||
let client_id = client_id.trim();
|
||||
let client_secret = client_secret.trim();
|
||||
if client_id.is_empty() || client_secret.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let allowed: Option<(String, Option<String>, Option<chrono::DateTime<chrono::Utc>>)> =
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT c.secret_hash, c.prev_secret_hash, c.prev_expires_at
|
||||
FROM oauth_clients c
|
||||
JOIN tenant_oauth_clients tc
|
||||
ON tc.client_id = c.client_id
|
||||
WHERE tc.tenant_id = $1 AND c.client_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(client_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|_| DomainError::Unexpected)?;
|
||||
|
||||
let Some((secret_hash, prev_hash, prev_expires_at)) = allowed else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if verify_password(client_secret, &secret_hash) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let (Some(prev_hash), Some(prev_expires_at)) = (prev_hash, prev_expires_at)
|
||||
&& chrono::Utc::now() < prev_expires_at
|
||||
&& verify_password(client_secret, &prev_hash)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod application;
|
||||
pub mod config;
|
||||
pub mod constants;
|
||||
pub mod db;
|
||||
pub mod docs;
|
||||
pub mod handlers;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
pub mod middleware;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
pub mod presentation;
|
||||
pub mod utils;
|
||||
|
||||
|
||||
232
src/main.rs
232
src/main.rs
@@ -1,47 +1,26 @@
|
||||
mod application;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod db; // 声明 db 模块
|
||||
mod docs;
|
||||
mod handlers;
|
||||
mod domain;
|
||||
mod infrastructure;
|
||||
mod middleware;
|
||||
mod models;
|
||||
mod presentation;
|
||||
mod redis;
|
||||
mod services;
|
||||
mod utils;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::StatusCode,
|
||||
middleware::from_fn,
|
||||
middleware::from_fn_with_state,
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use common_telemetry::telemetry::{self, TelemetryConfig};
|
||||
use config::AppConfig;
|
||||
use handlers::{
|
||||
AppState, approve_app_status_change_handler, authorization_check_handler, code2token_handler,
|
||||
create_app_handler, create_client_handler, create_role_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, jwks_handler,
|
||||
list_app_status_change_requests_handler, list_apps_handler, list_clients_handler,
|
||||
list_permissions_handler, list_roles_handler, list_user_roles_handler, list_users_handler,
|
||||
login_code_handler, login_handler, logout_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, rotate_client_secret_handler, set_tenant_enabled_apps_handler,
|
||||
set_user_roles_handler, update_app_handler, update_client_redirect_uris_handler,
|
||||
update_role_handler, update_tenant_handler, update_tenant_status_handler, update_user_handler,
|
||||
};
|
||||
use services::{
|
||||
use constants::CANONICAL_BASE;
|
||||
use application::services::{
|
||||
AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService,
|
||||
TenantService, UserService,
|
||||
};
|
||||
use presentation::http::api;
|
||||
use presentation::http::state::AppState;
|
||||
use std::net::SocketAddr;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
// 引入 models 下的所有结构体以生成文档
|
||||
use auth_kit::jwt::JwtVerifyConfig;
|
||||
use common_telemetry::telemetry::{self, TelemetryConfig};
|
||||
use docs::ApiDoc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -89,6 +68,9 @@ async fn main() {
|
||||
let authorization_service = AuthorizationService::new(pool.clone());
|
||||
let app_service = AppService::new(pool.clone());
|
||||
let permission_service = PermissionService::new(pool.clone());
|
||||
let tenant_config_repo = std::sync::Arc::new(
|
||||
infrastructure::repositories::tenant_config_repo::TenantConfigRepoPg::new(pool.clone()),
|
||||
);
|
||||
let redis = match redis::init_manager(&config).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
@@ -108,189 +90,17 @@ async fn main() {
|
||||
permission_service,
|
||||
redis,
|
||||
auth_code_jwt_secret: config.auth_code_jwt_secret.clone(),
|
||||
tenant_config_repo,
|
||||
};
|
||||
|
||||
let auth_cfg = middleware::auth::AuthMiddlewareConfig {
|
||||
skip_exact_paths: vec![
|
||||
"/.well-known/jwks.json".to_string(),
|
||||
"/tenants/register".to_string(),
|
||||
"/auth/register".to_string(),
|
||||
"/auth/login".to_string(),
|
||||
"/auth/login-code".to_string(),
|
||||
"/auth/refresh".to_string(),
|
||||
"/auth/code2token".to_string(),
|
||||
"/iam/api/v1/.well-known/jwks.json".to_string(),
|
||||
"/iam/api/v1/tenants/register".to_string(),
|
||||
"/iam/api/v1/auth/register".to_string(),
|
||||
"/iam/api/v1/auth/login".to_string(),
|
||||
"/iam/api/v1/auth/login-code".to_string(),
|
||||
"/iam/api/v1/auth/refresh".to_string(),
|
||||
"/iam/api/v1/auth/code2token".to_string(),
|
||||
],
|
||||
skip_path_prefixes: vec!["/scalar".to_string()],
|
||||
jwt: JwtVerifyConfig::rs256_from_pem(
|
||||
"iam-service",
|
||||
&crate::utils::keys::get_keys().public_pem,
|
||||
)
|
||||
.expect("invalid JWT_PUBLIC_KEY_PEM"),
|
||||
};
|
||||
let tenant_cfg = middleware::TenantMiddlewareConfig {
|
||||
skip_exact_paths: vec![
|
||||
"/.well-known/jwks.json".to_string(),
|
||||
"/tenants/register".to_string(),
|
||||
"/auth/refresh".to_string(),
|
||||
"/auth/code2token".to_string(),
|
||||
"/iam/api/v1/.well-known/jwks.json".to_string(),
|
||||
"/iam/api/v1/tenants/register".to_string(),
|
||||
"/iam/api/v1/auth/refresh".to_string(),
|
||||
"/iam/api/v1/auth/code2token".to_string(),
|
||||
],
|
||||
skip_path_prefixes: vec!["/scalar".to_string()],
|
||||
};
|
||||
if std::env::args().any(|a| a == "--print-routes") {
|
||||
for (method, path) in api::routes() {
|
||||
println!("{} {}{}", method, CANONICAL_BASE, path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 构建路由
|
||||
let api = Router::new()
|
||||
.route("/.well-known/jwks.json", get(jwks_handler))
|
||||
.route("/tenants/register", post(create_tenant_handler))
|
||||
.route(
|
||||
"/tenants/me",
|
||||
get(get_tenant_handler)
|
||||
.patch(update_tenant_handler)
|
||||
.delete(delete_tenant_handler),
|
||||
)
|
||||
.route("/tenants/me/status", post(update_tenant_status_handler))
|
||||
.route(
|
||||
"/auth/register",
|
||||
post(register_handler)
|
||||
.layer(middleware::rate_limit::register_rate_limiter())
|
||||
.layer(from_fn(middleware::rate_limit::log_rate_limit_register)),
|
||||
)
|
||||
.route(
|
||||
"/auth/login",
|
||||
post(login_handler)
|
||||
.layer(middleware::rate_limit::login_rate_limiter())
|
||||
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
|
||||
)
|
||||
.route(
|
||||
"/auth/login-code",
|
||||
post(login_code_handler)
|
||||
.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("/auth/logout", post(logout_handler))
|
||||
.route("/auth/code2token", post(code2token_handler))
|
||||
.route("/me/permissions", get(my_permissions_handler))
|
||||
.route("/authorize/check", post(authorization_check_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)
|
||||
.patch(update_user_handler)
|
||||
.delete(delete_user_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/password/reset",
|
||||
post(reset_user_password_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/roles",
|
||||
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_with_state(
|
||||
tenant_cfg.clone(),
|
||||
middleware::resolve_tenant_with_config,
|
||||
))
|
||||
.layer(from_fn_with_state(
|
||||
auth_cfg.clone(),
|
||||
middleware::auth::authenticate_with_config,
|
||||
))
|
||||
.layer(from_fn(
|
||||
common_telemetry::axum_middleware::trace_http_request,
|
||||
));
|
||||
|
||||
let platform_api = Router::new()
|
||||
.route(
|
||||
"/platform/tenants/{tenant_id}/enabled-apps",
|
||||
get(get_tenant_enabled_apps_handler).put(set_tenant_enabled_apps_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients",
|
||||
get(list_clients_handler).post(create_client_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients/{client_id}/rotate-secret",
|
||||
post(rotate_client_secret_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients/{client_id}/redirect-uris",
|
||||
put(update_client_redirect_uris_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/apps",
|
||||
get(list_apps_handler).post(create_app_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/apps/{app_id}",
|
||||
get(get_app_handler)
|
||||
.patch(update_app_handler)
|
||||
.delete(delete_app_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/apps/{app_id}/status-change-requests",
|
||||
post(request_app_status_change_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/app-status-change-requests",
|
||||
get(list_app_status_change_requests_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/app-status-change-requests/{request_id}/approve",
|
||||
post(approve_app_status_change_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/app-status-change-requests/{request_id}/reject",
|
||||
post(reject_app_status_change_handler),
|
||||
)
|
||||
.layer(from_fn_with_state(
|
||||
auth_cfg,
|
||||
middleware::auth::authenticate_with_config,
|
||||
))
|
||||
.layer(from_fn(
|
||||
common_telemetry::axum_middleware::trace_http_request,
|
||||
));
|
||||
|
||||
let v1 = Router::new().merge(platform_api).merge(api);
|
||||
let app = Router::new()
|
||||
.route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.merge(v1.clone())
|
||||
.nest("/iam/api/v1", v1)
|
||||
.with_state(state);
|
||||
let app = api::build_app(state);
|
||||
|
||||
// 6. 启动服务器
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
|
||||
41
src/middleware/auth_tenant.rs
Normal file
41
src/middleware/auth_tenant.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
use common_telemetry::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::middleware::TenantId;
|
||||
|
||||
fn parse_tenant_id_from_query(req: &Request) -> Option<Uuid> {
|
||||
let q = req.uri().query()?;
|
||||
url::form_urlencoded::parse(q.as_bytes())
|
||||
.find_map(|(k, v)| (k == "tenant_id").then_some(v))
|
||||
.and_then(|v| Uuid::parse_str(v.as_ref()).ok())
|
||||
}
|
||||
|
||||
fn parse_tenant_id_from_headers(req: &Request) -> Option<Uuid> {
|
||||
req.headers()
|
||||
.get("X-Tenant-ID")
|
||||
.or_else(|| req.headers().get("X-Tenant-Id"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| Uuid::parse_str(v).ok())
|
||||
}
|
||||
|
||||
pub async fn validate_auth_tenant(req: Request, next: Next) -> Result<Response, AppError> {
|
||||
let path = req.uri().path();
|
||||
if path.ends_with("/auth/refresh") {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
|
||||
let tenant_id = parse_tenant_id_from_headers(&req).or_else(|| parse_tenant_id_from_query(&req));
|
||||
let Some(tenant_id) = tenant_id else {
|
||||
return Err(AppError::BadRequest(
|
||||
"Missing X-Tenant-ID header or tenant_id query".into(),
|
||||
));
|
||||
};
|
||||
|
||||
tracing::Span::current().record("tenant_id", tracing::field::display(tenant_id));
|
||||
|
||||
let mut req = req;
|
||||
req.extensions_mut().insert(TenantId(tenant_id));
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod auth_tenant;
|
||||
pub mod rate_limit;
|
||||
|
||||
pub use auth_kit::middleware::tenant::{TenantId, TenantMiddlewareConfig, resolve_tenant_with_config};
|
||||
pub use auth_kit::middleware::tenant::{
|
||||
TenantId, TenantMiddlewareConfig, resolve_tenant_with_config,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ use tower_governor::key_extractor::{KeyExtractor, PeerIpKeyExtractor, SmartIpKey
|
||||
use tracing::Instrument;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TrustedProxySmartIpKeyExtractor {
|
||||
pub struct TrustedProxySmartIpKeyExtractor {
|
||||
trusted_proxies: Vec<IpNet>,
|
||||
}
|
||||
|
||||
|
||||
270
src/presentation/http/api.rs
Normal file
270
src/presentation/http/api.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
|
||||
use crate::constants::CANONICAL_BASE;
|
||||
use crate::docs::ApiDoc;
|
||||
use crate::middleware as core_middleware;
|
||||
use crate::presentation::http::handlers::{
|
||||
app, auth, authorization, client, jwks, permission, platform, role, sso, tenant, user,
|
||||
};
|
||||
use crate::presentation::http::state::AppState;
|
||||
|
||||
pub fn routes() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("GET", "/.well-known/jwks.json"),
|
||||
("POST", "/tenants/register"),
|
||||
("POST", "/auth/register"),
|
||||
("POST", "/auth/login"),
|
||||
("POST", "/auth/login-code"),
|
||||
("POST", "/auth/refresh"),
|
||||
("POST", "/auth/logout"),
|
||||
("POST", "/auth/code2token"),
|
||||
("POST", "/internal/auth/code2token"),
|
||||
("GET", "/me/permissions"),
|
||||
("POST", "/authorize/check"),
|
||||
("GET", "/users"),
|
||||
("GET", "/users/{id}"),
|
||||
("PATCH", "/users/{id}"),
|
||||
("DELETE", "/users/{id}"),
|
||||
("POST", "/users/me/password/reset"),
|
||||
("POST", "/users/{id}/password/reset"),
|
||||
("GET", "/users/{id}/roles"),
|
||||
("PUT", "/users/{id}/roles"),
|
||||
("GET", "/permissions"),
|
||||
("GET", "/roles"),
|
||||
("POST", "/roles"),
|
||||
("GET", "/roles/{id}"),
|
||||
("PATCH", "/roles/{id}"),
|
||||
("DELETE", "/roles/{id}"),
|
||||
("POST", "/roles/{id}/permissions/grant"),
|
||||
("POST", "/roles/{id}/permissions/revoke"),
|
||||
("POST", "/roles/{id}/users/grant"),
|
||||
("POST", "/roles/{id}/users/revoke"),
|
||||
("GET", "/platform/tenants/{tenant_id}/enabled-apps"),
|
||||
("PUT", "/platform/tenants/{tenant_id}/enabled-apps"),
|
||||
("GET", "/platform/clients"),
|
||||
("POST", "/platform/clients"),
|
||||
("PUT", "/platform/clients/{client_id}/redirect-uris"),
|
||||
("POST", "/platform/clients/{client_id}/rotate-secret"),
|
||||
("GET", "/platform/apps"),
|
||||
("POST", "/platform/apps"),
|
||||
("GET", "/platform/apps/{app_id}"),
|
||||
("PATCH", "/platform/apps/{app_id}"),
|
||||
("DELETE", "/platform/apps/{app_id}"),
|
||||
("POST", "/platform/apps/{app_id}/status-change-requests"),
|
||||
("GET", "/platform/app-status-change-requests"),
|
||||
(
|
||||
"POST",
|
||||
"/platform/app-status-change-requests/{request_id}/approve",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/platform/app-status-change-requests/{request_id}/reject",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
let tenant_required_auth = Router::new()
|
||||
.route(
|
||||
"/register",
|
||||
post(auth::register_handler)
|
||||
.layer(core_middleware::rate_limit::register_rate_limiter())
|
||||
.layer(axum::middleware::from_fn(
|
||||
core_middleware::rate_limit::log_rate_limit_register,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/login",
|
||||
post(auth::login_handler)
|
||||
.layer(core_middleware::rate_limit::login_rate_limiter())
|
||||
.layer(axum::middleware::from_fn(
|
||||
core_middleware::rate_limit::log_rate_limit_login,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/login-code",
|
||||
post(sso::login_code_handler)
|
||||
.layer(core_middleware::rate_limit::login_rate_limiter())
|
||||
.layer(axum::middleware::from_fn(
|
||||
core_middleware::rate_limit::log_rate_limit_login,
|
||||
)),
|
||||
)
|
||||
.route("/code2token", post(sso::code2token_handler))
|
||||
.layer(axum::middleware::from_fn(
|
||||
core_middleware::auth_tenant::validate_auth_tenant,
|
||||
));
|
||||
|
||||
let no_tenant_auth = Router::new().route(
|
||||
"/refresh",
|
||||
post(auth::refresh_handler)
|
||||
.layer(core_middleware::rate_limit::login_rate_limiter())
|
||||
.layer(axum::middleware::from_fn(
|
||||
core_middleware::rate_limit::log_rate_limit_login,
|
||||
)),
|
||||
);
|
||||
|
||||
let public_v1 = Router::new()
|
||||
.route("/.well-known/jwks.json", get(jwks::jwks_handler))
|
||||
.route("/tenants/register", post(tenant::create_tenant_handler))
|
||||
.route(
|
||||
"/internal/auth/code2token",
|
||||
post(sso::internal_code2token_handler),
|
||||
)
|
||||
.nest(
|
||||
"/auth",
|
||||
Router::new()
|
||||
.merge(tenant_required_auth)
|
||||
.merge(no_tenant_auth),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
|
||||
let auth_cfg = core_middleware::auth::AuthMiddlewareConfig {
|
||||
skip_exact_paths: vec![],
|
||||
skip_path_prefixes: vec![],
|
||||
jwt: auth_kit::jwt::JwtVerifyConfig::rs256_from_pem(
|
||||
"iam-service",
|
||||
&crate::utils::keys::get_keys().public_pem,
|
||||
)
|
||||
.expect("invalid JWT_PUBLIC_KEY_PEM"),
|
||||
};
|
||||
let tenant_cfg = core_middleware::TenantMiddlewareConfig {
|
||||
skip_exact_paths: vec![],
|
||||
skip_path_prefixes: vec![],
|
||||
};
|
||||
|
||||
let protected_v1 = Router::new()
|
||||
.route("/auth/logout", post(auth::logout_handler))
|
||||
.route("/me/permissions", get(authorization::my_permissions_handler))
|
||||
.route(
|
||||
"/authorize/check",
|
||||
post(authorization::authorization_check_handler),
|
||||
)
|
||||
.route("/users", get(user::list_users_handler))
|
||||
.route(
|
||||
"/users/me/password/reset",
|
||||
post(user::reset_my_password_handler),
|
||||
)
|
||||
.route("/permissions", get(permission::list_permissions_handler))
|
||||
.route(
|
||||
"/users/{id}",
|
||||
get(user::get_user_handler)
|
||||
.patch(user::update_user_handler)
|
||||
.delete(user::delete_user_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/password/reset",
|
||||
post(user::reset_user_password_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/roles",
|
||||
get(user::list_user_roles_handler).put(user::set_user_roles_handler),
|
||||
)
|
||||
.route(
|
||||
"/roles",
|
||||
get(role::list_roles_handler).post(role::create_role_handler),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}",
|
||||
get(role::get_role_handler)
|
||||
.patch(role::update_role_handler)
|
||||
.delete(role::delete_role_handler),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}/permissions/grant",
|
||||
post(role::grant_role_permissions_handler),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}/permissions/revoke",
|
||||
post(role::revoke_role_permissions_handler),
|
||||
)
|
||||
.route("/roles/{id}/users/grant", post(role::grant_role_users_handler))
|
||||
.route(
|
||||
"/roles/{id}/users/revoke",
|
||||
post(role::revoke_role_users_handler),
|
||||
)
|
||||
.route(
|
||||
"/tenants/me",
|
||||
get(tenant::get_tenant_handler)
|
||||
.patch(tenant::update_tenant_handler)
|
||||
.delete(tenant::delete_tenant_handler),
|
||||
)
|
||||
.route("/tenants/me/status", post(tenant::update_tenant_status_handler))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
auth_cfg.clone(),
|
||||
core_middleware::auth::authenticate_with_config,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
tenant_cfg.clone(),
|
||||
core_middleware::resolve_tenant_with_config,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let platform_v1 = Router::new()
|
||||
.route(
|
||||
"/platform/tenants/{tenant_id}/enabled-apps",
|
||||
get(platform::get_tenant_enabled_apps_handler)
|
||||
.put(platform::set_tenant_enabled_apps_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients",
|
||||
get(client::list_clients_handler).post(client::create_client_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients/{client_id}/rotate-secret",
|
||||
post(client::rotate_client_secret_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients/{client_id}/redirect-uris",
|
||||
put(client::update_client_redirect_uris_handler),
|
||||
)
|
||||
.route("/platform/apps", get(app::list_apps_handler).post(app::create_app_handler))
|
||||
.route(
|
||||
"/platform/apps/{app_id}",
|
||||
get(app::get_app_handler)
|
||||
.patch(app::update_app_handler)
|
||||
.delete(app::delete_app_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/apps/{app_id}/status-change-requests",
|
||||
post(app::request_app_status_change_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/app-status-change-requests",
|
||||
get(app::list_app_status_change_requests_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/app-status-change-requests/{request_id}/approve",
|
||||
post(app::approve_app_status_change_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/app-status-change-requests/{request_id}/reject",
|
||||
post(app::reject_app_status_change_handler),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
auth_cfg,
|
||||
core_middleware::auth::authenticate_with_config,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let v1 = Router::new()
|
||||
.merge(public_v1)
|
||||
.merge(protected_v1)
|
||||
.merge(platform_v1)
|
||||
.layer(axum::middleware::from_fn(
|
||||
common_telemetry::axum_middleware::trace_http_request,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.route(
|
||||
"/favicon.ico",
|
||||
get(|| async { axum::http::StatusCode::NO_CONTENT }),
|
||||
)
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.nest(CANONICAL_BASE, v1)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{
|
||||
App, AppStatusChangeRequest, ApproveAppStatusChangeRequest, CreateAppRequest, ListAppsQuery,
|
||||
RequestAppStatusChangeRequest, UpdateAppRequest,
|
||||
};
|
||||
use crate::presentation::http::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
@@ -14,7 +14,10 @@ use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn require_sensitive_token(headers: &HeaderMap) -> Result<(), AppError> {
|
||||
let Some(expected) = std::env::var("IAM_SENSITIVE_ACTION_TOKEN").ok().filter(|v| !v.is_empty()) else {
|
||||
let Some(expected) = std::env::var("IAM_SENSITIVE_ACTION_TOKEN")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let provided = headers
|
||||
@@ -25,12 +28,12 @@ fn require_sensitive_token(headers: &HeaderMap) -> Result<(), AppError> {
|
||||
if provided == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::PermissionDenied("sensitive:token_required".into()))
|
||||
Err(AppError::PermissionDenied(
|
||||
"sensitive:token_required".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create app (registry).
|
||||
/// 创建应用(应用注册表)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/platform/apps",
|
||||
@@ -63,8 +66,6 @@ pub async fn create_app_handler(
|
||||
Ok(AppResponse::created(app))
|
||||
}
|
||||
|
||||
/// List apps (registry).
|
||||
/// 查询应用列表(分页/筛选/排序)。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/platform/apps",
|
||||
@@ -97,8 +98,6 @@ pub async fn list_apps_handler(
|
||||
Ok(AppResponse::ok(apps))
|
||||
}
|
||||
|
||||
/// Get app by id (registry).
|
||||
/// 查询应用详情。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/platform/apps/{app_id}",
|
||||
@@ -131,8 +130,6 @@ pub async fn get_app_handler(
|
||||
Ok(AppResponse::ok(app))
|
||||
}
|
||||
|
||||
/// Update app (registry).
|
||||
/// 更新应用基础信息。
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/platform/apps/{app_id}",
|
||||
@@ -171,8 +168,6 @@ pub async fn update_app_handler(
|
||||
Ok(AppResponse::ok(app))
|
||||
}
|
||||
|
||||
/// Request app status change (enable/disable).
|
||||
/// 申请应用上下线(需要审批,可设置生效时间)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/platform/apps/{app_id}/status-change-requests",
|
||||
@@ -211,8 +206,6 @@ pub async fn request_app_status_change_handler(
|
||||
Ok(AppResponse::created(req))
|
||||
}
|
||||
|
||||
/// List app status change requests.
|
||||
/// 查询应用状态变更审批单列表。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/platform/app-status-change-requests",
|
||||
@@ -245,9 +238,7 @@ pub async fn list_app_status_change_requests_handler(
|
||||
.await?;
|
||||
let status = params.get("status").cloned();
|
||||
let page = params.get("page").and_then(|v| v.parse::<u32>().ok());
|
||||
let page_size = params
|
||||
.get("page_size")
|
||||
.and_then(|v| v.parse::<u32>().ok());
|
||||
let page_size = params.get("page_size").and_then(|v| v.parse::<u32>().ok());
|
||||
let rows = state
|
||||
.app_service
|
||||
.list_status_change_requests(status, page, page_size)
|
||||
@@ -255,8 +246,6 @@ pub async fn list_app_status_change_requests_handler(
|
||||
Ok(AppResponse::ok(rows))
|
||||
}
|
||||
|
||||
/// Approve app status change request.
|
||||
/// 审批通过应用状态变更审批单。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/platform/app-status-change-requests/{request_id}/approve",
|
||||
@@ -295,8 +284,6 @@ pub async fn approve_app_status_change_handler(
|
||||
Ok(AppResponse::ok(row))
|
||||
}
|
||||
|
||||
/// Reject app status change request.
|
||||
/// 驳回应用状态变更审批单。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/platform/app-status-change-requests/{request_id}/reject",
|
||||
@@ -336,8 +323,6 @@ pub async fn reject_app_status_change_handler(
|
||||
Ok(AppResponse::ok(row))
|
||||
}
|
||||
|
||||
/// Delete app (soft delete).
|
||||
/// 删除应用(软删除,标记 status=deleted)。
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/platform/apps/{app_id}",
|
||||
@@ -372,4 +357,3 @@ pub async fn delete_app_handler(
|
||||
state.app_service.delete_app(&app_id, user_id).await?;
|
||||
Ok(AppResponse::ok(serde_json::json!({})))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::presentation::http::state::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{
|
||||
@@ -8,8 +8,6 @@ use axum::{Json, extract::State};
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
use tracing::instrument;
|
||||
|
||||
/// Register (create user in tenant).
|
||||
/// 注册接口(在租户下创建用户)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/register",
|
||||
@@ -26,26 +24,17 @@ use tracing::instrument;
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
pub async fn register_handler(
|
||||
// 1. 自动注入 TenantId (由中间件解析)
|
||||
TenantId(tenant_id): TenantId,
|
||||
// 2. 获取全局状态中的 Service
|
||||
State(state): State<AppState>,
|
||||
// 3. 获取 Body
|
||||
Json(payload): Json<CreateUserRequest>,
|
||||
) -> Result<AppResponse<UserResponse>, AppError> {
|
||||
let user = state.auth_service.register(tenant_id, payload).await?;
|
||||
|
||||
// 转换为 Response DTO (隐藏密码等敏感信息)
|
||||
let response = UserResponse {
|
||||
Ok(AppResponse::created(UserResponse {
|
||||
id: user.id,
|
||||
email: user.email.clone(),
|
||||
};
|
||||
|
||||
Ok(AppResponse::created(response))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Login (issue access token).
|
||||
/// 登录接口(签发访问令牌)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/login",
|
||||
@@ -67,12 +56,9 @@ pub async fn login_handler(
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<AppResponse<LoginResponse>, AppError> {
|
||||
let response = state.auth_service.login(tenant_id, payload).await?;
|
||||
|
||||
Ok(AppResponse::ok(response))
|
||||
}
|
||||
|
||||
/// Refresh access token (rotate refresh token).
|
||||
/// 刷新访问令牌(同时轮换 refresh_token,一次性使用)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/refresh",
|
||||
@@ -95,8 +81,6 @@ pub async fn refresh_handler(
|
||||
Ok(AppResponse::ok(response))
|
||||
}
|
||||
|
||||
/// Logout (revoke all refresh tokens for current user).
|
||||
/// 退出登录(吊销当前用户所有 refresh token)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/logout",
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse};
|
||||
use crate::presentation::http::state::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
use tracing::instrument;
|
||||
use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -24,22 +24,6 @@ use crate::models::{AuthorizationCheckRequest, AuthorizationCheckResponse};
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// List current user's permissions in current tenant.
|
||||
/// 查询当前登录用户在当前租户下的权限编码列表。
|
||||
///
|
||||
/// 用途:
|
||||
/// - 快速自查当前令牌是否携带期望的权限(便于联调与排障)。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Header `X-Tenant-ID`(可选;若提供需与 Token 中 tenant_id 一致,否则返回 403)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:权限字符串数组(如 `user:read`)
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未携带或无法解析访问令牌
|
||||
/// - `403`:租户不匹配或无权访问
|
||||
pub async fn my_permissions_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::presentation::http::state::AppState;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{
|
||||
ClientSummary, CreateClientRequest, CreateClientResponse, RotateClientSecretResponse,
|
||||
@@ -11,8 +11,6 @@ use axum::{
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
use tracing::instrument;
|
||||
|
||||
/// Create a new client and return its secret (shown once).
|
||||
/// 创建 client 并返回 clientSecret(仅展示一次)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/platform/clients",
|
||||
@@ -57,8 +55,6 @@ pub async fn create_client_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update allowed redirect URIs for a client.
|
||||
/// 更新 client 的允许回调地址(redirectUris)。
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/platform/clients/{client_id}/redirect-uris",
|
||||
@@ -98,8 +94,6 @@ pub async fn update_client_redirect_uris_handler(
|
||||
Ok(AppResponse::ok(serde_json::json!({})))
|
||||
}
|
||||
|
||||
/// Rotate client secret (previous secret stays valid for grace period).
|
||||
/// 轮换 clientSecret(旧密钥在宽限期内仍可用)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/platform/clients/{client_id}/rotate-secret",
|
||||
@@ -129,18 +123,13 @@ pub async fn rotate_client_secret_handler(
|
||||
.require_platform_permission(user_id, "iam:client:write")
|
||||
.await?;
|
||||
|
||||
let secret = state
|
||||
.client_service
|
||||
.rotate_secret(client_id.clone())
|
||||
.await?;
|
||||
let secret = state.client_service.rotate_secret(client_id.clone()).await?;
|
||||
Ok(AppResponse::ok(RotateClientSecretResponse {
|
||||
client_id,
|
||||
client_secret: secret,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List clients (secrets are never returned).
|
||||
/// 查询 client 列表(不返回 secret)。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/platform/clients",
|
||||
@@ -91,3 +91,4 @@ mod tests {
|
||||
assert!(jwks.keys.len() >= 2);
|
||||
}
|
||||
}
|
||||
|
||||
11
src/presentation/http/handlers/mod.rs
Normal file
11
src/presentation/http/handlers/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod auth;
|
||||
pub mod app;
|
||||
pub mod authorization;
|
||||
pub mod client;
|
||||
pub mod jwks;
|
||||
pub mod permission;
|
||||
pub mod platform;
|
||||
pub mod role;
|
||||
pub mod sso;
|
||||
pub mod tenant;
|
||||
pub mod user;
|
||||
48
src/presentation/http/handlers/permission.rs
Normal file
48
src/presentation/http/handlers/permission.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::middleware::TenantId;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{ListPermissionsQuery, Permission};
|
||||
use crate::presentation::http::state::AppState;
|
||||
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,6 +1,6 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{TenantEnabledAppsResponse, UpdateTenantEnabledAppsRequest};
|
||||
use crate::presentation::http::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
@@ -9,8 +9,6 @@ use common_telemetry::{AppError, AppResponse};
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Get tenant enabled apps (platform scope).
|
||||
/// 平台层:查询租户已开通应用(enabled_apps)。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/platform/tenants/{tenant_id}/enabled-apps",
|
||||
@@ -48,8 +46,6 @@ pub async fn get_tenant_enabled_apps_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set tenant enabled apps (platform scope, full replace).
|
||||
/// 平台层:设置租户已开通应用(enabled_apps,全量覆盖)。
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/platform/tenants/{tenant_id}/enabled-apps",
|
||||
@@ -93,3 +89,4 @@ pub async fn set_tenant_enabled_apps_handler(
|
||||
updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{
|
||||
CreateRoleRequest, RolePermissionsRequest, RoleResponse, RoleUsersRequest, UpdateRoleRequest,
|
||||
};
|
||||
use crate::presentation::http::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
@@ -32,36 +32,6 @@ use uuid::Uuid;
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 在当前租户下创建自定义角色(Role)。
|
||||
///
|
||||
/// 本接口用于创建 **租户级自定义角色**(`roles.tenant_id = 当前租户`)。创建后可继续通过
|
||||
/// “角色-权限绑定”接口为角色授予权限,再通过“用户-角色绑定”接口把角色授予用户。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(内部包含 `Uuid`):当前请求的租户 ID(由中间件解析并注入)。
|
||||
/// - `State(state): State<AppState>`:应用状态,包含 `RoleService`/`AuthorizationService` 等依赖。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:
|
||||
/// 从 `Authorization: Bearer <access_token>` 解析得到的认证上下文,包含调用方用户 ID 与租户 ID。
|
||||
/// - `Json(payload): Json<CreateRoleRequest>`:创建角色的请求体(角色名与描述)。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<RoleResponse>)`:创建成功返回 `201`,`data` 为新建角色(含 `id/name/description`)。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:Token 中租户与当前租户不一致(跨租户访问被拒绝)。
|
||||
/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。
|
||||
/// - `Err(AppError::AlreadyExists(_))`:角色名冲突(同租户下同名角色已存在)。
|
||||
/// - `Err(AppError::DbError(_))`:数据库写入失败(连接异常、约束失败等)。
|
||||
///
|
||||
/// ## Example
|
||||
/// ```rust,ignore
|
||||
/// // POST /roles
|
||||
/// // Headers:
|
||||
/// // Authorization: Bearer <access_token>
|
||||
/// // X-Tenant-ID: <tenant_uuid> // 可选,但若提供必须与 token 中 tenant_id 一致
|
||||
/// // Body:
|
||||
/// // { "name": "ContentAdmin", "description": "CMS content admins" }
|
||||
/// ```
|
||||
pub async fn create_role_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -109,23 +79,6 @@ pub async fn create_role_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// 查询当前租户下的角色列表(Role)。
|
||||
///
|
||||
/// 返回当前租户的所有角色(包含系统角色与自定义角色)。系统角色通常 `is_system=true`,
|
||||
/// 用于内置管理员/平台管理员等能力;自定义角色用于业务场景的权限组合。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前请求的租户 ID。
|
||||
/// - `State(state): State<AppState)`:应用状态。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<Vec<RoleResponse>>)`:成功返回 `200`,`data` 为角色数组。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("role:read"))`:调用方缺少 `role:read` 权限。
|
||||
/// - `Err(AppError::DbError(_))`:数据库查询失败。
|
||||
pub async fn list_roles_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -175,24 +128,6 @@ pub async fn list_roles_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// 查询角色详情(Role)。
|
||||
///
|
||||
/// 仅允许读取当前租户内的角色,避免跨租户信息泄露。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。
|
||||
/// - `State(state): State<AppState>`:应用状态。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
|
||||
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<RoleResponse>)`:成功返回 `200`,`data` 为角色信息。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("role:read"))`:调用方缺少 `role:read` 权限。
|
||||
/// - `Err(AppError::NotFound(_))`:角色不存在或不属于当前租户。
|
||||
/// - `Err(AppError::DbError(_))`:数据库查询失败。
|
||||
pub async fn get_role_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -240,28 +175,6 @@ pub async fn get_role_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 更新角色基础信息(Role)。
|
||||
///
|
||||
/// 仅允许修改 **自定义角色**。当目标角色为系统角色(`is_system=true`)时返回 403,
|
||||
/// 以防止线上误操作导致全局权限体系漂移。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。
|
||||
/// - `State(state): State<AppState>`:应用状态。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
|
||||
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
|
||||
/// - `Json(payload): Json<UpdateRoleRequest>`:可选更新字段(`name`/`description`)。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<RoleResponse>)`:成功返回 `200`,`data` 为更新后的角色信息。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。
|
||||
/// - `Err(AppError::PermissionDenied("role:system_immutable"))`:系统角色不可修改。
|
||||
/// - `Err(AppError::AlreadyExists(_))`:角色名冲突。
|
||||
/// - `Err(AppError::NotFound(_))`:角色不存在。
|
||||
/// - `Err(AppError::DbError(_))`:数据库写入失败。
|
||||
pub async fn update_role_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -311,26 +224,6 @@ pub async fn update_role_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// 删除角色(Role)。
|
||||
///
|
||||
/// 仅允许删除 **自定义角色**;系统角色(`is_system=true`)不可删除。
|
||||
/// 删除角色会级联删除 `user_roles` 与 `role_permissions` 关联(由外键约束实现)。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。
|
||||
/// - `State(state): State<AppState>`:应用状态。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
|
||||
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `200`,`data` 为 `{}`。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。
|
||||
/// - `Err(AppError::PermissionDenied("role:system_immutable"))`:系统角色不可删除。
|
||||
/// - `Err(AppError::NotFound(_))`:角色不存在。
|
||||
/// - `Err(AppError::DbError(_))`:数据库删除失败。
|
||||
pub async fn delete_role_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -377,38 +270,6 @@ pub async fn delete_role_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 为角色批量授予权限(Role → Permission 绑定)。
|
||||
///
|
||||
/// 将 `permission_codes` 批量写入 `role_permissions`(幂等:重复绑定不会报错)。
|
||||
/// 仅允许对 **自定义角色** 执行此操作;系统角色不可通过 API 修改权限集。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。
|
||||
/// - `State(state): State<AppState>`:应用状态。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
|
||||
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
|
||||
/// - `Json(payload): Json<RolePermissionsRequest>`:待授予的权限码数组(`Vec<String>`)。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `200`,`data` 为 `{}`。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。
|
||||
/// - `Err(AppError::PermissionDenied("role:system_immutable"))`:系统角色不可变更权限集。
|
||||
/// - `Err(AppError::BadRequest(_))`:存在非法的 `permission_codes`(权限码未在 `permissions` 表中定义)。
|
||||
/// - `Err(AppError::NotFound(_))`:角色不存在。
|
||||
/// - `Err(AppError::DbError(_))`:数据库写入失败。
|
||||
///
|
||||
/// ## Example
|
||||
/// ```rust,ignore
|
||||
/// // POST /roles/{role_id}/permissions/grant
|
||||
/// // Body:
|
||||
/// // { "permission_codes": ["cms:article:create", "cms:article:publish", "cms:*:*"] }
|
||||
/// //
|
||||
/// // 说明:
|
||||
/// // - 通配符权限(如 cms:*:*)只有在 permissions 表中存在并被绑定到角色时才会生效。
|
||||
/// ```
|
||||
pub async fn grant_role_permissions_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -456,27 +317,6 @@ pub async fn grant_role_permissions_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 从角色批量回收权限(Role → Permission 解绑)。
|
||||
///
|
||||
/// 从 `role_permissions` 删除指定 `permission_codes` 的关联(幂等:不存在的关联不会报错)。
|
||||
/// 仅允许对 **自定义角色** 执行此操作;系统角色不可通过 API 修改权限集。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。
|
||||
/// - `State(state): State<AppState>`:应用状态。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文。
|
||||
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
|
||||
/// - `Json(payload): Json<RolePermissionsRequest>`:待回收的权限码数组(`Vec<String>`)。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `200`,`data` 为 `{}`。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("role:write"))`:调用方缺少 `role:write` 权限。
|
||||
/// - `Err(AppError::PermissionDenied("role:system_immutable"))`:系统角色不可变更权限集。
|
||||
/// - `Err(AppError::NotFound(_))`:角色不存在。
|
||||
/// - `Err(AppError::DbError(_))`:数据库写入失败。
|
||||
pub async fn revoke_role_permissions_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -524,34 +364,6 @@ pub async fn revoke_role_permissions_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 批量把角色授予用户(User ← Role 绑定)。
|
||||
///
|
||||
/// 将 `user_ids` 批量写入 `user_roles`(幂等:重复授予不会报错)。
|
||||
/// 该接口用于“按角色”为多个用户授权,适用于组织/部门批量开通权限的场景。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。
|
||||
/// - `State(state): State<AppState>`:应用状态。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文(`user_id` 为操作者)。
|
||||
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
|
||||
/// - `Json(payload): Json<RoleUsersRequest>`:待授予用户 ID 列表(`Vec<Uuid>`)。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `200`,`data` 为 `{}`。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("user:write"))`:调用方缺少 `user:write` 权限。
|
||||
/// - `Err(AppError::NotFound(_))`:角色不存在。
|
||||
/// - `Err(AppError::BadRequest(_))`:存在非法 `user_ids`(用户不在当前租户内)。
|
||||
/// - `Err(AppError::DbError(_))`:数据库写入失败。
|
||||
///
|
||||
/// ## Example
|
||||
/// ```rust,ignore
|
||||
/// // POST /roles/{role_id}/users/grant
|
||||
/// // Body:
|
||||
/// // { "user_ids": ["<user_uuid_1>", "<user_uuid_2>"] }
|
||||
/// ```
|
||||
pub async fn grant_role_users_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -599,25 +411,6 @@ pub async fn grant_role_users_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 批量从用户回收角色(User ← Role 解绑)。
|
||||
///
|
||||
/// 从 `user_roles` 删除指定 `user_ids` 的角色关联(幂等:不存在的关联不会报错)。
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `TenantId(tenant_id): TenantId`(`Uuid`):当前租户 ID。
|
||||
/// - `State(state): State<AppState>`:应用状态。
|
||||
/// - `AuthContext { tenant_id: auth_tenant_id, user_id, .. }`:认证上下文(`user_id` 为操作者)。
|
||||
/// - `Path(role_id): Path<Uuid>`:目标角色 ID。
|
||||
/// - `Json(payload): Json<RoleUsersRequest>`:待回收用户 ID 列表(`Vec<Uuid>`)。
|
||||
///
|
||||
/// ## Returns
|
||||
/// - `Ok(AppResponse<serde_json::Value>)`:成功返回 `200`,`data` 为 `{}`。
|
||||
///
|
||||
/// ## Errors
|
||||
/// - `Err(AppError::PermissionDenied("tenant:mismatch"))`:跨租户访问被拒绝。
|
||||
/// - `Err(AppError::PermissionDenied("user:write"))`:调用方缺少 `user:write` 权限。
|
||||
/// - `Err(AppError::NotFound(_))`:角色不存在。
|
||||
/// - `Err(AppError::DbError(_))`:数据库写入失败。
|
||||
pub async fn revoke_role_users_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -1,16 +1,24 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::application::use_cases::exchange_code::{ExchangeCodeUseCase, Execute as _};
|
||||
use crate::presentation::http::state::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::models::{Code2TokenRequest, Code2TokenResponse, LoginCodeRequest, LoginCodeResponse};
|
||||
use anyhow::anyhow;
|
||||
use axum::{Json, extract::State};
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
|
||||
use redis::AsyncCommands;
|
||||
use redis::Script;
|
||||
use serde::Deserialize;
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn map_domain_error(e: crate::domain::DomainError) -> AppError {
|
||||
match e {
|
||||
crate::domain::DomainError::Unauthorized => AppError::AuthError("Unauthorized".into()),
|
||||
crate::domain::DomainError::InvalidArgument(s) => AppError::BadRequest(s),
|
||||
crate::domain::DomainError::Unexpected => AppError::AnyhowError(anyhow!("Unexpected")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, serde::Serialize)]
|
||||
struct AuthCodeClaims {
|
||||
sub: String,
|
||||
@@ -23,20 +31,10 @@ struct AuthCodeClaims {
|
||||
jti: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthCodeRedisValue {
|
||||
user_id: String,
|
||||
tenant_id: String,
|
||||
client_id: Option<String>,
|
||||
redirect_uri: Option<String>,
|
||||
}
|
||||
|
||||
fn redis_key(jti: &str) -> String {
|
||||
format!("iam:auth_code:{}", jti)
|
||||
}
|
||||
|
||||
/// Exchange one-time authorization code to access/refresh token.
|
||||
/// 授权码换取 token(一次性 code,5 分钟有效,单次使用)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/code2token",
|
||||
@@ -46,100 +44,102 @@ fn redis_key(jti: &str) -> String {
|
||||
(status = 200, description = "Token issued", body = Code2TokenResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
params(
|
||||
("X-Tenant-ID" = String, Header, description = "Tenant UUID (required for external calls)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
pub async fn code2token_handler(
|
||||
TenantId(expected_tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Code2TokenRequest>,
|
||||
) -> Result<AppResponse<Code2TokenResponse>, AppError> {
|
||||
let ok = state
|
||||
.tenant_config_repo
|
||||
.validate_client_pair(
|
||||
expected_tenant_id,
|
||||
&payload.client_id,
|
||||
&payload.client_secret,
|
||||
)
|
||||
.await
|
||||
.map_err(map_domain_error)?;
|
||||
if !ok {
|
||||
return Err(AppError::AuthError("Invalid client credentials".into()));
|
||||
}
|
||||
|
||||
let use_case = ExchangeCodeUseCase {
|
||||
auth_service: state.auth_service.clone(),
|
||||
redis: state.redis.clone(),
|
||||
auth_code_jwt_secret: state.auth_code_jwt_secret.clone(),
|
||||
};
|
||||
let res = use_case.execute(payload).await.map_err(map_domain_error)?;
|
||||
if res.tenant_id != expected_tenant_id {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
|
||||
Ok(AppResponse::ok(Code2TokenResponse {
|
||||
access_token: res.access_token,
|
||||
refresh_token: res.refresh_token,
|
||||
token_type: res.token_type,
|
||||
expires_in: res.expires_in,
|
||||
tenant_id: res.tenant_id.to_string(),
|
||||
user_id: res.user_id.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/internal/auth/code2token",
|
||||
tag = "Auth",
|
||||
request_body = Code2TokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Token issued", body = Code2TokenResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
params(
|
||||
("X-Internal-Token" = String, Header, description = "Pre-shared internal token"),
|
||||
("X-Tenant-ID" = Option<String>, Header, description = "Optional tenant UUID")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
pub async fn internal_code2token_handler(
|
||||
headers: axum::http::HeaderMap,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Code2TokenRequest>,
|
||||
) -> Result<AppResponse<Code2TokenResponse>, AppError> {
|
||||
let expected = std::env::var("INTERNAL_EXCHANGE_PSK")
|
||||
.map_err(|_| AppError::ConfigError("INTERNAL_EXCHANGE_PSK is required".into()))?;
|
||||
let token = headers
|
||||
.get("X-Internal-Token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if token != expected {
|
||||
return Err(AppError::AuthError("Invalid internal token".into()));
|
||||
}
|
||||
|
||||
state
|
||||
.client_service
|
||||
.verify_client_secret(&payload.client_id, &payload.client_secret)
|
||||
.await?;
|
||||
|
||||
if payload.code.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("code is required".into()));
|
||||
}
|
||||
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.set_issuer(&["iam-front", "iam-service"]);
|
||||
|
||||
let token_data = decode::<AuthCodeClaims>(
|
||||
payload.code.trim(),
|
||||
&DecodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(|e| AppError::AuthError(e.to_string()))?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
if let Some(cid) = &claims.client_id {
|
||||
if cid != payload.client_id.trim() {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
}
|
||||
let jti = claims.jti.trim();
|
||||
if jti.is_empty() {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
|
||||
let script = Script::new(
|
||||
r#"
|
||||
local v = redis.call('GET', KEYS[1])
|
||||
if v then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return v
|
||||
"#,
|
||||
);
|
||||
|
||||
let key = redis_key(jti);
|
||||
let mut conn = state.redis.clone();
|
||||
let val: Option<String> = script
|
||||
.key(key)
|
||||
.invoke_async(&mut conn)
|
||||
.await
|
||||
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
|
||||
|
||||
let Some(val) = val else {
|
||||
return Err(AppError::AuthError("Invalid or used code".into()));
|
||||
let use_case = ExchangeCodeUseCase {
|
||||
auth_service: state.auth_service.clone(),
|
||||
redis: state.redis.clone(),
|
||||
auth_code_jwt_secret: state.auth_code_jwt_secret.clone(),
|
||||
};
|
||||
|
||||
let stored: AuthCodeRedisValue =
|
||||
serde_json::from_str(&val).map_err(|_| AppError::AuthError("Invalid code".into()))?;
|
||||
|
||||
if let Some(cid) = stored.client_id.as_deref() {
|
||||
if cid != payload.client_id.trim() {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if stored.user_id != claims.sub || stored.tenant_id != claims.tenant_id {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
|
||||
let user_id =
|
||||
Uuid::parse_str(&stored.user_id).map_err(|_| AppError::AuthError("Invalid code".into()))?;
|
||||
let tenant_id = Uuid::parse_str(&stored.tenant_id)
|
||||
.map_err(|_| AppError::AuthError("Invalid code".into()))?;
|
||||
|
||||
let tokens = state
|
||||
.auth_service
|
||||
.issue_tokens_for_user(tenant_id, user_id, 7200)
|
||||
.await?;
|
||||
|
||||
let res = use_case.execute(payload).await.map_err(map_domain_error)?;
|
||||
Ok(AppResponse::ok(Code2TokenResponse {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_type: tokens.token_type,
|
||||
expires_in: tokens.expires_in,
|
||||
tenant_id: tenant_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
access_token: res.access_token,
|
||||
refresh_token: res.refresh_token,
|
||||
token_type: res.token_type,
|
||||
expires_in: res.expires_in,
|
||||
tenant_id: res.tenant_id.to_string(),
|
||||
user_id: res.user_id.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Login with username/password and issue one-time authorization code.
|
||||
/// 用户账户密码登录并签发一次性授权码(用于 SSO 授权码模式)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/login-code",
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{
|
||||
CreateTenantRequest, TenantResponse, UpdateTenantRequest, UpdateTenantStatusRequest,
|
||||
};
|
||||
use crate::presentation::http::state::AppState;
|
||||
use axum::{Json, extract::State, http::HeaderMap};
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
use tracing::instrument;
|
||||
@@ -43,21 +43,6 @@ fn require_sensitive_token(headers: &HeaderMap) -> Result<(), AppError> {
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// Create tenant (public endpoint).
|
||||
/// 创建租户(公开接口)。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 新租户默认 `status=active`。
|
||||
/// - `config` 未提供时默认 `{}`。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Body `CreateTenantRequest`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `201`:返回新建租户信息(含 `id`)
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:请求参数错误
|
||||
pub async fn create_tenant_handler(
|
||||
headers: HeaderMap,
|
||||
State(state): State<AppState>,
|
||||
@@ -65,13 +50,12 @@ pub async fn create_tenant_handler(
|
||||
) -> Result<AppResponse<TenantResponse>, AppError> {
|
||||
require_sensitive_token(&headers)?;
|
||||
let tenant = state.tenant_service.create_tenant(payload).await?;
|
||||
let response = TenantResponse {
|
||||
Ok(AppResponse::created(TenantResponse {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
status: tenant.status,
|
||||
config: tenant.config,
|
||||
};
|
||||
Ok(AppResponse::created(response))
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -92,23 +76,6 @@ pub async fn create_tenant_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// Get current tenant info.
|
||||
/// 获取当前登录用户所属租户的信息。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 若同时提供 `X-Tenant-ID` 与 Token 中租户不一致,返回 403(tenant:mismatch)。
|
||||
/// - 需要具备 `tenant:read` 权限。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Header `X-Tenant-ID`(可选;若提供需与 Token 一致)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:租户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn get_tenant_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -126,13 +93,12 @@ pub async fn get_tenant_handler(
|
||||
.require_permission(tenant_id, user_id, "tenant:read")
|
||||
.await?;
|
||||
let tenant = state.tenant_service.get_tenant(tenant_id).await?;
|
||||
let response = TenantResponse {
|
||||
Ok(AppResponse::ok(TenantResponse {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
status: tenant.status,
|
||||
config: tenant.config,
|
||||
};
|
||||
Ok(AppResponse::ok(response))
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -155,24 +121,6 @@ pub async fn get_tenant_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// Update current tenant (name / config).
|
||||
/// 更新当前租户的基础信息(名称 / 配置)。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 只允许更新当前登录租户;租户不一致返回 403。
|
||||
/// - 需要具备 `tenant:write` 权限。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Body `UpdateTenantRequest`:`name` / `config` 为可选字段,未提供则保持不变
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:更新后的租户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:请求参数错误
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn update_tenant_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -194,13 +142,12 @@ pub async fn update_tenant_handler(
|
||||
.tenant_service
|
||||
.update_tenant(tenant_id, payload)
|
||||
.await?;
|
||||
let response = TenantResponse {
|
||||
Ok(AppResponse::ok(TenantResponse {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
status: tenant.status,
|
||||
config: tenant.config,
|
||||
};
|
||||
Ok(AppResponse::ok(response))
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -223,24 +170,6 @@ pub async fn update_tenant_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// Update current tenant status (e.g. active / disabled).
|
||||
/// 更新当前租户状态(如 active / disabled)。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 只允许更新当前登录租户;租户不一致返回 403。
|
||||
/// - 需要具备 `tenant:write` 权限。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Body `UpdateTenantStatusRequest.status`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:更新后的租户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:请求参数错误
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn update_tenant_status_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -262,13 +191,12 @@ pub async fn update_tenant_status_handler(
|
||||
.tenant_service
|
||||
.update_tenant_status(tenant_id, payload)
|
||||
.await?;
|
||||
let response = TenantResponse {
|
||||
Ok(AppResponse::ok(TenantResponse {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
status: tenant.status,
|
||||
config: tenant.config,
|
||||
};
|
||||
Ok(AppResponse::ok(response))
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -290,20 +218,6 @@ pub async fn update_tenant_status_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// Delete current tenant.
|
||||
/// 删除当前租户。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 只允许删除当前登录租户;租户不一致返回 403。
|
||||
/// - 需要具备 `tenant:write` 权限。
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:删除成功(空响应)
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
/// - `404`:租户不存在
|
||||
pub async fn delete_tenant_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{
|
||||
AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, ResetMyPasswordRequest,
|
||||
RoleResponse, UpdateUserRequest, UpdateUserRolesRequest, UserResponse,
|
||||
};
|
||||
use crate::presentation::http::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
@@ -63,25 +63,6 @@ fn require_sensitive_token(headers: &HeaderMap) -> Result<(), AppError> {
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// List users in tenant with pagination.
|
||||
/// 分页查询当前租户下的用户列表。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅返回当前租户用户;租户不一致返回 403。
|
||||
/// - 需要具备 `user:read` 权限。
|
||||
/// - 分页参数约束:`page>=1`,`page_size` 范围 `1..=200`。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Query `page` / `page_size`(可选)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:用户列表
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:分页参数非法
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn list_users_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -140,24 +121,6 @@ pub async fn list_users_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// Get user by id.
|
||||
/// 根据用户 ID 查询用户详情。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅允许查询当前租户用户;租户不一致返回 403。
|
||||
/// - 需要具备 `user:read` 权限。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Path `id`:用户 UUID
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:用户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
/// - `404`:用户不存在
|
||||
pub async fn get_user_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -208,27 +171,6 @@ pub async fn get_user_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// Update user (currently supports updating email).
|
||||
/// 更新指定用户信息(目前支持更新邮箱)。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅允许更新当前租户用户;租户不一致返回 403。
|
||||
/// - 需要具备 `user:write` 权限。
|
||||
/// - `UpdateUserRequest` 中未提供的字段保持不变。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Path `id`:用户 UUID
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Body `UpdateUserRequest`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:更新后的用户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:请求参数错误
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
/// - `404`:用户不存在
|
||||
pub async fn update_user_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -278,24 +220,6 @@ pub async fn update_user_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// Delete user.
|
||||
/// 删除指定用户。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅允许删除当前租户用户;租户不一致返回 403。
|
||||
/// - 需要具备 `user:write` 权限。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Path `id`:用户 UUID
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:删除成功(空响应)
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
/// - `404`:用户不存在
|
||||
pub async fn delete_user_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -313,7 +237,6 @@ pub async fn delete_user_handler(
|
||||
.authorization_service
|
||||
.require_permission(tenant_id, user_id, "user:write")
|
||||
.await?;
|
||||
|
||||
state
|
||||
.user_service
|
||||
.delete_user(tenant_id, target_user_id)
|
||||
@@ -341,15 +264,6 @@ pub async fn delete_user_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// List roles bound to a user.
|
||||
/// 查询用户已绑定的角色列表。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅允许在当前租户内查询;租户不一致返回 403。
|
||||
/// - 需要具备 `user:read` 权限。
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:角色列表(角色名称与描述)
|
||||
pub async fn list_user_roles_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -410,14 +324,6 @@ pub async fn list_user_roles_handler(
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// Set user's roles (full replace, idempotent).
|
||||
/// 设置用户的角色绑定(全量覆盖,幂等)。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅允许在当前租户内操作;租户不一致返回 403。
|
||||
/// - 需要具备 `user:write` 权限。
|
||||
/// - `role_ids` 必须全部属于当前租户,否则返回 400。
|
||||
/// - 该接口为“全量覆盖”:会先清空用户在当前租户下的角色绑定,再按 `role_ids` 重新写入。
|
||||
pub async fn set_user_roles_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
@@ -453,8 +359,6 @@ pub async fn set_user_roles_handler(
|
||||
Ok(AppResponse::ok(response))
|
||||
}
|
||||
|
||||
/// Reset my password (requires current password).
|
||||
/// 重置自己的密码(需要提供旧密码)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/users/me/password/reset",
|
||||
@@ -500,8 +404,6 @@ pub async fn reset_my_password_handler(
|
||||
Ok(AppResponse::ok(serde_json::json!({})))
|
||||
}
|
||||
|
||||
/// Reset a user's password as tenant admin (generates temporary password).
|
||||
/// 租户管理员重置任意用户密码(生成临时密码)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/users/{id}/password/reset",
|
||||
3
src/presentation/http/mod.rs
Normal file
3
src/presentation/http/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod handlers;
|
||||
pub mod state;
|
||||
23
src/presentation/http/state.rs
Normal file
23
src/presentation/http/state.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use crate::application::services::{
|
||||
AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService,
|
||||
TenantService, UserService,
|
||||
};
|
||||
use crate::domain::repositories::tenant_config_repo::TenantConfigRepo;
|
||||
use redis::aio::ConnectionManager;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub auth_service: AuthService,
|
||||
pub client_service: ClientService,
|
||||
pub user_service: UserService,
|
||||
pub role_service: RoleService,
|
||||
pub tenant_service: TenantService,
|
||||
pub authorization_service: AuthorizationService,
|
||||
pub app_service: AppService,
|
||||
pub permission_service: PermissionService,
|
||||
pub redis: ConnectionManager,
|
||||
pub auth_code_jwt_secret: String,
|
||||
pub tenant_config_repo: Arc<dyn TenantConfigRepo>,
|
||||
}
|
||||
|
||||
1
src/presentation/mod.rs
Normal file
1
src/presentation/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod http;
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::utils::keys::get_keys;
|
||||
use common_telemetry::AppError;
|
||||
use jsonwebtoken::{Algorithm, Header, Validation, decode, encode};
|
||||
use jsonwebtoken::{Algorithm, Header, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
@@ -74,14 +74,3 @@ pub fn sign_with_ttl(
|
||||
header.kid = Some(keys.kid.clone());
|
||||
encode(&header, &claims, &keys.encoding_key).map_err(|e| AppError::AuthError(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn verify(token: &str) -> Result<Claims, AppError> {
|
||||
let keys = get_keys();
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.set_issuer(&["iam-service"]);
|
||||
|
||||
let token_data = decode::<Claims>(token, &keys.decoding_key, &validation)
|
||||
.map_err(|e| AppError::AuthError(e.to_string()))?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use std::sync::OnceLock;
|
||||
|
||||
pub struct KeyPair {
|
||||
pub encoding_key: jsonwebtoken::EncodingKey,
|
||||
pub decoding_key: jsonwebtoken::DecodingKey,
|
||||
pub kid: String,
|
||||
pub public_n: String,
|
||||
pub public_e: String,
|
||||
@@ -49,15 +48,12 @@ pub fn get_keys() -> &'static KeyPair {
|
||||
|
||||
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
|
||||
.expect("failed to create encoding key");
|
||||
let decoding_key = jsonwebtoken::DecodingKey::from_rsa_pem(public_pem.as_bytes())
|
||||
.expect("failed to create decoding key");
|
||||
|
||||
let public_n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
|
||||
let public_e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
|
||||
|
||||
KeyPair {
|
||||
encoding_key,
|
||||
decoding_key,
|
||||
kid,
|
||||
public_n,
|
||||
public_e,
|
||||
|
||||
@@ -3,5 +3,5 @@ pub mod jwt;
|
||||
pub mod keys;
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::{sign, sign_with_ttl, verify};
|
||||
pub use jwt::{sign, sign_with_ttl};
|
||||
pub use password::{hash_password, verify_password};
|
||||
|
||||
Reference in New Issue
Block a user