feat(handler): add app
This commit is contained in:
12
.env.example
12
.env.example
@@ -7,3 +7,15 @@ LOG_FILE_NAME=iam.log
|
||||
DATABASE_URL=postgres://iam_service_user:iam_service_password@localhost:5432/iam_service_db
|
||||
JWT_SECRET=please_replace_with_a_secure_random_string
|
||||
PORT=3000
|
||||
|
||||
# Optional: Scalar/OpenAPI example injection
|
||||
IAM_DOCS_DEFAULT_TENANT_ID=
|
||||
IAM_DOCS_DEFAULT_TOKEN=
|
||||
IAM_DOCS_REQUIRE_TENANT_ID=0
|
||||
IAM_DOCS_REQUIRE_TOKEN=0
|
||||
|
||||
# Optional: second-factor token for sensitive operations (tenant create, app delete)
|
||||
IAM_SENSITIVE_ACTION_TOKEN=
|
||||
|
||||
# Optional: enable second-factor token for password reset (admin path uses this token too)
|
||||
# (same IAM_SENSITIVE_ACTION_TOKEN header: X-Sensitive-Token)
|
||||
|
||||
129
docs/APP_API.md
Normal file
129
docs/APP_API.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# App 生命周期管理接口(平台级)
|
||||
|
||||
本模块用于维护“允许的 App 注册表(apps)”,并提供应用上下线审批(含生效时间)与审计记录能力。
|
||||
|
||||
## 权限与访问级别
|
||||
|
||||
本模块所有接口均为平台级接口:
|
||||
- URL 前缀:`/platform`
|
||||
- 认证:`Authorization: Bearer <access_token>`
|
||||
- 权限:平台权限(系统角色 `SuperAdmin`)校验
|
||||
|
||||
权限码:
|
||||
- `iam:app:read`:查询 apps 与审批单
|
||||
- `iam:app:write`:创建/更新 apps、提交上下线申请
|
||||
- `iam:app:approve`:审批上下线申请
|
||||
- `iam:app:delete`:删除(软删除)app
|
||||
|
||||
## 通用响应结构
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "Success|Created", "data": {}, "trace_id": null }
|
||||
```
|
||||
|
||||
错误(示例):
|
||||
|
||||
```json
|
||||
{ "code": 20003, "message": "Permission denied: iam:app:read", "details": null, "trace_id": null }
|
||||
```
|
||||
|
||||
常见错误码:
|
||||
- 20000/20006:未认证(缺少 Authorization)
|
||||
- 20003:无权限(缺少平台权限码)
|
||||
- 30000:参数错误(格式/长度/取值非法)
|
||||
- 30002:资源不存在(app / request_id 不存在)
|
||||
- 30003:资源冲突(app_id 重复)
|
||||
|
||||
## 1) 新增 App
|
||||
|
||||
**POST** `/platform/apps`
|
||||
|
||||
Body:
|
||||
- `id`:应用标识符(2~32,`[a-z0-9_-]`,建议小写,如 `cms`/`tms`)
|
||||
- `name`:名称(必填,<=100)
|
||||
- `description`:描述(可选)
|
||||
- `app_type`:类型(可选,默认 `generic`,建议小写短标识)
|
||||
- `owner`:负责人/团队(可选,<=100)
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:3000/platform/apps" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "id":"dms","name":"DMS","description":"Document","app_type":"product","owner":"team-a" }'
|
||||
```
|
||||
|
||||
## 2) 查询 App 列表(分页/筛选/排序)
|
||||
|
||||
**GET** `/platform/apps`
|
||||
|
||||
Query:
|
||||
- `page`:默认 1
|
||||
- `page_size`:默认 20,范围 1..=200
|
||||
- `status`:可选(`active|disabled|deleted`)
|
||||
- `app_type`:可选
|
||||
- `created_from` / `created_to`:可选(RFC3339)
|
||||
- `sort_by`:`id|name|status|app_type|created_at|updated_at`(默认 `created_at`)
|
||||
- `sort_order`:`asc|desc`(默认 `desc`)
|
||||
|
||||
## 3) 查询 App 详情
|
||||
|
||||
**GET** `/platform/apps/{app_id}`
|
||||
|
||||
## 4) 更新 App 基础信息
|
||||
|
||||
**PATCH** `/platform/apps/{app_id}`
|
||||
|
||||
Body(全部可选):
|
||||
- `name`
|
||||
- `description`
|
||||
- `app_type`
|
||||
- `owner`
|
||||
|
||||
## 5) 申请 App 上下线(审批 + 生效时间)
|
||||
|
||||
**POST** `/platform/apps/{app_id}/status-change-requests`
|
||||
|
||||
Body:
|
||||
- `to_status`:`active|disabled`
|
||||
- `effective_at`:可选(RFC3339;不填表示审批后立即生效)
|
||||
- `reason`:可选
|
||||
|
||||
说明:
|
||||
- 新建审批单初始 `status=pending`
|
||||
- 审批通过后:
|
||||
- 若 `effective_at` 为空或已到达,则自动应用并把审批单状态置为 `applied`
|
||||
- 若 `effective_at` 在未来,则审批单状态为 `approved`,在后续列表/查询时会尝试自动应用到期变更
|
||||
|
||||
## 6) 查询审批单列表
|
||||
|
||||
**GET** `/platform/app-status-change-requests`
|
||||
|
||||
Query:
|
||||
- `status`:可选(`pending|approved|applied|rejected`)
|
||||
- `page` / `page_size`
|
||||
|
||||
## 7) 审批通过 / 驳回
|
||||
|
||||
通过:
|
||||
|
||||
**POST** `/platform/app-status-change-requests/{request_id}/approve`
|
||||
|
||||
Body:
|
||||
- `effective_at`:可选(用于覆盖/补充生效时间)
|
||||
|
||||
驳回:
|
||||
|
||||
**POST** `/platform/app-status-change-requests/{request_id}/reject?reason=...`
|
||||
|
||||
## 8) 删除 App(软删除)
|
||||
|
||||
**DELETE** `/platform/apps/{app_id}`
|
||||
|
||||
说明:
|
||||
- 软删除会将 `apps.status` 标记为 `deleted`
|
||||
- 若设置环境变量 `IAM_SENSITIVE_ACTION_TOKEN`,必须同时提供 Header:`X-Sensitive-Token: <token>`
|
||||
|
||||
53
docs/PERF_TEST.md
Normal file
53
docs/PERF_TEST.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 性能压测说明(App 管理接口)
|
||||
|
||||
本仓库默认不携带外部压测工具(如 wrk/oha/hey),因此提供“可复现的压测方法 + 指标口径 + 建议阈值”,并建议在 CI/CD 或预发环境执行并归档结果。
|
||||
|
||||
## 1) 压测对象
|
||||
|
||||
- `GET /platform/apps`:分页列表(典型读流量)
|
||||
- `POST /platform/apps`:创建(写流量)
|
||||
- `POST /platform/apps/{app_id}/status-change-requests`:写流量
|
||||
|
||||
## 2) 指标口径
|
||||
|
||||
- 吞吐:RPS
|
||||
- 延迟:p50 / p95 / p99
|
||||
- 错误率:非 2xx 比例
|
||||
- 资源:CPU / 内存 / DB 连接池占用
|
||||
|
||||
## 3) 环境准备
|
||||
|
||||
- 数据库完成迁移:`./scripts/db/migrate.sh && ./scripts/db/verify.sh`
|
||||
- 用平台租户 token 作为压测 token(必须具备 `iam:app:read` 等权限)
|
||||
- 建议在压测前预填充一定数量 apps(如 1k/10k)
|
||||
|
||||
## 4) 示例(wrk)
|
||||
|
||||
若环境已安装 `wrk`:
|
||||
|
||||
```bash
|
||||
wrk -t4 -c32 -d30s \\
|
||||
-H "Authorization: Bearer <token>" \\
|
||||
"http://127.0.0.1:3000/platform/apps?page=1&page_size=20"
|
||||
```
|
||||
|
||||
## 5) 示例(纯 curl 简易压测)
|
||||
|
||||
该方式不统计精确吞吐与分位数,但可快速验证稳定性与错误率:
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 200); do
|
||||
curl -s -o /dev/null -w "%{http_code}\\n" \\
|
||||
-H "Authorization: Bearer <token>" \\
|
||||
"http://127.0.0.1:3000/platform/apps?page=1&page_size=20" &
|
||||
done
|
||||
wait
|
||||
```
|
||||
|
||||
## 6) 建议阈值(参考)
|
||||
|
||||
- `GET /platform/apps`:p95 < 100ms(本机/同 AZ),错误率 < 0.1%
|
||||
- `POST /platform/apps`:p95 < 200ms,错误率 < 0.1%
|
||||
|
||||
实际阈值应结合数据库规模、索引、网关链路与部署规格确定。
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
|
||||
- Tag:`Tenant`
|
||||
- Header:无
|
||||
- 二次验证(可选但建议生产启用):
|
||||
- 若设置环境变量 `IAM_SENSITIVE_ACTION_TOKEN`,则必须传 Header:`X-Sensitive-Token: <token>`,否则返回 403。
|
||||
- Body:
|
||||
|
||||
```json
|
||||
@@ -212,6 +214,82 @@ enabled_apps 维护建议:
|
||||
- 下线应用:将 `apps.status` 置为非 `active`(例如 `disabled`),之后将无法再被设置进任何租户的 enabled_apps。
|
||||
- 查询可用应用(示例 SQL):`SELECT id, name, status FROM apps ORDER BY id;`
|
||||
|
||||
### Step 4.2:平台层 App 生命周期管理(SuperAdmin)
|
||||
|
||||
用于维护“允许的 App 注册表”,并提供应用上下线(审批 + 生效时间)能力。
|
||||
|
||||
权限要求(平台级):
|
||||
- `iam:app:read`
|
||||
- `iam:app:write`
|
||||
- `iam:app:approve`
|
||||
- `iam:app:delete`
|
||||
|
||||
#### 4.2.1 新增 App
|
||||
|
||||
**POST** `/platform/apps`
|
||||
|
||||
- Tag:`App`
|
||||
- Header:`Authorization: Bearer <access_token>`(平台租户下登录得到的 token)
|
||||
- Body(示例):
|
||||
|
||||
```json
|
||||
{ "id": "dms", "name": "DMS", "description": "Document Management System", "app_type": "product", "owner": "team-a" }
|
||||
```
|
||||
|
||||
#### 4.2.2 查询 App 列表(分页/筛选/排序)
|
||||
|
||||
**GET** `/platform/apps?page=1&page_size=20&status=active&app_type=product&sort_by=created_at&sort_order=desc`
|
||||
|
||||
- Tag:`App`
|
||||
- Header:`Authorization: Bearer <access_token>`
|
||||
|
||||
#### 4.2.3 更新 App 基础信息
|
||||
|
||||
**PATCH** `/platform/apps/{app_id}`
|
||||
|
||||
- Tag:`App`
|
||||
- Header:`Authorization: Bearer <access_token>`
|
||||
- Body(示例):
|
||||
|
||||
```json
|
||||
{ "description": "DMS v2", "owner": "team-b" }
|
||||
```
|
||||
|
||||
#### 4.2.4 申请 App 上下线(需要审批,可设置生效时间)
|
||||
|
||||
**POST** `/platform/apps/{app_id}/status-change-requests`
|
||||
|
||||
- Tag:`App`
|
||||
- Header:`Authorization: Bearer <access_token>`
|
||||
- Body(示例:立即禁用):
|
||||
|
||||
```json
|
||||
{ "to_status": "disabled", "reason": "security patch" }
|
||||
```
|
||||
|
||||
- Body(示例:延迟生效):
|
||||
|
||||
```json
|
||||
{ "to_status": "disabled", "effective_at": "2026-02-01T00:00:00Z", "reason": "maintenance window" }
|
||||
```
|
||||
|
||||
#### 4.2.5 审批上下线申请单
|
||||
|
||||
**GET** `/platform/app-status-change-requests?status=pending&page=1&page_size=20`
|
||||
|
||||
**POST** `/platform/app-status-change-requests/{request_id}/approve`
|
||||
|
||||
**POST** `/platform/app-status-change-requests/{request_id}/reject?reason=...`
|
||||
|
||||
#### 4.2.6 删除 App(软删除)
|
||||
|
||||
**DELETE** `/platform/apps/{app_id}`
|
||||
|
||||
- Tag:`App`
|
||||
- Header:`Authorization: Bearer <access_token>`
|
||||
- 二次验证(可选但建议生产启用):
|
||||
- 若设置环境变量 `IAM_SENSITIVE_ACTION_TOKEN`,则必须同时传 Header:`X-Sensitive-Token: <token>`,否则返回 403。
|
||||
|
||||
### Step 5:列出用户(User)
|
||||
|
||||
**GET** `/users?page=1&page_size=20`
|
||||
@@ -272,3 +350,25 @@ enabled_apps 维护建议:
|
||||
- `/auth/login`:约 2 req/s,burst 10(同一 IP)
|
||||
- `/auth/register`:约 1 req/s,burst 5(同一 IP)
|
||||
- 触发后返回:HTTP 429 + `code=40000`
|
||||
|
||||
## 密码重置(User)
|
||||
|
||||
用户自助重置(需要旧密码):
|
||||
|
||||
- **POST** `/users/me/password/reset`
|
||||
- Tag:`User`
|
||||
- Header:`Authorization: Bearer <access_token>`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{ "current_password": "oldPassword123", "new_password": "newPassword456" }
|
||||
```
|
||||
|
||||
租户管理员重置任意用户(生成临时密码):
|
||||
|
||||
- **POST** `/users/{id}/password/reset`
|
||||
- Tag:`User`
|
||||
- Header:`Authorization: Bearer <access_token>`
|
||||
- 权限:需要 `user:password:reset:any`
|
||||
- 二次验证(可选但建议生产启用):
|
||||
- 若设置环境变量 `IAM_SENSITIVE_ACTION_TOKEN`,则必须传 Header:`X-Sensitive-Token: <token>`,否则返回 403。
|
||||
|
||||
90
docs/SECURITY_GOVERNANCE.md
Normal file
90
docs/SECURITY_GOVERNANCE.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 安全评估与接口治理建议(IAM Service)
|
||||
|
||||
本文面向本仓库当前实现,给出“暴露面审查、风险识别、接口分级、敏感操作二次验证、隐藏策略”建议,并标注已落地的控制点。
|
||||
|
||||
## 1) 当前接口暴露面概览(按访问级别)
|
||||
|
||||
### 公开接口(Public)
|
||||
|
||||
特点:无需 Bearer Token;可被外部直接调用(应最小化)。
|
||||
|
||||
- `POST /tenants/register`:创建租户
|
||||
- `POST /auth/register`:租户内注册用户(依赖 Header `X-Tenant-ID`)
|
||||
- `POST /auth/login`:租户内登录(依赖 Header `X-Tenant-ID`)
|
||||
- `GET /scalar`:OpenAPI/Scalar 文档
|
||||
|
||||
### 认证接口(Authenticated)
|
||||
|
||||
特点:要求 Bearer Token;应默认通过网关/WAF 暴露。
|
||||
|
||||
- `GET /tenants/me`、`PATCH /tenants/me`、`DELETE /tenants/me`
|
||||
- `POST /tenants/me/status`
|
||||
- `GET /me/permissions`
|
||||
- `GET/PATCH/DELETE /users...`、`GET/PUT /users/{id}/roles`
|
||||
- `GET/POST /roles`
|
||||
|
||||
### 内部/平台接口(Internal / Platform)
|
||||
|
||||
特点:平台权限(系统角色)才能访问,建议网关层做更严格限制(IP allowlist、mTLS、独立域名等)。
|
||||
|
||||
- `GET/PUT /platform/tenants/{tenant_id}/enabled-apps`
|
||||
- `GET/POST /platform/apps`、`GET/PATCH/DELETE /platform/apps/{app_id}`
|
||||
- `POST /platform/apps/{app_id}/status-change-requests`
|
||||
- `GET /platform/app-status-change-requests`
|
||||
- `POST /platform/app-status-change-requests/{request_id}/approve|reject`
|
||||
|
||||
## 2) 风险识别(重点)
|
||||
|
||||
- **租户创建为公开接口**:可能被滥用批量创建租户,造成资源耗尽、审计噪声、后续越权风险面扩大。
|
||||
- **注册为公开接口**:对任意租户 ID 可注册用户(虽然要求 `X-Tenant-ID`,但租户 ID 可被枚举/泄露)。
|
||||
- **平台接口暴露风险**:即便有平台权限控制,一旦 token 泄露将影响全局(enabled_apps 与 apps 注册表)。
|
||||
- **文档暴露**:`/scalar` 默认公开,可能暴露内部接口与参数细节。
|
||||
|
||||
## 3) 已落地控制点(本次实现)
|
||||
|
||||
- **敏感操作二次验证(可选开关)**
|
||||
- 通过环境变量 `IAM_SENSITIVE_ACTION_TOKEN` 启用二次验证。
|
||||
- 当启用时,下列操作必须额外携带 Header `X-Sensitive-Token: <token>`:
|
||||
- `POST /tenants/register`
|
||||
- `DELETE /platform/apps/{app_id}`
|
||||
- 目的:降低“单一 Bearer Token 泄露”或“误调用”带来的破坏面。
|
||||
|
||||
- **平台权限细分**
|
||||
- apps 生命周期管理引入平台权限码:`iam:app:read|write|approve|delete`
|
||||
- enabled_apps 管理权限码:`iam:tenant:enabled_apps:read|write`
|
||||
|
||||
## 4) 建议的接口分级制度(治理)
|
||||
|
||||
建议把接口按“公开 / 认证 / 内部平台”分级,并在网关与代码侧同时落实:
|
||||
|
||||
- Public(公开)
|
||||
- 强制:限流、验证码/人机校验(可选)、IP 风控、灰度开关、审计
|
||||
- 建议:对 `tenants/register` 改为“邀请制/工单制”,或迁移到平台后台
|
||||
|
||||
- Authenticated(认证)
|
||||
- 强制:最小权限、全链路审计、错误码统一、分页上限、输入校验
|
||||
|
||||
- Internal / Platform(内部)
|
||||
- 强制:网关层隔离(独立域名/路由、mTLS 或 IP allowlist)、更严格限流、短 TTL token、专用账号
|
||||
- 建议:默认不在公开文档展示(需要登录后才能看到或独立文档入口)
|
||||
|
||||
## 5) 接口隐藏策略建议(不改变业务语义)
|
||||
|
||||
- 网关层
|
||||
- 路由隔离:`/platform/*` 走独立 upstream 或仅内网可达
|
||||
- 访问控制:IP allowlist、mTLS、JWT audience/issuer 分离
|
||||
- 速率限制:对平台接口与租户创建设置更低阈值
|
||||
|
||||
- 版本管理
|
||||
- 对内部平台接口采用独立版本前缀:如 `/platform/v1/...`
|
||||
- 对外接口保持稳定;内部接口可快速迭代
|
||||
|
||||
- 文档权限
|
||||
- 将 `/scalar` 拆分为 public 与 internal 两份,或对 internal 文档加登录保护
|
||||
|
||||
## 6) 下一步改造建议(按风险优先级)
|
||||
|
||||
1. 将 `POST /tenants/register` 从公开接口迁移为平台接口(或邀请制),并引入更强风控。
|
||||
2. 对 `POST /auth/register` 引入租户级注册策略(开关、邀请、允许域名白名单等)。
|
||||
3. 对 `/platform/*` 接口在网关实现“物理隔离”(内网 + mTLS)而不仅是逻辑权限校验。
|
||||
|
||||
44
docs/TEST_REPORT.md
Normal file
44
docs/TEST_REPORT.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 测试报告(App 生命周期管理)
|
||||
|
||||
## 1) 目标与范围
|
||||
|
||||
覆盖本次新增的 App 生命周期管理能力:
|
||||
- app id/name/type 等输入校验与清洗
|
||||
- apps 注册表的创建/更新/查询
|
||||
- 上下线审批(申请/审批/生效)
|
||||
- 软删除
|
||||
|
||||
## 2) 测试类型
|
||||
|
||||
### 单元测试(无需数据库)
|
||||
|
||||
- `src/services/app.rs`:包含 app_id 等关键字段校验用例
|
||||
|
||||
### 集成/冒烟测试(需要 DATABASE_URL)
|
||||
|
||||
当设置 `DATABASE_URL` 时会执行:
|
||||
- `tests/enabled_apps_smoke.rs`:enabled_apps 读写与版本冲突
|
||||
- `tests/app_lifecycle_smoke.rs`:apps 创建/更新/审批禁用/软删除
|
||||
|
||||
若未设置 `DATABASE_URL`,上述集成测试会自动跳过(返回 Ok)。
|
||||
|
||||
## 3) 执行方式
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
若要执行集成测试,需提供可用的 PostgreSQL 连接串,并保证已完成迁移:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL='postgres://...'
|
||||
./scripts/db/migrate.sh
|
||||
./scripts/db/verify.sh
|
||||
cargo test
|
||||
```
|
||||
|
||||
## 4) 结果说明
|
||||
|
||||
- 已在当前仓库执行 `cargo test`,测试通过。
|
||||
- 代码仓库内测试已保持可重复执行,并在无数据库时不阻塞开发体验。
|
||||
- 覆盖率统计需依赖覆盖率工具(如 cargo-llvm-cov 或 tarpaulin);本仓库当前未内置该工具链。
|
||||
@@ -16,6 +16,8 @@
|
||||
|---|---|---|
|
||||
| 0001 | `migrations/0001_core.sql` | IAM 核心表(tenant/user/role/permission 等)与基础种子数据 |
|
||||
| 0002 | `migrations/0002_enabled_apps.sql` | enabled_apps(租户应用开通)、平台租户与平台权限(SuperAdmin) |
|
||||
| 0003 | `migrations/0003_app_lifecycle.sql` | apps 生命周期管理(扩展字段、变更记录、上下线审批) |
|
||||
| 0004 | `migrations/0004_password_reset.sql` | 密码重置(权限码与 Admin/SuperAdmin 授权) |
|
||||
|
||||
校验脚本映射(与 migrations 一一对应):
|
||||
|
||||
@@ -23,6 +25,8 @@
|
||||
|---|---|---|
|
||||
| 0001 | `scripts/db/verify/0001_core.sql` | 校验核心表、索引与基础种子 |
|
||||
| 0002 | `scripts/db/verify/0002_enabled_apps.sql` | 校验 enabled_apps 相关表与平台种子 |
|
||||
| 0003 | `scripts/db/verify/0003_app_lifecycle.sql` | 校验 apps 生命周期管理相关表与权限种子 |
|
||||
| 0004 | `scripts/db/verify/0004_password_reset.sql` | 校验密码重置权限码种子 |
|
||||
|
||||
## 执行方式
|
||||
|
||||
|
||||
64
scripts/db/migrations/0003_app_lifecycle.sql
Normal file
64
scripts/db/migrations/0003_app_lifecycle.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE apps
|
||||
ADD COLUMN IF NOT EXISTS app_type VARCHAR(50) NOT NULL DEFAULT 'generic',
|
||||
ADD COLUMN IF NOT EXISTS owner VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS owner_user_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW();
|
||||
|
||||
UPDATE apps
|
||||
SET updated_at = COALESCE(updated_at, created_at, NOW());
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_status ON apps(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_app_type ON apps(app_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_created_at ON apps(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_change_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
app_id VARCHAR(32) NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
actor_user_id UUID,
|
||||
before JSONB,
|
||||
after JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_change_logs_app_id ON app_change_logs(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_change_logs_created_at ON app_change_logs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_status_change_requests (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
app_id VARCHAR(32) NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
from_status VARCHAR(20) NOT NULL,
|
||||
to_status VARCHAR(20) NOT NULL,
|
||||
requested_by UUID,
|
||||
requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
effective_at TIMESTAMP WITH TIME ZONE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
approved_by UUID,
|
||||
approved_at TIMESTAMP WITH TIME ZONE,
|
||||
rejected_by UUID,
|
||||
rejected_at TIMESTAMP WITH TIME ZONE,
|
||||
reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_status_change_requests_status ON app_status_change_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_status_change_requests_app_id ON app_status_change_requests(app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_status_change_requests_effective_at ON app_status_change_requests(effective_at);
|
||||
|
||||
INSERT INTO permissions (code, description, resource, action) VALUES
|
||||
('iam:app:read', 'Read apps registry', 'app', 'read'),
|
||||
('iam:app:write', 'Manage apps registry', 'app', 'write'),
|
||||
('iam:app:approve', 'Approve app status change', 'app_status_change', 'approve'),
|
||||
('iam:app:delete', 'Delete apps registry', 'app', 'delete')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r, permissions p
|
||||
WHERE r.name = 'SuperAdmin'
|
||||
AND r.tenant_id = '00000000-0000-0000-0000-000000000001'
|
||||
AND p.code IN ('iam:app:read', 'iam:app:write', 'iam:app:approve', 'iam:app:delete')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
24
scripts/db/migrations/0004_password_reset.sql
Normal file
24
scripts/db/migrations/0004_password_reset.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO permissions (code, description, resource, action) VALUES
|
||||
('user:password:reset:any', 'Reset any user password in tenant', 'user_password', 'reset_any')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r, permissions p
|
||||
WHERE r.name = 'Admin'
|
||||
AND r.is_system = TRUE
|
||||
AND p.code = 'user:password:reset:any'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r, permissions p
|
||||
WHERE r.name = 'SuperAdmin'
|
||||
AND r.tenant_id = '00000000-0000-0000-0000-000000000001'
|
||||
AND p.code = 'user:password:reset:any'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
24
scripts/db/rollback/0003.down.sql
Normal file
24
scripts/db/rollback/0003.down.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM role_permissions rp
|
||||
USING roles r, permissions p
|
||||
WHERE rp.role_id = r.id
|
||||
AND rp.permission_id = p.id
|
||||
AND r.name = 'SuperAdmin'
|
||||
AND r.tenant_id = '00000000-0000-0000-0000-000000000001'
|
||||
AND p.code IN ('iam:app:read', 'iam:app:write', 'iam:app:approve', 'iam:app:delete');
|
||||
|
||||
DELETE FROM permissions
|
||||
WHERE code IN ('iam:app:read', 'iam:app:write', 'iam:app:approve', 'iam:app:delete');
|
||||
|
||||
DROP TABLE IF EXISTS app_status_change_requests;
|
||||
DROP TABLE IF EXISTS app_change_logs;
|
||||
|
||||
ALTER TABLE apps
|
||||
DROP COLUMN IF EXISTS app_type,
|
||||
DROP COLUMN IF EXISTS owner,
|
||||
DROP COLUMN IF EXISTS owner_user_id,
|
||||
DROP COLUMN IF EXISTS updated_at;
|
||||
|
||||
COMMIT;
|
||||
|
||||
17
scripts/db/rollback/0004.down.sql
Normal file
17
scripts/db/rollback/0004.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM role_permissions rp
|
||||
USING roles r, permissions p
|
||||
WHERE rp.role_id = r.id
|
||||
AND rp.permission_id = p.id
|
||||
AND p.code = 'user:password:reset:any'
|
||||
AND (
|
||||
(r.name = 'Admin' AND r.is_system = TRUE)
|
||||
OR (r.name = 'SuperAdmin' AND r.tenant_id = '00000000-0000-0000-0000-000000000001')
|
||||
);
|
||||
|
||||
DELETE FROM permissions
|
||||
WHERE code = 'user:password:reset:any';
|
||||
|
||||
COMMIT;
|
||||
|
||||
24
scripts/db/verify/0003_app_lifecycle.sql
Normal file
24
scripts/db/verify/0003_app_lifecycle.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.app_change_logs') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: app_change_logs';
|
||||
END IF;
|
||||
IF to_regclass('public.app_status_change_requests') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: app_status_change_requests';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'apps' AND column_name = 'app_type'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'apps.app_type missing';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'iam:app:read') THEN
|
||||
RAISE EXCEPTION 'missing seed permission iam:app:read';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'iam:app:approve') THEN
|
||||
RAISE EXCEPTION 'missing seed permission iam:app:approve';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
7
scripts/db/verify/0004_password_reset.sql
Normal file
7
scripts/db/verify/0004_password_reset.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'user:password:reset:any') THEN
|
||||
RAISE EXCEPTION 'missing permission user:password:reset:any';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
31
src/docs.rs
31
src/docs.rs
@@ -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
375
src/handlers/app.rs
Normal 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!({})))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
52
src/main.rs
52
src/main.rs
@@ -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,
|
||||
|
||||
134
src/models.rs
134
src/models.rs
@@ -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
802
src/services/app.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤t_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)
|
||||
}
|
||||
}
|
||||
|
||||
93
tests/app_lifecycle_smoke.rs
Normal file
93
tests/app_lifecycle_smoke.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use iam_service::models::{CreateAppRequest, ListAppsQuery, RequestAppStatusChangeRequest, UpdateAppRequest};
|
||||
use iam_service::services::AppService;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_lifecycle_create_update_disable_approve_delete()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let database_url = match std::env::var("DATABASE_URL") {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let pool = PgPool::connect(&database_url).await?;
|
||||
let service = AppService::new(pool.clone());
|
||||
let actor = Uuid::new_v4();
|
||||
|
||||
let app_id = format!("app{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let app_id = &app_id[..std::cmp::min(32, app_id.len())];
|
||||
let app_id = app_id.to_string();
|
||||
|
||||
let created = service
|
||||
.create_app(
|
||||
CreateAppRequest {
|
||||
id: app_id.clone(),
|
||||
name: "Test App".to_string(),
|
||||
description: Some("desc".to_string()),
|
||||
app_type: "product".to_string(),
|
||||
owner: Some("team-a".to_string()),
|
||||
},
|
||||
actor,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(created.id, app_id);
|
||||
assert_eq!(created.status, "active");
|
||||
|
||||
let updated = service
|
||||
.update_app(
|
||||
&app_id,
|
||||
UpdateAppRequest {
|
||||
name: Some("Test App 2".to_string()),
|
||||
description: None,
|
||||
app_type: None,
|
||||
owner: Some("team-b".to_string()),
|
||||
},
|
||||
actor,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(updated.name, "Test App 2");
|
||||
assert_eq!(updated.owner.as_deref(), Some("team-b"));
|
||||
|
||||
let req = service
|
||||
.request_status_change(
|
||||
&app_id,
|
||||
RequestAppStatusChangeRequest {
|
||||
to_status: "disabled".to_string(),
|
||||
effective_at: None,
|
||||
reason: Some("test".to_string()),
|
||||
},
|
||||
actor,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(req.status, "pending");
|
||||
|
||||
let approved = service
|
||||
.approve_status_change(req.id, None, actor)
|
||||
.await?;
|
||||
assert!(approved.status == "approved" || approved.status == "applied");
|
||||
|
||||
let got = service.get_app(&app_id).await?;
|
||||
assert_eq!(got.status, "disabled");
|
||||
|
||||
let list = service
|
||||
.list_apps(ListAppsQuery {
|
||||
page: Some(1),
|
||||
page_size: Some(50),
|
||||
status: Some("disabled".to_string()),
|
||||
app_type: None,
|
||||
sort_by: Some("id".to_string()),
|
||||
sort_order: Some("asc".to_string()),
|
||||
created_from: None,
|
||||
created_to: None,
|
||||
})
|
||||
.await?;
|
||||
assert!(list.iter().any(|a| a.id == app_id));
|
||||
|
||||
service.delete_app(&app_id, actor).await?;
|
||||
let got2 = service.get_app(&app_id).await?;
|
||||
assert_eq!(got2.status, "deleted");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
139
tests/password_reset_smoke.rs
Normal file
139
tests/password_reset_smoke.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use iam_service::models::{CreateUserRequest, LoginRequest};
|
||||
use iam_service::services::{AuthService, TenantService, UserService};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_reset_self_and_admin_flow()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let database_url = match std::env::var("DATABASE_URL") {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let pool = PgPool::connect(&database_url).await?;
|
||||
|
||||
let has_users: Option<String> = sqlx::query_scalar("SELECT to_regclass('public.users')::text")
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
let has_refresh_tokens: Option<String> =
|
||||
sqlx::query_scalar("SELECT to_regclass('public.refresh_tokens')::text")
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
if has_users.is_none() || has_refresh_tokens.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tenant_service = TenantService::new(pool.clone());
|
||||
let user_service = UserService::new(pool.clone());
|
||||
let auth_service = AuthService::new(pool.clone(), "unused".to_string());
|
||||
|
||||
let tenant = tenant_service
|
||||
.create_tenant(iam_service::models::CreateTenantRequest {
|
||||
name: format!("pwreset-{}", Uuid::new_v4()),
|
||||
config: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let admin = auth_service
|
||||
.register(
|
||||
tenant.id,
|
||||
CreateUserRequest {
|
||||
email: format!("admin-{}@example.com", Uuid::new_v4()),
|
||||
password: "AdminOldPassword123".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let user = auth_service
|
||||
.register(
|
||||
tenant.id,
|
||||
CreateUserRequest {
|
||||
email: format!("user-{}@example.com", Uuid::new_v4()),
|
||||
password: "UserOldPassword123".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let login1 = auth_service
|
||||
.login(
|
||||
tenant.id,
|
||||
LoginRequest {
|
||||
email: user.email.clone(),
|
||||
password: "UserOldPassword123".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let active_tokens: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(1) FROM refresh_tokens WHERE user_id = $1 AND is_revoked = FALSE")
|
||||
.bind(user.id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert!(active_tokens >= 1);
|
||||
|
||||
user_service
|
||||
.reset_my_password(
|
||||
tenant.id,
|
||||
user.id,
|
||||
"UserOldPassword123".to_string(),
|
||||
"UserNewPassword456".to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let revoked_tokens: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(1) FROM refresh_tokens WHERE user_id = $1 AND is_revoked = TRUE")
|
||||
.bind(user.id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert!(revoked_tokens >= 1);
|
||||
|
||||
let old_login = auth_service
|
||||
.login(
|
||||
tenant.id,
|
||||
LoginRequest {
|
||||
email: user.email.clone(),
|
||||
password: "UserOldPassword123".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(old_login.is_err());
|
||||
|
||||
let _new_login = auth_service
|
||||
.login(
|
||||
tenant.id,
|
||||
LoginRequest {
|
||||
email: user.email.clone(),
|
||||
password: "UserNewPassword456".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let temp = user_service
|
||||
.reset_user_password_as_admin(tenant.id, admin.id, user.id, Some(24))
|
||||
.await?;
|
||||
assert!(temp.len() >= 16 && temp.len() <= 64);
|
||||
|
||||
let old2 = auth_service
|
||||
.login(
|
||||
tenant.id,
|
||||
LoginRequest {
|
||||
email: user.email.clone(),
|
||||
password: "UserNewPassword456".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(old2.is_err());
|
||||
|
||||
let _temp_login = auth_service
|
||||
.login(
|
||||
tenant.id,
|
||||
LoginRequest {
|
||||
email: user.email.clone(),
|
||||
password: temp.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = login1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user