feat(handler): add app

This commit is contained in:
2026-01-31 15:44:56 +08:00
parent 6b68a368f1
commit 4dc46659c9
25 changed files with 2516 additions and 14 deletions

View File

@@ -1,7 +1,10 @@
use crate::handlers;
use crate::models::{
CreateRoleRequest, CreateTenantRequest, CreateUserRequest, LoginRequest, LoginResponse, Role,
RoleResponse, Tenant, TenantEnabledAppsResponse, TenantResponse,
AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, App, AppStatusChangeRequest,
ApproveAppStatusChangeRequest, CreateAppRequest, CreateRoleRequest, CreateTenantRequest,
CreateUserRequest, ListAppsQuery, LoginRequest, LoginResponse,
RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, Tenant,
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest,
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse,
};
@@ -137,6 +140,15 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
handlers::authorization::my_permissions_handler,
handlers::platform::get_tenant_enabled_apps_handler,
handlers::platform::set_tenant_enabled_apps_handler,
handlers::app::create_app_handler,
handlers::app::list_apps_handler,
handlers::app::get_app_handler,
handlers::app::update_app_handler,
handlers::app::delete_app_handler,
handlers::app::request_app_status_change_handler,
handlers::app::list_app_status_change_requests_handler,
handlers::app::approve_app_status_change_handler,
handlers::app::reject_app_status_change_handler,
handlers::tenant::create_tenant_handler,
handlers::tenant::get_tenant_handler,
handlers::tenant::update_tenant_handler,
@@ -150,6 +162,8 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
handlers::user::delete_user_handler,
handlers::user::list_user_roles_handler,
handlers::user::set_user_roles_handler,
handlers::user::reset_my_password_handler,
handlers::user::reset_user_password_handler,
// Add other handlers here as you implement them
),
components(
@@ -170,7 +184,17 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
UpdateTenantStatusRequest,
UpdateTenantEnabledAppsRequest,
TenantEnabledAppsResponse,
UpdateUserRolesRequest
UpdateUserRolesRequest,
ResetMyPasswordRequest,
AdminResetUserPasswordRequest,
AdminResetUserPasswordResponse,
App,
CreateAppRequest,
UpdateAppRequest,
ListAppsQuery,
RequestAppStatusChangeRequest,
ApproveAppStatusChangeRequest,
AppStatusChangeRequest
)
),
tags(
@@ -179,6 +203,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
(name = "User", description = "用户:查询/列表/更新/删除(需权限)"),
(name = "Role", description = "角色:创建/列表(需权限)"),
(name = "Me", description = "当前用户:权限自查等"),
(name = "App", description = "应用:应用注册表与生命周期管理(平台级)"),
(name = "Policy", description = "策略预留ABAC/策略引擎后续扩展)")
)
)]

375
src/handlers/app.rs Normal file
View File

@@ -0,0 +1,375 @@
use crate::handlers::AppState;
use crate::middleware::auth::AuthContext;
use crate::models::{
App, AppStatusChangeRequest, ApproveAppStatusChangeRequest, CreateAppRequest, ListAppsQuery,
RequestAppStatusChangeRequest, UpdateAppRequest,
};
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()))
}
}
/// Create app (registry).
/// 创建应用(应用注册表)。
#[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))
}
/// List apps (registry).
/// 查询应用列表(分页/筛选/排序)。
#[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))
}
/// Get app by id (registry).
/// 查询应用详情。
#[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))
}
/// Update app (registry).
/// 更新应用基础信息。
#[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))
}
/// Request app status change (enable/disable).
/// 申请应用上下线(需要审批,可设置生效时间)。
#[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))
}
/// List app status change requests.
/// 查询应用状态变更审批单列表。
#[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))
}
/// Approve app status change request.
/// 审批通过应用状态变更审批单。
#[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))
}
/// Reject app status change request.
/// 驳回应用状态变更审批单。
#[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))
}
/// Delete app (soft delete).
/// 删除应用(软删除,标记 status=deleted
#[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

