perf(struct): ddd

This commit is contained in:
2026-02-03 17:31:08 +08:00
parent 202b5eaad5
commit 4a071bd7c8
64 changed files with 1214 additions and 1189 deletions

View File

@@ -0,0 +1,359 @@
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},
http::HeaderMap,
};
use common_telemetry::{AppError, AppResponse};
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 {
return Ok(());
};
let provided = headers
.get("X-Sensitive-Token")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if provided == expected {
Ok(())
} else {
Err(AppError::PermissionDenied(
"sensitive:token_required".into(),
))
}
}
#[utoipa::path(
post,
path = "/platform/apps",
tag = "App",
security(
("bearer_auth" = [])
),
request_body = CreateAppRequest,
responses(
(status = 201, description = "App created", body = App),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
)
)]
#[instrument(skip(state, payload))]
pub async fn create_app_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Json(payload): Json<CreateAppRequest>,
) -> Result<AppResponse<App>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:write")
.await?;
let app = state.app_service.create_app(payload, user_id).await?;
Ok(AppResponse::created(app))
}
#[utoipa::path(
get,
path = "/platform/apps",
tag = "App",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Apps list", body = [App]),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
ListAppsQuery
)
)]
#[instrument(skip(state))]
pub async fn list_apps_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Query(query): Query<ListAppsQuery>,
) -> Result<AppResponse<Vec<App>>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:read")
.await?;
let apps = state.app_service.list_apps(query).await?;
Ok(AppResponse::ok(apps))
}
#[utoipa::path(
get,
path = "/platform/apps/{app_id}",
tag = "App",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "App detail", body = App),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("app_id" = String, Path, description = "App id")
)
)]
#[instrument(skip(state))]
pub async fn get_app_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(app_id): Path<String>,
) -> Result<AppResponse<App>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:read")
.await?;
let app = state.app_service.get_app(&app_id).await?;
Ok(AppResponse::ok(app))
}
#[utoipa::path(
patch,
path = "/platform/apps/{app_id}",
tag = "App",
security(
("bearer_auth" = [])
),
request_body = UpdateAppRequest,
responses(
(status = 200, description = "Updated", body = App),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("app_id" = String, Path, description = "App id")
)
)]
#[instrument(skip(state, payload))]
pub async fn update_app_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(app_id): Path<String>,
Json(payload): Json<UpdateAppRequest>,
) -> Result<AppResponse<App>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:write")
.await?;
let app = state
.app_service
.update_app(&app_id, payload, user_id)
.await?;
Ok(AppResponse::ok(app))
}
#[utoipa::path(
post,
path = "/platform/apps/{app_id}/status-change-requests",
tag = "App",
security(
("bearer_auth" = [])
),
request_body = RequestAppStatusChangeRequest,
responses(
(status = 201, description = "Request created", body = AppStatusChangeRequest),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("app_id" = String, Path, description = "App id")
)
)]
#[instrument(skip(state, payload))]
pub async fn request_app_status_change_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(app_id): Path<String>,
Json(payload): Json<RequestAppStatusChangeRequest>,
) -> Result<AppResponse<AppStatusChangeRequest>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:write")
.await?;
let req = state
.app_service
.request_status_change(&app_id, payload, user_id)
.await?;
Ok(AppResponse::created(req))
}
#[utoipa::path(
get,
path = "/platform/app-status-change-requests",
tag = "App",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Requests list", body = [AppStatusChangeRequest]),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("status" = Option<String>, Query, description = "pending/approved/applied/rejected"),
("page" = Option<u32>, Query, description = "页码,默认 1"),
("page_size" = Option<u32>, Query, description = "每页数量,默认 20最大 200")
)
)]
#[instrument(skip(state))]
pub async fn list_app_status_change_requests_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<AppResponse<Vec<AppStatusChangeRequest>>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:read")
.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 rows = state
.app_service
.list_status_change_requests(status, page, page_size)
.await?;
Ok(AppResponse::ok(rows))
}
#[utoipa::path(
post,
path = "/platform/app-status-change-requests/{request_id}/approve",
tag = "App",
security(
("bearer_auth" = [])
),
request_body = ApproveAppStatusChangeRequest,
responses(
(status = 200, description = "Approved", body = AppStatusChangeRequest),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("request_id" = String, Path, description = "Request id (UUID)")
)
)]
#[instrument(skip(state, payload))]
pub async fn approve_app_status_change_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(request_id): Path<Uuid>,
Json(payload): Json<ApproveAppStatusChangeRequest>,
) -> Result<AppResponse<AppStatusChangeRequest>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:approve")
.await?;
let row = state
.app_service
.approve_status_change(request_id, payload.effective_at, user_id)
.await?;
Ok(AppResponse::ok(row))
}
#[utoipa::path(
post,
path = "/platform/app-status-change-requests/{request_id}/reject",
tag = "App",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Rejected", body = AppStatusChangeRequest),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("request_id" = String, Path, description = "Request id (UUID)"),
("reason" = Option<String>, Query, description = "Reject reason")
)
)]
#[instrument(skip(state))]
pub async fn reject_app_status_change_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(request_id): Path<Uuid>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<AppResponse<AppStatusChangeRequest>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:approve")
.await?;
let reason = params.get("reason").cloned();
let row = state
.app_service
.reject_status_change(request_id, reason, user_id)
.await?;
Ok(AppResponse::ok(row))
}
#[utoipa::path(
delete,
path = "/platform/apps/{app_id}",
tag = "App",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Sensitive-Token" = Option<String>, Header, description = "二次验证令牌(当 IAM_SENSITIVE_ACTION_TOKEN 设置时必填)"),
("app_id" = String, Path, description = "App id")
)
)]
#[instrument(skip(state, headers))]
pub async fn delete_app_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
headers: HeaderMap,
Path(app_id): Path<String>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:app:delete")
.await?;
require_sensitive_token(&headers)?;
state.app_service.delete_app(&app_id, user_id).await?;
Ok(AppResponse::ok(serde_json::json!({})))
}

