fix(sql): fix sql script
This commit is contained in:
24
src/docs.rs
24
src/docs.rs
@@ -1,8 +1,9 @@
|
||||
use crate::handlers;
|
||||
use crate::models::{
|
||||
CreateRoleRequest, CreateTenantRequest, CreateUserRequest, LoginRequest, LoginResponse, Role,
|
||||
RoleResponse, Tenant, TenantResponse, UpdateTenantRequest, UpdateTenantStatusRequest,
|
||||
UpdateUserRequest, User, UserResponse,
|
||||
RoleResponse, Tenant, TenantEnabledAppsResponse, TenantResponse,
|
||||
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
|
||||
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse,
|
||||
};
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
use utoipa::{Modify, OpenApi};
|
||||
@@ -34,10 +35,22 @@ impl Modify for SecurityAddon {
|
||||
version = "0.1.0",
|
||||
description = include_str!("../docs/SCALAR_GUIDE.md")
|
||||
),
|
||||
servers(
|
||||
(
|
||||
url = "https://{env}/api",
|
||||
description = "Environment server",
|
||||
variables(
|
||||
("env" = (default = "dev", enum_values("dev", "staging", "prod"))),
|
||||
("port" = (default = "5010"))
|
||||
)
|
||||
)
|
||||
),
|
||||
paths(
|
||||
handlers::auth::register_handler,
|
||||
handlers::auth::login_handler,
|
||||
handlers::authorization::my_permissions_handler,
|
||||
handlers::platform::get_tenant_enabled_apps_handler,
|
||||
handlers::platform::set_tenant_enabled_apps_handler,
|
||||
handlers::tenant::create_tenant_handler,
|
||||
handlers::tenant::get_tenant_handler,
|
||||
handlers::tenant::update_tenant_handler,
|
||||
@@ -49,6 +62,8 @@ impl Modify for SecurityAddon {
|
||||
handlers::user::get_user_handler,
|
||||
handlers::user::update_user_handler,
|
||||
handlers::user::delete_user_handler,
|
||||
handlers::user::list_user_roles_handler,
|
||||
handlers::user::set_user_roles_handler,
|
||||
// Add other handlers here as you implement them
|
||||
),
|
||||
components(
|
||||
@@ -66,7 +81,10 @@ impl Modify for SecurityAddon {
|
||||
TenantResponse,
|
||||
CreateTenantRequest,
|
||||
UpdateTenantRequest,
|
||||
UpdateTenantStatusRequest
|
||||
UpdateTenantStatusRequest,
|
||||
UpdateTenantEnabledAppsRequest,
|
||||
TenantEnabledAppsResponse,
|
||||
UpdateUserRolesRequest
|
||||
)
|
||||
),
|
||||
tags(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod authorization;
|
||||
pub mod auth;
|
||||
pub mod authorization;
|
||||
pub mod platform;
|
||||
pub mod role;
|
||||
pub mod tenant;
|
||||
pub mod user;
|
||||
@@ -8,13 +9,15 @@ use crate::services::{AuthService, AuthorizationService, RoleService, TenantServ
|
||||
|
||||
pub use auth::{login_handler, register_handler};
|
||||
pub use authorization::my_permissions_handler;
|
||||
pub use platform::{get_tenant_enabled_apps_handler, set_tenant_enabled_apps_handler};
|
||||
pub use role::{create_role_handler, list_roles_handler};
|
||||
pub use 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_users_handler, update_user_handler,
|
||||
delete_user_handler, get_user_handler, list_user_roles_handler, list_users_handler,
|
||||
set_user_roles_handler, update_user_handler,
|
||||
};
|
||||
|
||||
// 状态对象,包含 Service
|
||||
|
||||
92
src/handlers/platform.rs
Normal file
92
src/handlers/platform.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{TenantEnabledAppsResponse, UpdateTenantEnabledAppsRequest};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/platform/tenants/{tenant_id}/enabled-apps",
|
||||
tag = "Tenant",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取租户已开通应用列表", body = TenantEnabledAppsResponse),
|
||||
(status = 401, description = "未认证"),
|
||||
(status = 403, description = "无权限"),
|
||||
(status = 404, description = "未找到")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||
("tenant_id" = String, Path, description = "租户 UUID")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn get_tenant_enabled_apps_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthContext { user_id, .. }: AuthContext,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
) -> Result<AppResponse<TenantEnabledAppsResponse>, AppError> {
|
||||
state
|
||||
.authorization_service
|
||||
.require_platform_permission(user_id, "iam:tenant:enabled_apps:read")
|
||||
.await?;
|
||||
let (enabled_apps, version, updated_at) = state.tenant_service.get_enabled_apps(tenant_id).await?;
|
||||
Ok(AppResponse::ok(TenantEnabledAppsResponse {
|
||||
tenant_id,
|
||||
enabled_apps,
|
||||
version,
|
||||
updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/platform/tenants/{tenant_id}/enabled-apps",
|
||||
tag = "Tenant",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
request_body = UpdateTenantEnabledAppsRequest,
|
||||
responses(
|
||||
(status = 200, description = "更新租户已开通应用列表", body = TenantEnabledAppsResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未认证"),
|
||||
(status = 403, description = "无权限"),
|
||||
(status = 404, description = "未找到"),
|
||||
(status = 409, description = "版本冲突")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||
("tenant_id" = String, Path, description = "租户 UUID")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
pub async fn set_tenant_enabled_apps_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthContext { user_id, .. }: AuthContext,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateTenantEnabledAppsRequest>,
|
||||
) -> Result<AppResponse<TenantEnabledAppsResponse>, AppError> {
|
||||
state
|
||||
.authorization_service
|
||||
.require_platform_permission(user_id, "iam:tenant:enabled_apps:write")
|
||||
.await?;
|
||||
let (enabled_apps, version, updated_at) = state
|
||||
.tenant_service
|
||||
.set_enabled_apps(tenant_id, payload.enabled_apps, payload.expected_version, user_id)
|
||||
.await?;
|
||||
Ok(AppResponse::ok(TenantEnabledAppsResponse {
|
||||
tenant_id,
|
||||
enabled_apps,
|
||||
version,
|
||||
updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{UpdateUserRequest, UserResponse};
|
||||
use crate::models::{RoleResponse, UpdateUserRequest, UpdateUserRolesRequest, UserResponse};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
@@ -291,3 +291,133 @@ pub async fn delete_user_handler(
|
||||
.await?;
|
||||
Ok(AppResponse::ok_empty())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/users/{id}/roles",
|
||||
tag = "User",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "用户角色列表", body = [RoleResponse]),
|
||||
(status = 401, description = "未认证"),
|
||||
(status = 403, description = "无权限"),
|
||||
(status = 404, description = "未找到")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"),
|
||||
("id" = String, Path, description = "用户 UUID")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// 查询用户已绑定的角色列表。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅允许在当前租户内查询;租户不一致返回 403。
|
||||
/// - 需要具备 `user:read` 权限。
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:角色列表(角色名称与描述)
|
||||
pub async fn list_user_roles_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id: actor_user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Path(target_user_id): Path<Uuid>,
|
||||
) -> Result<AppResponse<Vec<RoleResponse>>, AppError> {
|
||||
if auth_tenant_id != tenant_id {
|
||||
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
|
||||
}
|
||||
state
|
||||
.authorization_service
|
||||
.require_permission(tenant_id, actor_user_id, "user:read")
|
||||
.await?;
|
||||
|
||||
state
|
||||
.user_service
|
||||
.get_user_by_id(tenant_id, target_user_id)
|
||||
.await?;
|
||||
|
||||
let roles = state
|
||||
.role_service
|
||||
.list_roles_for_user(tenant_id, target_user_id)
|
||||
.await?;
|
||||
let response = roles
|
||||
.into_iter()
|
||||
.map(|r| RoleResponse {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
})
|
||||
.collect();
|
||||
Ok(AppResponse::ok(response))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/users/{id}/roles",
|
||||
tag = "User",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
request_body = UpdateUserRolesRequest,
|
||||
responses(
|
||||
(status = 200, description = "用户角色更新成功", body = [RoleResponse]),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未认证"),
|
||||
(status = 403, description = "无权限"),
|
||||
(status = 404, description = "未找到")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"),
|
||||
("id" = String, Path, description = "用户 UUID")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 设置用户的角色绑定(全量覆盖,幂等)。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅允许在当前租户内操作;租户不一致返回 403。
|
||||
/// - 需要具备 `user:write` 权限。
|
||||
/// - `role_ids` 必须全部属于当前租户,否则返回 400。
|
||||
/// - 该接口为“全量覆盖”:会先清空用户在当前租户下的角色绑定,再按 `role_ids` 重新写入。
|
||||
pub async fn set_user_roles_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id: actor_user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Path(target_user_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateUserRolesRequest>,
|
||||
) -> Result<AppResponse<Vec<RoleResponse>>, AppError> {
|
||||
if auth_tenant_id != tenant_id {
|
||||
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
|
||||
}
|
||||
state
|
||||
.authorization_service
|
||||
.require_permission(tenant_id, actor_user_id, "user:write")
|
||||
.await?;
|
||||
|
||||
let roles = state
|
||||
.role_service
|
||||
.set_roles_for_user(tenant_id, target_user_id, payload.role_ids)
|
||||
.await?;
|
||||
|
||||
let response = roles
|
||||
.into_iter()
|
||||
.map(|r| RoleResponse {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
})
|
||||
.collect();
|
||||
Ok(AppResponse::ok(response))
|
||||
}
|
||||
|
||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod docs;
|
||||
pub mod handlers;
|
||||
pub mod middleware;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
|
||||
23
src/main.rs
23
src/main.rs
@@ -16,9 +16,11 @@ use axum::{
|
||||
use config::AppConfig;
|
||||
use handlers::{
|
||||
AppState, create_role_handler, create_tenant_handler, delete_tenant_handler,
|
||||
delete_user_handler, get_tenant_handler, get_user_handler, list_roles_handler,
|
||||
list_users_handler, login_handler, my_permissions_handler, register_handler,
|
||||
update_tenant_handler, update_tenant_status_handler, update_user_handler,
|
||||
delete_user_handler, get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler,
|
||||
list_roles_handler, list_user_roles_handler, list_users_handler, login_handler,
|
||||
my_permissions_handler, register_handler, set_tenant_enabled_apps_handler,
|
||||
set_user_roles_handler, update_tenant_handler, update_tenant_status_handler,
|
||||
update_user_handler,
|
||||
};
|
||||
use services::{AuthService, AuthorizationService, RoleService, TenantService, UserService};
|
||||
use std::net::SocketAddr;
|
||||
@@ -110,6 +112,10 @@ async fn main() {
|
||||
.patch(update_user_handler)
|
||||
.delete(delete_user_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))
|
||||
.layer(from_fn(middleware::resolve_tenant))
|
||||
.layer(from_fn(middleware::auth::authenticate))
|
||||
@@ -117,9 +123,20 @@ async fn main() {
|
||||
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),
|
||||
)
|
||||
.layer(from_fn(middleware::auth::authenticate))
|
||||
.layer(from_fn(
|
||||
common_telemetry::axum_middleware::trace_http_request,
|
||||
));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.merge(platform_api)
|
||||
.merge(api)
|
||||
.with_state(state);
|
||||
|
||||
|
||||
@@ -192,3 +192,29 @@ pub struct TenantResponse {
|
||||
#[serde(default = "default_json_object")]
|
||||
pub config: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct UpdateTenantEnabledAppsRequest {
|
||||
#[serde(default)]
|
||||
pub enabled_apps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub expected_version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema, IntoParams)]
|
||||
pub struct TenantEnabledAppsResponse {
|
||||
#[serde(default = "default_uuid")]
|
||||
pub tenant_id: Uuid,
|
||||
#[serde(default)]
|
||||
pub enabled_apps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub version: i32,
|
||||
#[serde(default)]
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct UpdateUserRolesRequest {
|
||||
#[serde(default)]
|
||||
pub role_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User};
|
||||
use crate::utils::authz::filter_permissions_by_enabled_apps;
|
||||
use crate::utils::{hash_password, sign, verify_password};
|
||||
use common_telemetry::AppError;
|
||||
use rand::RngCore;
|
||||
@@ -143,8 +144,29 @@ impl AuthService {
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec<String>, i32)>(
|
||||
r#"
|
||||
SELECT enabled_apps, version
|
||||
FROM tenant_entitlements
|
||||
WHERE tenant_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user.tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.unwrap_or_else(|| (vec![], 0));
|
||||
|
||||
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
|
||||
|
||||
// 3. 签发 Access Token
|
||||
let access_token = sign(user.id, user.tenant_id, roles, permissions)?;
|
||||
let access_token = sign(
|
||||
user.id,
|
||||
user.tenant_id,
|
||||
roles,
|
||||
permissions,
|
||||
enabled_apps,
|
||||
apps_version,
|
||||
)?;
|
||||
|
||||
// 4. 生成 Refresh Token
|
||||
let mut refresh_bytes = [0u8; 32];
|
||||
@@ -197,11 +219,14 @@ impl AuthService {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT $1, p.id FROM permissions p
|
||||
SELECT $1, p.id
|
||||
FROM permissions p
|
||||
WHERE ($2::uuid = '00000000-0000-0000-0000-000000000001' OR p.code NOT LIKE 'iam:%')
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(role_id)
|
||||
.bind(tenant_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::utils::authz::filter_permissions_by_enabled_apps;
|
||||
use common_telemetry::AppError;
|
||||
use sqlx::PgPool;
|
||||
use tracing::instrument;
|
||||
@@ -34,6 +35,13 @@ impl AuthorizationService {
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let enabled_apps: Vec<String> =
|
||||
sqlx::query_scalar("SELECT enabled_apps FROM tenant_entitlements WHERE tenant_id = $1")
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
let query = r#"
|
||||
SELECT DISTINCT p.code
|
||||
FROM permissions p
|
||||
@@ -47,7 +55,7 @@ impl AuthorizationService {
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
Ok(filter_permissions_by_enabled_apps(rows, &enabled_apps))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
@@ -76,4 +84,38 @@ impl AuthorizationService {
|
||||
Err(AppError::PermissionDenied(permission_code.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_platform_permissions_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let query = r#"
|
||||
SELECT DISTINCT p.code
|
||||
FROM permissions p
|
||||
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||
JOIN user_roles ur ON ur.role_id = rp.role_id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = $1 AND r.is_system = TRUE
|
||||
"#;
|
||||
let rows = sqlx::query_scalar::<_, String>(query)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn require_platform_permission(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
permission_code: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let permissions = self.list_platform_permissions_for_user(user_id).await?;
|
||||
if permissions.iter().any(|p| p == permission_code) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::PermissionDenied(permission_code.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,123 @@ impl RoleService {
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, role_ids))]
|
||||
pub async fn list_roles_by_ids(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
role_ids: &[Uuid],
|
||||
) -> Result<Vec<Role>, AppError> {
|
||||
if role_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let query = "SELECT id, tenant_id, name, description FROM roles WHERE tenant_id = $1 AND id = ANY($2)";
|
||||
sqlx::query_as::<_, Role>(query)
|
||||
.bind(tenant_id)
|
||||
.bind(role_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_roles_for_user(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
target_user_id: Uuid,
|
||||
) -> Result<Vec<Role>, AppError> {
|
||||
let query = r#"
|
||||
SELECT r.id, r.tenant_id, r.name, r.description
|
||||
FROM roles r
|
||||
JOIN user_roles ur ON ur.role_id = r.id
|
||||
WHERE r.tenant_id = $1 AND ur.user_id = $2
|
||||
"#;
|
||||
sqlx::query_as::<_, Role>(query)
|
||||
.bind(tenant_id)
|
||||
.bind(target_user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::DbError(e))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, role_ids))]
|
||||
pub async fn set_roles_for_user(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
target_user_id: Uuid,
|
||||
role_ids: Vec<Uuid>,
|
||||
) -> Result<Vec<Role>, AppError> {
|
||||
let unique: Vec<Uuid> = {
|
||||
let mut s = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for id in role_ids {
|
||||
if s.insert(id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let exists: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM users WHERE tenant_id = $1 AND id = $2",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(target_user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Err(AppError::NotFound("User not found".into()));
|
||||
}
|
||||
|
||||
let roles = if unique.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
let found = sqlx::query_as::<_, Role>(
|
||||
"SELECT id, tenant_id, name, description FROM roles WHERE tenant_id = $1 AND id = ANY($2)",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(&unique)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(AppError::DbError)?;
|
||||
|
||||
if found.len() != unique.len() {
|
||||
return Err(AppError::BadRequest("Invalid role_ids".into()));
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM user_roles ur
|
||||
USING roles r
|
||||
WHERE ur.role_id = r.id
|
||||
AND r.tenant_id = $1
|
||||
AND ur.user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(target_user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !unique.is_empty() {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, UNNEST($2::uuid[])
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(target_user_id)
|
||||
.bind(&unique)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
use crate::models::{
|
||||
CreateTenantRequest, Tenant, UpdateTenantRequest, UpdateTenantStatusRequest,
|
||||
};
|
||||
use crate::models::{CreateTenantRequest, Tenant, UpdateTenantRequest, UpdateTenantStatusRequest};
|
||||
use common_telemetry::AppError;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EnabledAppsCacheEntry {
|
||||
enabled_apps: Vec<String>,
|
||||
version: i32,
|
||||
updated_at: String,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TenantService {
|
||||
pool: PgPool,
|
||||
enabled_apps_cache: Arc<RwLock<HashMap<Uuid, EnabledAppsCacheEntry>>>,
|
||||
}
|
||||
|
||||
impl TenantService {
|
||||
/// 创建租户服务实例。
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
Self {
|
||||
pool,
|
||||
enabled_apps_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, req))]
|
||||
@@ -31,17 +45,43 @@ impl TenantService {
|
||||
/// 异常:
|
||||
/// - 数据库写入失败(如连接异常、约束失败等)
|
||||
pub async fn create_tenant(&self, req: CreateTenantRequest) -> Result<Tenant, AppError> {
|
||||
let config = req.config.unwrap_or_else(|| Value::Object(Default::default()));
|
||||
let mut config = req
|
||||
.config
|
||||
.unwrap_or_else(|| Value::Object(Default::default()));
|
||||
if !config.is_object() {
|
||||
config = Value::Object(Default::default());
|
||||
}
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
obj.insert("enabled_apps".to_string(), Value::Array(vec![]));
|
||||
obj.insert(
|
||||
"enabled_apps_version".to_string(),
|
||||
Value::Number(serde_json::Number::from(0)),
|
||||
);
|
||||
}
|
||||
let query = 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(&self.pool)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
|
||||
VALUES ($1, '{}'::text[], 0)
|
||||
ON CONFLICT (tenant_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(tenant.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(tenant)
|
||||
}
|
||||
|
||||
@@ -131,4 +171,228 @@ impl TenantService {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_enabled_apps(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> Result<(Vec<String>, i32, String), AppError> {
|
||||
let now = Instant::now();
|
||||
if let Some(hit) = self
|
||||
.enabled_apps_cache
|
||||
.read()
|
||||
.await
|
||||
.get(&tenant_id)
|
||||
.cloned()
|
||||
{
|
||||
if hit.expires_at > now {
|
||||
return Ok((hit.enabled_apps, hit.version, hit.updated_at));
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, (Vec<String>, i32, chrono::DateTime<chrono::Utc>)>(
|
||||
r#"
|
||||
SELECT enabled_apps, version, updated_at
|
||||
FROM tenant_entitlements
|
||||
WHERE tenant_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let (enabled_apps, version, updated_at) = match row {
|
||||
Some((apps, v, ts)) => (apps, v, ts.to_rfc3339()),
|
||||
None => {
|
||||
let exists: Option<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM tenants WHERE id = $1")
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Err(AppError::NotFound("Tenant not found".into()));
|
||||
}
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
(vec![], 0, ts)
|
||||
}
|
||||
};
|
||||
|
||||
let ttl = Duration::from_secs(60);
|
||||
self.enabled_apps_cache.write().await.insert(
|
||||
tenant_id,
|
||||
EnabledAppsCacheEntry {
|
||||
enabled_apps: enabled_apps.clone(),
|
||||
version,
|
||||
updated_at: updated_at.clone(),
|
||||
expires_at: now + ttl,
|
||||
},
|
||||
);
|
||||
Ok((enabled_apps, version, updated_at))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, enabled_apps))]
|
||||
pub async fn set_enabled_apps(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
enabled_apps: Vec<String>,
|
||||
expected_version: Option<i32>,
|
||||
actor_user_id: Uuid,
|
||||
) -> Result<(Vec<String>, i32, String), AppError> {
|
||||
let normalized = normalize_apps(enabled_apps);
|
||||
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
|
||||
FROM tenant_entitlements
|
||||
WHERE tenant_id = $1
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if current.is_none() {
|
||||
let exists: Option<Uuid> = sqlx::query_scalar("SELECT id FROM tenants WHERE id = $1")
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Err(AppError::NotFound("Tenant not found".into()));
|
||||
}
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
|
||||
VALUES ($1, '{}'::text[], 0)
|
||||
ON CONFLICT (tenant_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let (new_version, updated_at): (i32, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
|
||||
r#"
|
||||
UPDATE tenant_entitlements
|
||||
SET enabled_apps = $1,
|
||||
version = version + 1,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = $2
|
||||
RETURNING version, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(&normalized)
|
||||
.bind(tenant_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET config =
|
||||
jsonb_set(
|
||||
jsonb_set(COALESCE(config, '{}'::jsonb), '{enabled_apps}', to_jsonb($1::text[]), true),
|
||||
'{enabled_apps_version}', to_jsonb($2::int), true
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(&normalized)
|
||||
.bind(new_version)
|
||||
.bind(tenant_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO tenant_enabled_apps_history (tenant_id, version, enabled_apps, actor_user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(new_version)
|
||||
.bind(&normalized)
|
||||
.bind(actor_user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
|
||||
VALUES ($1, $2, 'tenant.enabled_apps.update', 'tenant', 'allow', $3)
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(actor_user_id)
|
||||
.bind(serde_json::json!({
|
||||
"before": { "enabled_apps": before_apps, "version": before_version },
|
||||
"after": { "enabled_apps": normalized, "version": new_version }
|
||||
}))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let ttl = Duration::from_secs(60);
|
||||
let now = Instant::now();
|
||||
let updated_at = updated_at.to_rfc3339();
|
||||
self.enabled_apps_cache.write().await.insert(
|
||||
tenant_id,
|
||||
EnabledAppsCacheEntry {
|
||||
enabled_apps: normalized.clone(),
|
||||
version: new_version,
|
||||
updated_at: updated_at.clone(),
|
||||
expires_at: now + ttl,
|
||||
},
|
||||
);
|
||||
|
||||
Ok((normalized, new_version, updated_at))
|
||||
}
|
||||
|
||||
async fn validate_apps_exist(&self, enabled_apps: &[String]) -> Result<(), AppError> {
|
||||
if enabled_apps.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<String> = sqlx::query_scalar("SELECT id FROM apps WHERE id = ANY($1)")
|
||||
.bind(enabled_apps)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let found: HashSet<String> = rows.into_iter().collect();
|
||||
for app in enabled_apps {
|
||||
if !found.contains(app) {
|
||||
return Err(AppError::BadRequest(format!("Unknown app: {app}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_apps(enabled_apps: Vec<String>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for a in enabled_apps {
|
||||
let v = a.trim().to_lowercase();
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(v.clone()) {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
22
src/utils/authz.rs
Normal file
22
src/utils/authz.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub fn permission_prefix(permission_code: &str) -> Option<&str> {
|
||||
permission_code.split_once(':').map(|(p, _)| p)
|
||||
}
|
||||
|
||||
pub fn filter_permissions_by_enabled_apps(
|
||||
permissions: Vec<String>,
|
||||
enabled_apps: &[String],
|
||||
) -> Vec<String> {
|
||||
let enabled: HashSet<&str> = enabled_apps.iter().map(|s| s.as_str()).collect();
|
||||
permissions
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
let Some(prefix) = permission_prefix(p) else {
|
||||
return true;
|
||||
};
|
||||
matches!(prefix, "user" | "role" | "tenant" | "iam") || enabled.contains(prefix)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ pub struct Claims {
|
||||
pub roles: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub apps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub apps_version: i32,
|
||||
}
|
||||
|
||||
pub fn sign(
|
||||
@@ -23,6 +27,8 @@ pub fn sign(
|
||||
tenant_id: Uuid,
|
||||
roles: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
apps: Vec<String>,
|
||||
apps_version: i32,
|
||||
) -> Result<String, AppError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -39,6 +45,8 @@ pub fn sign(
|
||||
iss: "iam-service".to_string(),
|
||||
roles,
|
||||
permissions,
|
||||
apps,
|
||||
apps_version,
|
||||
};
|
||||
|
||||
let keys = get_keys();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod keys;
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
pub mod authz;
|
||||
|
||||
pub use password::{hash_password, verify_password};
|
||||
pub use jwt::{sign, verify};
|
||||
|
||||
Reference in New Issue
Block a user