@@ -1,3 +1,4 @@
pub mod app;
pub mod auth;
pub mod authorization;
pub mod platform;
@@ -5,8 +6,15 @@ pub mod role;
pub mod tenant;
pub mod user;
use crate::services::{AuthService, AuthorizationService, RoleService, TenantService, UserService};
use crate::services::{
AppService, AuthService, AuthorizationService, RoleService, TenantService, UserService,
};
pub use app::{
approve_app_status_change_handler, create_app_handler, delete_app_handler, get_app_handler,
list_app_status_change_requests_handler, list_apps_handler, reject_app_status_change_handler,
request_app_status_change_handler, update_app_handler,
};
pub use auth::{login_handler, register_handler};
pub use authorization::my_permissions_handler;
pub use platform::{get_tenant_enabled_apps_handler, set_tenant_enabled_apps_handler};
@@ -17,7 +25,8 @@ pub use tenant::{
};
pub use user::{
delete_user_handler, get_user_handler, list_user_roles_handler, list_users_handler,
set_user_roles_handler, update_user_handler,
reset_my_password_handler, reset_user_password_handler, set_user_roles_handler,
update_user_handler,
};
// 状态对象,包含 Service
@@ -28,4 +37,5 @@ pub struct AppState {
pub role_service: RoleService,
pub tenant_service: TenantService,
pub authorization_service: AuthorizationService,
pub app_service: AppService,
}

View File

@@ -4,10 +4,31 @@ use crate::middleware::auth::AuthContext;
use crate::models::{
CreateTenantRequest, TenantResponse, UpdateTenantRequest, UpdateTenantStatusRequest,
};
use axum::{Json, extract::State};
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",
@@ -16,6 +37,9 @@ use tracing::instrument;
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))]
@@ -35,9 +59,11 @@ use tracing::instrument;
/// 异常:
/// - `400`:请求参数错误
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?;
let response = TenantResponse {
id: tenant.id,

View File

@@ -1,10 +1,14 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use crate::models::{RoleResponse, UpdateUserRequest, UpdateUserRolesRequest, UserResponse};
use crate::models::{
AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, ResetMyPasswordRequest,
RoleResponse, UpdateUserRequest, UpdateUserRolesRequest, UserResponse,
};
use axum::{
Json,
extract::{Path, Query, State},
http::HeaderMap,
};
use common_telemetry::{AppError, AppResponse};
use serde::Deserialize;
@@ -17,6 +21,27 @@ pub struct ListUsersQuery {
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",
@@ -427,3 +452,104 @@ pub async fn set_user_roles_handler(
.collect();
Ok(AppResponse::ok(response))
}
/// Reset my password (requires current password).
/// 重置自己的密码(需要提供旧密码)。
#[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!({})))
}
/// Reset a user's password as tenant admin (generates temporary password).
/// 租户管理员重置任意用户密码(生成临时密码)。
#[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,
}))
}

View File