View File

@@ -0,0 +1,106 @@
use crate::presentation::http::state::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use crate::models::{
CreateUserRequest, LoginRequest, LoginResponse, RefreshTokenRequest, UserResponse,
};
use axum::{Json, extract::State};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
#[utoipa::path(
post,
path = "/auth/register",
tag = "Auth",
request_body = CreateUserRequest,
responses(
(status = 201, description = "User created", body = UserResponse),
(status = 400, description = "Bad request"),
(status = 429, description = "Too many requests")
),
params(
("X-Tenant-ID" = String, Header, description = "Tenant UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn register_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> Result<AppResponse<UserResponse>, AppError> {
let user = state.auth_service.register(tenant_id, payload).await?;
Ok(AppResponse::created(UserResponse {
id: user.id,
email: user.email.clone(),
}))
}
#[utoipa::path(
post,
path = "/auth/login",
tag = "Auth",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = LoginResponse),
(status = 401, description = "Unauthorized"),
(status = 429, description = "Too many requests")
),
params(
("X-Tenant-ID" = String, Header, description = "Tenant UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn login_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> Result<AppResponse<LoginResponse>, AppError> {
let response = state.auth_service.login(tenant_id, payload).await?;
Ok(AppResponse::ok(response))
}
#[utoipa::path(
post,
path = "/auth/refresh",
tag = "Auth",
request_body = RefreshTokenRequest,
responses(
(status = 200, description = "Token refreshed", body = LoginResponse),
(status = 401, description = "Unauthorized")
)
)]
#[instrument(skip(state, payload))]
pub async fn refresh_handler(
State(state): State<AppState>,
Json(payload): Json<RefreshTokenRequest>,
) -> Result<AppResponse<LoginResponse>, AppError> {
let response = state
.auth_service
.refresh_access_token(payload.refresh_token)
.await?;
Ok(AppResponse::ok(response))
}
#[utoipa::path(
post,
path = "/auth/logout",
tag = "Auth",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Logged out"),
(status = 401, description = "Unauthorized")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
)
)]
#[instrument(skip(state))]
pub async fn logout_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
) -> Result<AppResponse<serde_json::Value>, AppError> {
state.auth_service.logout(user_id).await?;
Ok(AppResponse::ok(serde_json::json!({})))
}

View File

