From 4dc46659c920d3be64729c632d2c5a412a2cc583 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Sat, 31 Jan 2026 15:44:56 +0800 Subject: [PATCH] feat(handler): add app --- .env.example | 12 + docs/APP_API.md | 129 +++ docs/PERF_TEST.md | 53 ++ docs/SCALAR_GUIDE.md | 100 +++ docs/SECURITY_GOVERNANCE.md | 90 ++ docs/TEST_REPORT.md | 44 + scripts/db/README.md | 4 + scripts/db/migrations/0003_app_lifecycle.sql | 64 ++ scripts/db/migrations/0004_password_reset.sql | 24 + scripts/db/rollback/0003.down.sql | 24 + scripts/db/rollback/0004.down.sql | 17 + scripts/db/verify/0003_app_lifecycle.sql | 24 + scripts/db/verify/0004_password_reset.sql | 7 + src/docs.rs | 31 +- src/handlers/app.rs | 375 ++++++++ src/handlers/mod.rs | 14 +- src/handlers/tenant.rs | 28 +- src/handlers/user.rs | 128 ++- src/main.rs | 52 +- src/models.rs | 134 +++ src/services/app.rs | 802 ++++++++++++++++++ src/services/mod.rs | 2 + src/services/user.rs | 140 +++ tests/app_lifecycle_smoke.rs | 93 ++ tests/password_reset_smoke.rs | 139 +++ 25 files changed, 2516 insertions(+), 14 deletions(-) create mode 100644 docs/APP_API.md create mode 100644 docs/PERF_TEST.md create mode 100644 docs/SECURITY_GOVERNANCE.md create mode 100644 docs/TEST_REPORT.md create mode 100644 scripts/db/migrations/0003_app_lifecycle.sql create mode 100644 scripts/db/migrations/0004_password_reset.sql create mode 100644 scripts/db/rollback/0003.down.sql create mode 100644 scripts/db/rollback/0004.down.sql create mode 100644 scripts/db/verify/0003_app_lifecycle.sql create mode 100644 scripts/db/verify/0004_password_reset.sql create mode 100644 src/handlers/app.rs create mode 100644 src/services/app.rs create mode 100644 tests/app_lifecycle_smoke.rs create mode 100644 tests/password_reset_smoke.rs diff --git a/.env.example b/.env.example index 32dc2cd..db98d93 100644 --- a/.env.example +++ b/.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) diff --git a/docs/APP_API.md b/docs/APP_API.md new file mode 100644 index 0000000..3878c3c --- /dev/null +++ b/docs/APP_API.md @@ -0,0 +1,129 @@ +# App 生命周期管理接口(平台级) + +本模块用于维护“允许的 App 注册表(apps)”,并提供应用上下线审批(含生效时间)与审计记录能力。 + +## 权限与访问级别 + +本模块所有接口均为平台级接口: +- URL 前缀:`/platform` +- 认证:`Authorization: Bearer ` +- 权限:平台权限(系统角色 `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 " \ + -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: ` + diff --git a/docs/PERF_TEST.md b/docs/PERF_TEST.md new file mode 100644 index 0000000..b54de82 --- /dev/null +++ b/docs/PERF_TEST.md @@ -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 " \\ + "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 " \\ + "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% + +实际阈值应结合数据库规模、索引、网关链路与部署规格确定。 + diff --git a/docs/SCALAR_GUIDE.md b/docs/SCALAR_GUIDE.md index 9c1e797..905c21f 100644 --- a/docs/SCALAR_GUIDE.md +++ b/docs/SCALAR_GUIDE.md @@ -77,6 +77,8 @@ - Tag:`Tenant` - Header:无 +- 二次验证(可选但建议生产启用): + - 若设置环境变量 `IAM_SENSITIVE_ACTION_TOKEN`,则必须传 Header:`X-Sensitive-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 `(平台租户下登录得到的 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 ` + +#### 4.2.3 更新 App 基础信息 + +**PATCH** `/platform/apps/{app_id}` + +- Tag:`App` +- Header:`Authorization: Bearer ` +- 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 ` +- 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 ` +- 二次验证(可选但建议生产启用): + - 若设置环境变量 `IAM_SENSITIVE_ACTION_TOKEN`,则必须同时传 Header:`X-Sensitive-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 ` +- Body: + +```json +{ "current_password": "oldPassword123", "new_password": "newPassword456" } +``` + +租户管理员重置任意用户(生成临时密码): + +- **POST** `/users/{id}/password/reset` +- Tag:`User` +- Header:`Authorization: Bearer ` +- 权限:需要 `user:password:reset:any` +- 二次验证(可选但建议生产启用): + - 若设置环境变量 `IAM_SENSITIVE_ACTION_TOKEN`,则必须传 Header:`X-Sensitive-Token: `,否则返回 403。 diff --git a/docs/SECURITY_GOVERNANCE.md b/docs/SECURITY_GOVERNANCE.md new file mode 100644 index 0000000..8bc96a9 --- /dev/null +++ b/docs/SECURITY_GOVERNANCE.md @@ -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: `: + - `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)而不仅是逻辑权限校验。 + diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md new file mode 100644 index 0000000..b295400 --- /dev/null +++ b/docs/TEST_REPORT.md @@ -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);本仓库当前未内置该工具链。 diff --git a/scripts/db/README.md b/scripts/db/README.md index aee43a1..450d8b3 100644 --- a/scripts/db/README.md +++ b/scripts/db/README.md @@ -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` | 校验密码重置权限码种子 | ## 执行方式 diff --git a/scripts/db/migrations/0003_app_lifecycle.sql b/scripts/db/migrations/0003_app_lifecycle.sql new file mode 100644 index 0000000..9d91ab7 --- /dev/null +++ b/scripts/db/migrations/0003_app_lifecycle.sql @@ -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; diff --git a/scripts/db/migrations/0004_password_reset.sql b/scripts/db/migrations/0004_password_reset.sql new file mode 100644 index 0000000..83b073c --- /dev/null +++ b/scripts/db/migrations/0004_password_reset.sql @@ -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; + diff --git a/scripts/db/rollback/0003.down.sql b/scripts/db/rollback/0003.down.sql new file mode 100644 index 0000000..ca92c97 --- /dev/null +++ b/scripts/db/rollback/0003.down.sql @@ -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; + diff --git a/scripts/db/rollback/0004.down.sql b/scripts/db/rollback/0004.down.sql new file mode 100644 index 0000000..6ed996d --- /dev/null +++ b/scripts/db/rollback/0004.down.sql @@ -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; + diff --git a/scripts/db/verify/0003_app_lifecycle.sql b/scripts/db/verify/0003_app_lifecycle.sql new file mode 100644 index 0000000..e7075fb --- /dev/null +++ b/scripts/db/verify/0003_app_lifecycle.sql @@ -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 $$; + diff --git a/scripts/db/verify/0004_password_reset.sql b/scripts/db/verify/0004_password_reset.sql new file mode 100644 index 0000000..8278849 --- /dev/null +++ b/scripts/db/verify/0004_password_reset.sql @@ -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 $$; + diff --git a/src/docs.rs b/src/docs.rs index aff3704..8540783 100644 --- a/src/docs.rs +++ b/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/策略引擎后续扩展)") ) )] diff --git a/src/handlers/app.rs b/src/handlers/app.rs new file mode 100644 index 0000000..ebf29de --- /dev/null +++ b/src/handlers/app.rs @@ -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 (访问令牌)") + ) +)] +#[instrument(skip(state, payload))] +pub async fn create_app_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ListAppsQuery + ) +)] +#[instrument(skip(state))] +pub async fn list_apps_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Query(query): Query, +) -> Result>, 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 (访问令牌)"), + ("app_id" = String, Path, description = "App id") + ) +)] +#[instrument(skip(state))] +pub async fn get_app_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(app_id): Path, +) -> Result, 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 (访问令牌)"), + ("app_id" = String, Path, description = "App id") + ) +)] +#[instrument(skip(state, payload))] +pub async fn update_app_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(app_id): Path, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ("app_id" = String, Path, description = "App id") + ) +)] +#[instrument(skip(state, payload))] +pub async fn request_app_status_change_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(app_id): Path, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ("status" = Option, Query, description = "pending/approved/applied/rejected"), + ("page" = Option, Query, description = "页码,默认 1"), + ("page_size" = Option, Query, description = "每页数量,默认 20,最大 200") + ) +)] +#[instrument(skip(state))] +pub async fn list_app_status_change_requests_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Query(params): Query>, +) -> Result>, 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::().ok()); + let page_size = params + .get("page_size") + .and_then(|v| v.parse::().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 (访问令牌)"), + ("request_id" = String, Path, description = "Request id (UUID)") + ) +)] +#[instrument(skip(state, payload))] +pub async fn approve_app_status_change_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(request_id): Path, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ("request_id" = String, Path, description = "Request id (UUID)"), + ("reason" = Option, Query, description = "Reject reason") + ) +)] +#[instrument(skip(state))] +pub async fn reject_app_status_change_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(request_id): Path, + Query(params): Query>, +) -> Result, 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 (访问令牌)"), + ("X-Sensitive-Token" = Option, 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, + AuthContext { user_id, .. }: AuthContext, + headers: HeaderMap, + Path(app_id): Path, +) -> Result, 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!({}))) +} + diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 3b09d32..e9f4ff3 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -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, } diff --git a/src/handlers/tenant.rs b/src/handlers/tenant.rs index a1b3663..9589f3e 100644 --- a/src/handlers/tenant.rs +++ b/src/handlers/tenant.rs @@ -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, 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, Json(payload): Json, ) -> Result, AppError> { + require_sensitive_token(&headers)?; let tenant = state.tenant_service.create_tenant(payload).await?; let response = TenantResponse { id: tenant.id, diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 629aca7..a6affad 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -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, } +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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ("X-Sensitive-Token" = Option, 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, + AuthContext { + tenant_id: auth_tenant_id, + user_id: actor_user_id, + .. + }: AuthContext, + headers: HeaderMap, + Path(target_user_id): Path, + Json(payload): Json, +) -> Result, 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, + })) +} diff --git a/src/main.rs b/src/main.rs index cff6984..64afe1b 100644 --- a/src/main.rs +++ b/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, diff --git a/src/models.rs b/src/models.rs index 16bc3c8..f7d29fa 100644 --- a/src/models.rs +++ b/src/models.rs @@ -218,3 +218,137 @@ pub struct UpdateUserRolesRequest { #[serde(default)] pub role_ids: Vec, } + +#[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, + #[schema(default = "generic", example = "generic")] + #[serde(default)] + pub app_type: String, + #[serde(default)] + pub owner: Option, + #[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, + #[serde(default)] + pub app_type: String, + #[serde(default)] + pub owner: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct UpdateAppRequest { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub app_type: Option, + #[serde(default)] + pub owner: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct ListAppsQuery { + pub page: Option, + pub page_size: Option, + pub status: Option, + pub app_type: Option, + pub sort_by: Option, + pub sort_order: Option, + pub created_from: Option, + pub created_to: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct RequestAppStatusChangeRequest { + #[serde(default)] + pub to_status: String, + #[serde(default)] + pub effective_at: Option, + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct ApproveAppStatusChangeRequest { + #[serde(default)] + pub effective_at: Option, +} + +#[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, + #[serde(default)] + pub status: String, + #[serde(default)] + pub approved_by: Option, + #[serde(default)] + pub approved_at: Option, + #[serde(default)] + pub rejected_by: Option, + #[serde(default)] + pub rejected_at: Option, + #[serde(default)] + pub reason: Option, +} + +#[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, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +pub struct AdminResetUserPasswordResponse { + #[schema(default = "", example = "TempPass-Example-123")] + #[serde(default)] + pub temporary_password: String, +} diff --git a/src/services/app.rs b/src/services/app.rs new file mode 100644 index 0000000..c1b9e8a --- /dev/null +++ b/src/services/app.rs @@ -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 { + 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, max_len: usize) -> Result, 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 { + 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 { + 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 { + 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, 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::>().ok()); + let created_to = query + .created_to + .and_then(|s| s.parse::>().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 { + 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 { + 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 = 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 { + 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 { + 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::>() + .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 = + 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, + actor_user_id: Uuid, + ) -> Result { + let effective_at = match effective_at { + Some(v) => Some( + v.parse::>() + .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>, + 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, + actor_user_id: Uuid, + ) -> Result { + let reason = Self::normalize_text_opt(reason, 10_000)?; + + let mut tx = self.pool.begin().await?; + let pending: Option = 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, + page: Option, + page_size: Option, + ) -> Result, 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 = 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"); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index c877b51..daac0c6 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -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; diff --git a/src/services/user.rs b/src/services/user.rs index cfcecd7..1665651 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -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 = 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 { + 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, + ) -> Result { + 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) + } } diff --git a/tests/app_lifecycle_smoke.rs b/tests/app_lifecycle_smoke.rs new file mode 100644 index 0000000..c5efcb3 --- /dev/null +++ b/tests/app_lifecycle_smoke.rs @@ -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> { + 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(()) +} + diff --git a/tests/password_reset_smoke.rs b/tests/password_reset_smoke.rs new file mode 100644 index 0000000..780b5c0 --- /dev/null +++ b/tests/password_reset_smoke.rs @@ -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> { + 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 = sqlx::query_scalar("SELECT to_regclass('public.users')::text") + .fetch_one(&pool) + .await?; + let has_refresh_tokens: Option = + 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(()) +} +