@@ -15,14 +15,19 @@ use axum::{
};
use config::AppConfig;
use handlers::{
AppState, create_role_handler, create_tenant_handler, delete_tenant_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,
AppState, approve_app_status_change_handler, create_app_handler, create_role_handler,
create_tenant_handler, delete_app_handler, delete_tenant_handler, delete_user_handler,
get_app_handler, get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler,
list_app_status_change_requests_handler, list_apps_handler, list_roles_handler,
list_user_roles_handler, list_users_handler, login_handler, my_permissions_handler,
register_handler, reject_app_status_change_handler, request_app_status_change_handler,
reset_my_password_handler, reset_user_password_handler, set_tenant_enabled_apps_handler,
set_user_roles_handler, update_app_handler, update_tenant_handler,
update_tenant_status_handler, update_user_handler,
};
use services::{
AppService, AuthService, AuthorizationService, RoleService, TenantService, UserService,
};
use services::{AuthService, AuthorizationService, RoleService, TenantService, UserService};
use std::net::SocketAddr;
use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable};
@@ -73,6 +78,7 @@ async fn main() {
let role_service = RoleService::new(pool.clone());
let tenant_service = TenantService::new(pool.clone());
let authorization_service = AuthorizationService::new(pool.clone());
let app_service = AppService::new(pool.clone());
let state = AppState {
auth_service,
@@ -80,6 +86,7 @@ async fn main() {
role_service,
tenant_service,
authorization_service,
app_service,
};
// 5. 构建路由
@@ -106,12 +113,17 @@ async fn main() {
)
.route("/me/permissions", get(my_permissions_handler))
.route("/users", get(list_users_handler))
.route("/users/me/password/reset", post(reset_my_password_handler))
.route(
"/users/{id}",
get(get_user_handler)
.patch(update_user_handler)
.delete(delete_user_handler),
)
.route(
"/users/{id}/password/reset",
post(reset_user_password_handler),
)
.route(
"/users/{id}/roles",
get(list_user_roles_handler).put(set_user_roles_handler),
@@ -128,6 +140,32 @@ async fn main() {
"/platform/tenants/{tenant_id}/enabled-apps",
get(get_tenant_enabled_apps_handler).put(set_tenant_enabled_apps_handler),
)
.route(
"/platform/apps",
get(list_apps_handler).post(create_app_handler),
)
.route(
"/platform/apps/{app_id}",
get(get_app_handler)
.patch(update_app_handler)
.delete(delete_app_handler),
)
.route(
"/platform/apps/{app_id}/status-change-requests",
post(request_app_status_change_handler),
)
.route(
"/platform/app-status-change-requests",
get(list_app_status_change_requests_handler),
)
.route(
"/platform/app-status-change-requests/{request_id}/approve",
post(approve_app_status_change_handler),
)
.route(
"/platform/app-status-change-requests/{request_id}/reject",
post(reject_app_status_change_handler),
)
.layer(from_fn(middleware::auth::authenticate))
.layer(from_fn(
common_telemetry::axum_middleware::trace_http_request,

View File

@@ -218,3 +218,137 @@ pub struct UpdateUserRolesRequest {
#[serde(default)]
pub role_ids: Vec<Uuid>,
}
#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)]
pub struct App {
#[schema(default = "")]
#[serde(default)]
pub id: String,
#[schema(default = "")]
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[schema(default = "generic", example = "generic")]
#[serde(default)]
pub app_type: String,
#[serde(default)]
pub owner: Option<String>,
#[schema(default = "active", example = "active")]
#[serde(default)]
pub status: String,
#[schema(default = "")]
#[serde(default)]
pub created_at: String,
#[schema(default = "")]
#[serde(default)]
pub updated_at: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct CreateAppRequest {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub app_type: String,
#[serde(default)]
pub owner: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct UpdateAppRequest {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub app_type: Option<String>,
#[serde(default)]
pub owner: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ListAppsQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub status: Option<String>,
pub app_type: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
pub created_from: Option<String>,
pub created_to: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct RequestAppStatusChangeRequest {
#[serde(default)]
pub to_status: String,
#[serde(default)]
pub effective_at: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ApproveAppStatusChangeRequest {
#[serde(default)]
pub effective_at: Option<String>,
}
#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)]
pub struct AppStatusChangeRequest {
#[serde(default = "default_uuid")]
pub id: Uuid,
#[serde(default)]
pub app_id: String,
#[serde(default)]
pub from_status: String,
#[serde(default)]
pub to_status: String,
#[serde(default = "default_uuid")]
pub requested_by: Uuid,
#[serde(default)]
pub requested_at: String,
#[serde(default)]
pub effective_at: Option<String>,
#[serde(default)]
pub status: String,
#[serde(default)]
pub approved_by: Option<Uuid>,
#[serde(default)]
pub approved_at: Option<String>,
#[serde(default)]
pub rejected_by: Option<Uuid>,
#[serde(default)]
pub rejected_at: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ResetMyPasswordRequest {
#[schema(default = "", example = "oldPassword123")]
#[serde(default)]
pub current_password: String,
#[schema(default = "", example = "newPassword456")]
#[serde(default)]
pub new_password: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct AdminResetUserPasswordRequest {
#[schema(default = 20, example = 20)]
#[serde(default)]
pub length: Option<usize>,
}
#[derive(Debug, Serialize, ToSchema, IntoParams)]
pub struct AdminResetUserPasswordResponse {
#[schema(default = "", example = "TempPass-Example-123")]
#[serde(default)]
pub temporary_password: String,
}

802
src/services/app.rs Normal file
View File

@@ -0,0 +1,802 @@
use crate::models::{
App, AppStatusChangeRequest, CreateAppRequest, ListAppsQuery, UpdateAppRequest,
};
use common_telemetry::AppError;
use sqlx::PgPool;
use tracing::instrument;
use uuid::Uuid;
#[derive(Clone)]
pub struct AppService {
pool: PgPool,
}
impl AppService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn normalize_app_id(raw: &str) -> Result<String, AppError> {
let id = raw.trim().to_ascii_lowercase();
if id.len() < 2 || id.len() > 32 {
return Err(AppError::BadRequest("Invalid app id length".into()));
}
if !id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
{
return Err(AppError::BadRequest("Invalid app id format".into()));
}
Ok(id)
}
fn normalize_text_opt(v: Option<String>, max_len: usize) -> Result<Option<String>, AppError> {
let Some(v) = v else {
return Ok(None);
};
let t = v.trim().to_string();
if t.is_empty() {
return Ok(None);
}
if t.len() > max_len {
return Err(AppError::BadRequest("Value too long".into()));
}
Ok(Some(t))
}
fn normalize_text_required(v: &str, max_len: usize) -> Result<String, AppError> {
let t = v.trim();
if t.is_empty() {
return Err(AppError::BadRequest("Value is required".into()));
}
if t.len() > max_len {
return Err(AppError::BadRequest("Value too long".into()));
}
Ok(t.to_string())
}
fn normalize_app_type(v: &str) -> Result<String, AppError> {
let t = v.trim().to_ascii_lowercase();
if t.is_empty() {
return Ok("generic".to_string());
}
if t.len() > 50 {
return Err(AppError::BadRequest("Invalid app_type length".into()));
}
if !t
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
{
return Err(AppError::BadRequest("Invalid app_type format".into()));
}
Ok(t)
}
#[instrument(skip(self, req))]
pub async fn create_app(
&self,
req: CreateAppRequest,
actor_user_id: Uuid,
) -> Result<App, AppError> {
let id = Self::normalize_app_id(&req.id)?;
let name = Self::normalize_text_required(&req.name, 100)?;
let description = Self::normalize_text_opt(req.description, 10_000)?;
let app_type = Self::normalize_app_type(&req.app_type)?;
let owner = Self::normalize_text_opt(req.owner, 100)?;
let mut tx = self.pool.begin().await?;
let inserted = sqlx::query_as::<_, App>(
r#"
INSERT INTO apps (id, name, description, status, app_type, owner, updated_at)
VALUES ($1, $2, $3, 'active', $4, $5, NOW())
RETURNING
id,
name,
description,
app_type,
owner,
status,
created_at::text as created_at,
updated_at::text as updated_at
"#,
)
.bind(&id)
.bind(&name)
.bind(&description)
.bind(&app_type)
.bind(&owner)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
if let sqlx::Error::Database(db) = &e {
if db.is_unique_violation() {
return AppError::AlreadyExists("App already exists".into());
}
}
e.into()
})?;
sqlx::query(
r#"
INSERT INTO app_change_logs (app_id, action, actor_user_id, after)
VALUES ($1, 'create', $2, $3)
"#,
)
.bind(&id)
.bind(actor_user_id)
.bind(serde_json::json!({
"id": inserted.id,
"name": inserted.name,
"description": inserted.description,
"app_type": inserted.app_type,
"owner": inserted.owner,
"status": inserted.status
}))
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ('00000000-0000-0000-0000-000000000001', $1, 'app.create', 'app', 'allow', $2)
"#,
)
.bind(actor_user_id)
.bind(serde_json::json!({ "app_id": id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(inserted)
}
#[instrument(skip(self))]
pub async fn list_apps(&self, query: ListAppsQuery) -> Result<Vec<App>, AppError> {
self.apply_due_status_changes().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 offset = (page - 1) as i64 * page_size as i64;
let status = query
.status
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let app_type = query
.app_type
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let sort_by = query.sort_by.unwrap_or_else(|| "created_at".to_string());
let sort_order = query.sort_order.unwrap_or_else(|| "desc".to_string());
let sort_by = match sort_by.as_str() {
"id" => "id",
"name" => "name",
"status" => "status",
"app_type" => "app_type",
"created_at" => "created_at",
"updated_at" => "updated_at",
_ => "created_at",
};
let sort_order = match sort_order.to_ascii_lowercase().as_str() {
"asc" => "ASC",
_ => "DESC",
};
let created_from = query
.created_from
.and_then(|s| s.parse::<chrono::DateTime<chrono::Utc>>().ok());
let created_to = query
.created_to
.and_then(|s| s.parse::<chrono::DateTime<chrono::Utc>>().ok());
let sql = format!(
r#"
SELECT
id,
name,
description,
app_type,
owner,
status,
created_at::text as created_at,
updated_at::text as updated_at
FROM apps
WHERE ($1::text IS NULL OR status = $1)
AND ($2::text IS NULL OR app_type = $2)
AND ($3::timestamptz IS NULL OR created_at >= $3)
AND ($4::timestamptz IS NULL OR created_at <= $4)
ORDER BY {sort_by} {sort_order}
LIMIT $5 OFFSET $6
"#
);
let rows = sqlx::query_as::<_, App>(&sql)
.bind(status)
.bind(app_type)
.bind(created_from)
.bind(created_to)
.bind(page_size as i64)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
#[instrument(skip(self))]
pub async fn get_app(&self, app_id: &str) -> Result<App, AppError> {
self.apply_due_status_changes().await?;
let id = Self::normalize_app_id(app_id)?;
let row = sqlx::query_as::<_, App>(
r#"
SELECT
id,
name,
description,
app_type,
owner,
status,
created_at::text as created_at,
updated_at::text as updated_at
FROM apps
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| AppError::NotFound("App not found".into()))?;
Ok(row)
}
#[instrument(skip(self, req))]
pub async fn update_app(
&self,
app_id: &str,
req: UpdateAppRequest,
actor_user_id: Uuid,
) -> Result<App, AppError> {
let id = Self::normalize_app_id(app_id)?;
let name = match req.name {
Some(v) => Some(Self::normalize_text_required(&v, 100)?),
None => None,
};
let description = Self::normalize_text_opt(req.description, 10_000)?;
let app_type = match req.app_type {
Some(v) => Some(Self::normalize_app_type(&v)?),
None => None,
};
let owner = Self::normalize_text_opt(req.owner, 100)?;
let mut tx = self.pool.begin().await?;
let before: Option<serde_json::Value> = sqlx::query_scalar(
r#"
SELECT to_jsonb(a)
FROM (
SELECT id, name, description, status, app_type, owner
FROM apps
WHERE id = $1
) a
"#,
)
.bind(&id)
.fetch_optional(&mut *tx)
.await?;
if before.is_none() {
return Err(AppError::NotFound("App not found".into()));
}
let updated = sqlx::query_as::<_, App>(
r#"
UPDATE apps
SET
name = COALESCE($1, name),
description = COALESCE($2, description),
app_type = COALESCE($3, app_type),
owner = COALESCE($4, owner),
updated_at = NOW()
WHERE id = $5
RETURNING
id,
name,
description,
app_type,
owner,
status,
created_at::text as created_at,
updated_at::text as updated_at
"#,
)
.bind(name)
.bind(description)
.bind(app_type)
.bind(owner)
.bind(&id)
.fetch_one(&mut *tx)
.await?;
let after = serde_json::json!({
"id": updated.id,
"name": updated.name,
"description": updated.description,
"app_type": updated.app_type,
"owner": updated.owner,
"status": updated.status
});
sqlx::query(
r#"
INSERT INTO app_change_logs (app_id, action, actor_user_id, before, after)
VALUES ($1, 'update', $2, $3, $4)
"#,
)
.bind(&id)
.bind(actor_user_id)
.bind(before.unwrap_or_else(|| serde_json::json!({})))
.bind(after)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ('00000000-0000-0000-0000-000000000001', $1, 'app.update', 'app', 'allow', $2)
"#,
)
.bind(actor_user_id)
.bind(serde_json::json!({ "app_id": id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(updated)
}
fn normalize_status(v: &str) -> Result<String, AppError> {
let s = v.trim().to_ascii_lowercase();
match s.as_str() {
"active" | "disabled" => Ok(s),
_ => Err(AppError::BadRequest("Invalid status".into())),
}
}
#[instrument(skip(self, req))]
pub async fn request_status_change(
&self,
app_id: &str,
req: crate::models::RequestAppStatusChangeRequest,
actor_user_id: Uuid,
) -> Result<AppStatusChangeRequest, AppError> {
let id = Self::normalize_app_id(app_id)?;
let to_status = Self::normalize_status(&req.to_status)?;
let effective_at = match req.effective_at {
Some(v) => Some(
v.parse::<chrono::DateTime<chrono::Utc>>()
.map_err(|_| AppError::BadRequest("Invalid effective_at".into()))?,
),
None => None,
};
let reason = Self::normalize_text_opt(req.reason, 10_000)?;
let mut tx = self.pool.begin().await?;
let from_status: Option<String> =
sqlx::query_scalar("SELECT status FROM apps WHERE id = $1 FOR UPDATE")
.bind(&id)
.fetch_optional(&mut *tx)
.await?;
let Some(from_status) = from_status else {
return Err(AppError::NotFound("App not found".into()));
};
if from_status == to_status {
return Err(AppError::BadRequest("No status change".into()));
}
let row = sqlx::query_as::<_, AppStatusChangeRequest>(
r#"
INSERT INTO app_status_change_requests (app_id, from_status, to_status, requested_by, effective_at, reason)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING
id,
app_id,
from_status,
to_status,
requested_by,
requested_at::text as requested_at,
effective_at::text as effective_at,
status,
approved_by,
approved_at::text as approved_at,
rejected_by,
rejected_at::text as rejected_at,
reason
"#,
)
.bind(&id)
.bind(&from_status)
.bind(&to_status)
.bind(actor_user_id)
.bind(effective_at)
.bind(&reason)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ('00000000-0000-0000-0000-000000000001', $1, 'app.status_change.request', 'app', 'allow', $2)
"#,
)
.bind(actor_user_id)
.bind(serde_json::json!({ "app_id": id, "to_status": to_status }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(row)
}
#[instrument(skip(self, effective_at))]
pub async fn approve_status_change(
&self,
request_id: Uuid,
effective_at: Option<String>,
actor_user_id: Uuid,
) -> Result<AppStatusChangeRequest, AppError> {
let effective_at = match effective_at {
Some(v) => Some(
v.parse::<chrono::DateTime<chrono::Utc>>()
.map_err(|_| AppError::BadRequest("Invalid effective_at".into()))?,
),
None => None,
};
let mut tx = self.pool.begin().await?;
let pending = sqlx::query_as::<
_,
(
Uuid,
String,
String,
Option<chrono::DateTime<chrono::Utc>>,
String,
),
>(
r#"
SELECT id, app_id, to_status, effective_at, status
FROM app_status_change_requests
WHERE id = $1
FOR UPDATE
"#,
)
.bind(request_id)
.fetch_optional(&mut *tx)
.await?;
let Some((id, app_id, to_status, current_effective_at, status)) = pending else {
return Err(AppError::NotFound("Status change request not found".into()));
};
if status != "pending" {
return Err(AppError::BadRequest("Request is not pending".into()));
}
let effective_at = effective_at.or(current_effective_at);
sqlx::query(
r#"
UPDATE app_status_change_requests
SET status = 'approved',
approved_by = $1,
approved_at = NOW(),
effective_at = COALESCE($2, effective_at)
WHERE id = $3
"#,
)
.bind(actor_user_id)
.bind(effective_at)
.bind(id)
.execute(&mut *tx)
.await?;
self.apply_due_status_changes_tx(&mut tx).await?;
let row = sqlx::query_as::<_, AppStatusChangeRequest>(
r#"
SELECT
id,
app_id,
from_status,
to_status,
requested_by,
requested_at::text as requested_at,
effective_at::text as effective_at,
status,
approved_by,
approved_at::text as approved_at,
rejected_by,
rejected_at::text as rejected_at,
reason
FROM app_status_change_requests
WHERE id = $1
"#,
)
.bind(id)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ('00000000-0000-0000-0000-000000000001', $1, 'app.status_change.approve', 'app', 'allow', $2)
"#,
)
.bind(actor_user_id)
.bind(serde_json::json!({ "request_id": request_id, "app_id": app_id, "to_status": to_status }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(row)
}
#[instrument(skip(self, reason))]
pub async fn reject_status_change(
&self,
request_id: Uuid,
reason: Option<String>,
actor_user_id: Uuid,
) -> Result<AppStatusChangeRequest, AppError> {
let reason = Self::normalize_text_opt(reason, 10_000)?;
let mut tx = self.pool.begin().await?;
let pending: Option<String> = sqlx::query_scalar(
r#"
SELECT status
FROM app_status_change_requests
WHERE id = $1
FOR UPDATE
"#,
)
.bind(request_id)
.fetch_optional(&mut *tx)
.await?;
let Some(status) = pending else {
return Err(AppError::NotFound("Status change request not found".into()));
};
if status != "pending" {
return Err(AppError::BadRequest("Request is not pending".into()));
}
sqlx::query(
r#"
UPDATE app_status_change_requests
SET status = 'rejected',
rejected_by = $1,
rejected_at = NOW(),
reason = COALESCE($2, reason)
WHERE id = $3
"#,
)
.bind(actor_user_id)
.bind(&reason)
.bind(request_id)
.execute(&mut *tx)
.await?;
let row = sqlx::query_as::<_, AppStatusChangeRequest>(
r#"
SELECT
id,
app_id,
from_status,
to_status,
requested_by,
requested_at::text as requested_at,
effective_at::text as effective_at,
status,
approved_by,
approved_at::text as approved_at,
rejected_by,
rejected_at::text as rejected_at,
reason
FROM app_status_change_requests
WHERE id = $1
"#,
)
.bind(request_id)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ('00000000-0000-0000-0000-000000000001', $1, 'app.status_change.reject', 'app', 'allow', $2)
"#,
)
.bind(actor_user_id)
.bind(serde_json::json!({ "request_id": request_id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(row)
}
#[instrument(skip(self))]
pub async fn list_status_change_requests(
&self,
status: Option<String>,
page: Option<u32>,
page_size: Option<u32>,
) -> Result<Vec<AppStatusChangeRequest>, AppError> {
let page = page.unwrap_or(1);
let page_size = page_size.unwrap_or(20);
if page == 0 || page_size == 0 || page_size > 200 {
return Err(AppError::BadRequest("Invalid pagination parameters".into()));
}
let offset = (page - 1) as i64 * page_size as i64;
let status = status
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let rows = sqlx::query_as::<_, AppStatusChangeRequest>(
r#"
SELECT
id,
app_id,
from_status,
to_status,
requested_by,
requested_at::text as requested_at,
effective_at::text as effective_at,
status,
approved_by,
approved_at::text as approved_at,
rejected_by,
rejected_at::text as rejected_at,
reason
FROM app_status_change_requests
WHERE ($1::text IS NULL OR status = $1)
ORDER BY requested_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(status)
.bind(page_size as i64)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
#[instrument(skip(self))]
pub async fn delete_app(&self, app_id: &str, actor_user_id: Uuid) -> Result<(), AppError> {
let id = Self::normalize_app_id(app_id)?;
let mut tx = self.pool.begin().await?;
let before: Option<serde_json::Value> = sqlx::query_scalar(
r#"
SELECT to_jsonb(a)
FROM (
SELECT id, name, description, status, app_type, owner
FROM apps
WHERE id = $1
) a
"#,
)
.bind(&id)
.fetch_optional(&mut *tx)
.await?;
if before.is_none() {
return Err(AppError::NotFound("App not found".into()));
}
sqlx::query("UPDATE apps SET status = 'deleted', updated_at = NOW() WHERE id = $1")
.bind(&id)
.execute(&mut *tx)
.await?;
let after: serde_json::Value = sqlx::query_scalar(
r#"
SELECT to_jsonb(a)
FROM (
SELECT id, name, description, status, app_type, owner
FROM apps
WHERE id = $1
) a
"#,
)
.bind(&id)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO app_change_logs (app_id, action, actor_user_id, before, after)
VALUES ($1, 'delete', $2, $3, $4)
"#,
)
.bind(&id)
.bind(actor_user_id)
.bind(before.unwrap_or_else(|| serde_json::json!({})))
.bind(after)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ('00000000-0000-0000-0000-000000000001', $1, 'app.delete', 'app', 'allow', $2)
"#,
)
.bind(actor_user_id)
.bind(serde_json::json!({ "app_id": id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn apply_due_status_changes(&self) -> Result<(), AppError> {
let mut tx = self.pool.begin().await?;
self.apply_due_status_changes_tx(&mut tx).await?;
tx.commit().await?;
Ok(())
}
async fn apply_due_status_changes_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), AppError> {
let due: Vec<(Uuid, String, String)> = sqlx::query_as(
r#"
SELECT id, app_id, to_status
FROM app_status_change_requests
WHERE status = 'approved'
AND COALESCE(effective_at, NOW()) <= NOW()
ORDER BY approved_at NULLS LAST, requested_at ASC
FOR UPDATE
"#,
)
.fetch_all(&mut **tx)
.await?;
for (request_id, app_id, to_status) in due {
sqlx::query("UPDATE apps SET status = $1, updated_at = NOW() WHERE id = $2")
.bind(&to_status)
.bind(&app_id)
.execute(&mut **tx)
.await?;
sqlx::query(
r#"
UPDATE app_status_change_requests
SET status = 'applied'
WHERE id = $1
"#,
)
.bind(request_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::AppService;
#[test]
fn normalize_app_id_rejects_invalid() {
assert!(AppService::normalize_app_id("A").is_err());
assert!(AppService::normalize_app_id("A@").is_err());
assert!(AppService::normalize_app_id("dms").is_ok());
assert_eq!(AppService::normalize_app_id(" CMS ").unwrap(), "cms");
}
}

View File

@@ -1,10 +1,12 @@
pub mod auth;
pub mod app;
pub mod authorization;
pub mod role;
pub mod tenant;
pub mod user;
pub use auth::AuthService;
pub use app::AppService;
pub use authorization::AuthorizationService;
pub use role::RoleService;
pub use tenant::TenantService;

View File

@@ -1,5 +1,9 @@
use crate::models::{UpdateUserRequest, User};
use crate::utils::{hash_password, verify_password};
use base64::Engine;
use common_telemetry::AppError;
use rand::RngCore;
use serde_json::json;
use sqlx::PgPool;
use tracing::instrument;
use uuid::Uuid;
@@ -106,4 +110,140 @@ impl UserService {
}
Ok(())
}
#[instrument(skip(self, current_password, new_password))]
pub async fn reset_my_password(
&self,
tenant_id: Uuid,
user_id: Uuid,
current_password: String,
new_password: String,
) -> Result<(), AppError> {
if current_password.trim().is_empty() || new_password.trim().is_empty() {
return Err(AppError::BadRequest("Password is required".into()));
}
if new_password.trim().len() < 8 {
return Err(AppError::BadRequest("Password too short".into()));
}
let mut tx = self.pool.begin().await?;
let stored_hash: Option<String> = sqlx::query_scalar(
"SELECT password_hash FROM users WHERE tenant_id = $1 AND id = $2 FOR UPDATE",
)
.bind(tenant_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?;
let Some(stored_hash) = stored_hash else {
return Err(AppError::NotFound("User not found".into()));
};
if !verify_password(&current_password, &stored_hash) {
return Err(AppError::InvalidCredentials);
}
let new_hash = hash_password(new_password.trim())
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
sqlx::query(
"UPDATE users SET password_hash = $1, updated_at = NOW() WHERE tenant_id = $2 AND id = $3",
)
.bind(&new_hash)
.bind(tenant_id)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?;
sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = $1")
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'user.password.reset.self', 'user', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(user_id)
.bind(json!({ "target_user_id": user_id }))
.execute(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?;
tx.commit().await?;
Ok(())
}
pub fn generate_temporary_password(length: usize) -> Result<String, AppError> {
let length = length.clamp(16, 64);
let mut bytes = vec![0u8; length];
rand::rng().fill_bytes(&mut bytes);
let mut out = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
out.truncate(length);
Ok(out)
}
#[instrument(skip(self))]
pub async fn reset_user_password_as_admin(
&self,
tenant_id: Uuid,
actor_user_id: Uuid,
target_user_id: Uuid,
length: Option<usize>,
) -> Result<String, AppError> {
let temp = Self::generate_temporary_password(length.unwrap_or(20))?;
let new_hash =
hash_password(&temp).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
let mut tx = self.pool.begin().await?;
let updated: i64 = sqlx::query_scalar(
r#"
UPDATE users
SET password_hash = $1, updated_at = NOW()
WHERE tenant_id = $2 AND id = $3
RETURNING 1
"#,
)
.bind(&new_hash)
.bind(tenant_id)
.bind(target_user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?
.unwrap_or(0);
if updated == 0 {
return Err(AppError::NotFound("User not found".into()));
}
sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = $1")
.bind(target_user_id)
.execute(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'user.password.reset.admin', 'user', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(json!({ "target_user_id": target_user_id }))
.execute(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?;
tx.commit().await?;
Ok(temp)
}
}