@@ -0,0 +1,91 @@
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;
#[utoipa::path(
get,
path = "/me/permissions",
tag = "Me",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "当前用户权限列表", body = [String]),
(status = 401, description = "未认证"),
(status = 403, description = "无权限")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)")
)
)]
#[instrument(skip(state))]
pub async fn my_permissions_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
) -> Result<AppResponse<Vec<String>>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
let permissions = state
.authorization_service
.list_permissions_for_user(tenant_id, user_id)
.await?;
Ok(AppResponse::ok(permissions))
}
#[utoipa::path(
post,
path = "/authorize/check",
tag = "Policy",
security(
("bearer_auth" = [])
),
request_body = AuthorizationCheckRequest,
responses(
(status = 200, description = "鉴权校验结果", body = AuthorizationCheckResponse),
(status = 401, description = "未认证"),
(status = 403, description = "租户不匹配")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)")
)
)]
#[instrument(skip(state, body))]
pub async fn authorization_check_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Json(body): Json<AuthorizationCheckRequest>,
) -> Result<AppResponse<AuthorizationCheckResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
let allowed = match state
.authorization_service
.require_permission(tenant_id, user_id, body.permission.as_str())
.await
{
Ok(()) => true,
Err(AppError::PermissionDenied(_)) => false,
Err(e) => return Err(e),
};
Ok(AppResponse::ok(AuthorizationCheckResponse { allowed }))
}

View File

@@ -0,0 +1,174 @@
use crate::presentation::http::state::AppState;
use crate::middleware::auth::AuthContext;
use crate::models::{
ClientSummary, CreateClientRequest, CreateClientResponse, RotateClientSecretResponse,
UpdateClientRedirectUrisRequest,
};
use axum::{
Json,
extract::{Path, State},
};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
#[utoipa::path(
post,
path = "/platform/clients",
tag = "Client",
security(
("bearer_auth" = [])
),
request_body = CreateClientRequest,
responses(
(status = 201, description = "Created", body = CreateClientResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
)
)]
#[instrument(skip(state, payload))]
pub async fn create_client_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Json(payload): Json<CreateClientRequest>,
) -> Result<AppResponse<CreateClientResponse>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:client:write")
.await?;
let secret = state
.client_service
.create_client(
payload.client_id.clone(),
payload.name.clone(),
payload.redirect_uris.clone(),
)
.await?;
Ok(AppResponse::created(CreateClientResponse {
client_id: payload.client_id,
client_secret: secret,
}))
}
#[utoipa::path(
put,
path = "/platform/clients/{client_id}/redirect-uris",
tag = "Client",
security(
("bearer_auth" = [])
),
request_body = UpdateClientRedirectUrisRequest,
responses(
(status = 200, description = "Updated"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("client_id" = String, Path, description = "clientId")
)
)]
#[instrument(skip(state, payload))]
pub async fn update_client_redirect_uris_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(client_id): Path<String>,
Json(payload): Json<UpdateClientRedirectUrisRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:client:write")
.await?;
state
.client_service
.set_redirect_uris(client_id, payload.redirect_uris)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/platform/clients/{client_id}/rotate-secret",
tag = "Client",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Rotated", body = RotateClientSecretResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("client_id" = String, Path, description = "clientId")
)
)]
#[instrument(skip(state))]
pub async fn rotate_client_secret_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
Path(client_id): Path<String>,
) -> Result<AppResponse<RotateClientSecretResponse>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:client:write")
.await?;
let secret = state.client_service.rotate_secret(client_id.clone()).await?;
Ok(AppResponse::ok(RotateClientSecretResponse {
client_id,
client_secret: secret,
}))
}
#[utoipa::path(
get,
path = "/platform/clients",
tag = "Client",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "OK", body = [ClientSummary]),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
)
)]
#[instrument(skip(state))]
pub async fn list_clients_handler(
State(state): State<AppState>,
AuthContext { user_id, .. }: AuthContext,
) -> Result<AppResponse<Vec<ClientSummary>>, AppError> {
state
.authorization_service
.require_platform_permission(user_id, "iam:client:read")
.await?;
let rows = state.client_service.list_clients().await?;
let clients = rows
.into_iter()
.map(
|(client_id, name, redirect_uris, created_at, updated_at)| ClientSummary {
client_id,
name,
redirect_uris,
created_at,
updated_at,
},
)
.collect();
Ok(AppResponse::ok(clients))
}

View File

@@ -0,0 +1,94 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Response;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Jwks {
keys: Vec<Jwk>,
}
#[derive(Serialize)]
struct Jwk {
kty: &'static str,
kid: String,
#[serde(rename = "use")]
use_field: &'static str,
alg: &'static str,
n: String,
e: String,
}
#[derive(Deserialize)]
struct ExtraJwkKey {
kid: String,
public_key_pem: String,
}
fn build_jwks(extra_json: Option<&str>) -> Result<Jwks, &'static str> {
let keys = crate::utils::keys::get_keys();
let mut jwk_keys = vec![Jwk {
kty: "RSA",
kid: keys.kid.clone(),
use_field: "sig",
alg: "RS256",
n: keys.public_n.clone(),
e: keys.public_e.clone(),
}];
if let Some(extra_json) = extra_json {
let extra: Vec<ExtraJwkKey> =
serde_json::from_str(extra_json).map_err(|_| "Invalid JWT_JWKS_EXTRA_KEYS_JSON")?;
for k in extra {
let (n, e) = crate::utils::keys::jwk_components_from_public_pem(&k.public_key_pem)
.map_err(|_| "Invalid public_key_pem in JWT_JWKS_EXTRA_KEYS_JSON")?;
jwk_keys.push(Jwk {
kty: "RSA",
kid: k.kid,
use_field: "sig",
alg: "RS256",
n,
e,
});
}
}
Ok(Jwks { keys: jwk_keys })
}
pub async fn jwks_handler() -> Response {
let extra_json = std::env::var("JWT_JWKS_EXTRA_KEYS_JSON").ok();
match build_jwks(extra_json.as_deref()) {
Ok(jwks) => (StatusCode::OK, Json(jwks)).into_response(),
Err(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(),
}
}
#[cfg(test)]
mod tests {
use super::build_jwks;
#[test]
fn build_jwks_rejects_invalid_extra_json() {
assert!(matches!(
build_jwks(Some("not-json")),
Err("Invalid JWT_JWKS_EXTRA_KEYS_JSON")
));
}
#[test]
fn build_jwks_accepts_extra_key() {
let active_public_pem = crate::utils::keys::get_keys().public_pem.clone();
let extra_json = serde_json::json!([{
"kid": "extra-kid",
"public_key_pem": active_public_pem
}])
.to_string();
let jwks = build_jwks(Some(&extra_json)).unwrap();
assert!(jwks.keys.iter().any(|k| k.kid == "extra-kid"));
assert!(jwks.keys.len() >= 2);
}
}

View 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;

View 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))
}

View File

@@ -0,0 +1,92 @@
use crate::middleware::auth::AuthContext;
use crate::models::{TenantEnabledAppsResponse, UpdateTenantEnabledAppsRequest};
use crate::presentation::http::state::AppState;
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,
}))
}

View File

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

View File

@@ -0,0 +1,219 @@
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, EncodingKey, Header, encode};
use redis::AsyncCommands;
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,
tenant_id: String,
client_id: Option<String>,
redirect_uri: Option<String>,
exp: usize,
iat: usize,
iss: String,
jti: String,
}
fn redis_key(jti: &str) -> String {
format!("iam:auth_code:{}", jti)
}
#[utoipa::path(
post,
path = "/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-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?;
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)?;
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 = "/auth/login-code",
tag = "Auth",
request_body = LoginCodeRequest,
responses(
(status = 200, description = "Code issued", body = LoginCodeResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 429, description = "Too many requests")
),
params(
("X-Tenant-ID" = String, Header, description = "Tenant UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn login_code_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
Json(payload): Json<LoginCodeRequest>,
) -> Result<AppResponse<LoginCodeResponse>, AppError> {
let redirect_uri = state
.client_service
.assert_redirect_uri_allowed(&payload.client_id, &payload.redirect_uri)
.await?;
let user = state
.auth_service
.verify_user_credentials(tenant_id, payload.email, payload.password)
.await?;
let now = chrono::Utc::now().timestamp() as usize;
let exp = now + 5 * 60;
let jti = Uuid::new_v4().to_string();
let claims = AuthCodeClaims {
sub: user.id.to_string(),
tenant_id: user.tenant_id.to_string(),
client_id: Some(payload.client_id),
redirect_uri: Some(redirect_uri.clone()),
exp,
iat: now,
iss: "iam-service".to_string(),
jti: jti.clone(),
};
let header = Header::new(Algorithm::HS256);
let code = encode(
&header,
&claims,
&EncodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()),
)
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
let value = serde_json::json!({
"user_id": user.id.to_string(),
"tenant_id": user.tenant_id.to_string(),
"client_id": claims.client_id,
"redirect_uri": claims.redirect_uri
})
.to_string();
let mut conn = state.redis.clone();
let _: () = conn
.set_ex(redis_key(&jti), value, 5 * 60)
.await
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
let mut u = url::Url::parse(&redirect_uri)
.map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?;
u.query_pairs_mut().append_pair("code", &code);
Ok(AppResponse::ok(LoginCodeResponse {
redirect_to: u.to_string(),
expires_at: exp,
}))
}

View File

@@ -0,0 +1,239 @@
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;
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 {
return Ok(());
};
let provided = headers
.get("X-Sensitive-Token")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if provided == expected {
Ok(())
} else {
Err(AppError::PermissionDenied(
"sensitive:token_required".into(),
))
}
}
#[utoipa::path(
post,
path = "/tenants/register",
tag = "Tenant",
request_body = CreateTenantRequest,
responses(
(status = 201, description = "租户创建成功", body = TenantResponse),
(status = 400, description = "请求参数错误")
),
params(
("X-Sensitive-Token" = Option<String>, Header, description = "二次验证令牌(当 IAM_SENSITIVE_ACTION_TOKEN 设置时必填)")
)
)]
#[instrument(skip(state, payload))]
pub async fn create_tenant_handler(
headers: HeaderMap,
State(state): State<AppState>,
Json(payload): Json<CreateTenantRequest>,
) -> Result<AppResponse<TenantResponse>, AppError> {
require_sensitive_token(&headers)?;
let tenant = state.tenant_service.create_tenant(payload).await?;
Ok(AppResponse::created(TenantResponse {
id: tenant.id,
name: tenant.name,
status: tenant.status,
config: tenant.config,
}))
}
#[utoipa::path(
get,
path = "/tenants/me",
tag = "Tenant",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "获取当前租户信息", body = TenantResponse),
(status = 401, description = "未认证"),
(status = 403, description = "无权限")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)")
)
)]
#[instrument(skip(state))]
pub async fn get_tenant_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
) -> Result<AppResponse<TenantResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "tenant:read")
.await?;
let tenant = state.tenant_service.get_tenant(tenant_id).await?;
Ok(AppResponse::ok(TenantResponse {
id: tenant.id,
name: tenant.name,
status: tenant.status,
config: tenant.config,
}))
}
#[utoipa::path(
patch,
path = "/tenants/me",
tag = "Tenant",
security(
("bearer_auth" = [])
),
request_body = UpdateTenantRequest,
responses(
(status = 200, description = "租户更新成功", body = TenantResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)")
)
)]
#[instrument(skip(state, payload))]
pub async fn update_tenant_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Json(payload): Json<UpdateTenantRequest>,
) -> Result<AppResponse<TenantResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "tenant:write")
.await?;
let tenant = state
.tenant_service
.update_tenant(tenant_id, payload)
.await?;
Ok(AppResponse::ok(TenantResponse {
id: tenant.id,
name: tenant.name,
status: tenant.status,
config: tenant.config,
}))
}
#[utoipa::path(
post,
path = "/tenants/me/status",
tag = "Tenant",
security(
("bearer_auth" = [])
),
request_body = UpdateTenantStatusRequest,
responses(
(status = 200, description = "租户状态更新成功", body = TenantResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)")
)
)]
#[instrument(skip(state, payload))]
pub async fn update_tenant_status_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Json(payload): Json<UpdateTenantStatusRequest>,
) -> Result<AppResponse<TenantResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "tenant:write")
.await?;
let tenant = state
.tenant_service
.update_tenant_status(tenant_id, payload)
.await?;
Ok(AppResponse::ok(TenantResponse {
id: tenant.id,
name: tenant.name,
status: tenant.status,
config: tenant.config,
}))
}
#[utoipa::path(
delete,
path = "/tenants/me",
tag = "Tenant",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "租户删除成功"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)")
)
)]
#[instrument(skip(state))]
pub async fn delete_tenant_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
) -> Result<AppResponse<()>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "tenant:write")
.await?;
state.tenant_service.delete_tenant(tenant_id).await?;
Ok(AppResponse::ok_empty())
}

View File

@@ -0,0 +1,457 @@
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},
http::HeaderMap,
};
use common_telemetry::{AppError, AppResponse};
use serde::Deserialize;
use tracing::instrument;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct ListUsersQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
}
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 {
return Ok(());
};
let provided = headers
.get("X-Sensitive-Token")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if provided == expected {
Ok(())
} else {
Err(AppError::PermissionDenied(
"sensitive:token_required".into(),
))
}
}
#[utoipa::path(
get,
path = "/users",
tag = "User",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "用户列表", body = [UserResponse]),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("page" = Option<u32>, Query, description = "页码,默认 1"),
("page_size" = Option<u32>, Query, description = "每页数量,默认 20最大 200")
)
)]
#[instrument(skip(state))]
pub async fn list_users_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Query(query): Query<ListUsersQuery>,
) -> Result<AppResponse<Vec<UserResponse>>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "user:read")
.await?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
if page == 0 || page_size == 0 || page_size > 200 {
return Err(AppError::BadRequest("Invalid pagination parameters".into()));
}
let users = state
.user_service
.list_users(tenant_id, page, page_size)
.await?;
let response = users
.into_iter()
.map(|u| UserResponse {
id: u.id,
email: u.email,
})
.collect();
Ok(AppResponse::ok(response))
}
#[utoipa::path(
get,
path = "/users/{id}",
tag = "User",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "用户详情", body = UserResponse),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "用户 UUID")
)
)]
#[instrument(skip(state))]
pub async fn get_user_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(target_user_id): Path<Uuid>,
) -> Result<AppResponse<UserResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "user:read")
.await?;
let user = state
.user_service
.get_user_by_id(tenant_id, target_user_id)
.await?;
Ok(AppResponse::ok(UserResponse {
id: user.id,
email: user.email,
}))
}
#[utoipa::path(
patch,
path = "/users/{id}",
tag = "User",
security(
("bearer_auth" = [])
),
request_body = UpdateUserRequest,
responses(
(status = 200, description = "用户更新成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "用户 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn update_user_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(target_user_id): Path<Uuid>,
Json(payload): Json<UpdateUserRequest>,
) -> Result<AppResponse<UserResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "user:write")
.await?;
let user = state
.user_service
.update_user(tenant_id, target_user_id, payload)
.await?;
Ok(AppResponse::ok(UserResponse {
id: user.id,
email: user.email,
}))
}
#[utoipa::path(
delete,
path = "/users/{id}",
tag = "User",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "用户删除成功"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "用户 UUID")
)
)]
#[instrument(skip(state))]
pub async fn delete_user_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(target_user_id): Path<Uuid>,
) -> Result<AppResponse<()>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "user:write")
.await?;
state
.user_service
.delete_user(tenant_id, target_user_id)
.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))]
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))]
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))
}
#[utoipa::path(
post,
path = "/users/me/password/reset",
tag = "User",
security(
("bearer_auth" = [])
),
request_body = ResetMyPasswordRequest,
responses(
(status = 200, description = "Password reset success"),
(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 一致)")
)
)]
#[instrument(skip(state, payload))]
pub async fn reset_my_password_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Json(payload): Json<ResetMyPasswordRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.user_service
.reset_my_password(
tenant_id,
user_id,
payload.current_password,
payload.new_password,
)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/users/{id}/password/reset",
tag = "User",
security(
("bearer_auth" = [])
),
request_body = AdminResetUserPasswordRequest,
responses(
(status = 200, description = "Password reset", body = AdminResetUserPasswordResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Sensitive-Token" = Option<String>, Header, description = "二次验证令牌(当 IAM_SENSITIVE_ACTION_TOKEN 设置时必填)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "用户 UUID")
)
)]
#[instrument(skip(state, headers, payload))]
pub async fn reset_user_password_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id: actor_user_id,
..
}: AuthContext,
headers: HeaderMap,
Path(target_user_id): Path<Uuid>,
Json(payload): Json<AdminResetUserPasswordRequest>,
) -> Result<AppResponse<AdminResetUserPasswordResponse>, 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:password:reset:any")
.await?;
require_sensitive_token(&headers)?;
let temp = state
.user_service
.reset_user_password_as_admin(tenant_id, actor_user_id, target_user_id, payload.length)
.await?;
Ok(AppResponse::ok(AdminResetUserPasswordResponse {
temporary_password: temp,
}))
}