fix(sql): fix sql script
This commit is contained in:
@@ -49,9 +49,8 @@ flowchart LR
|
|||||||
- [src/services/mod.rs](file:///home/shay/project/backend/iam-service/src/services/mod.rs):业务逻辑(注册/登录)与数据库交互
|
- [src/services/mod.rs](file:///home/shay/project/backend/iam-service/src/services/mod.rs):业务逻辑(注册/登录)与数据库交互
|
||||||
- [src/models.rs](file:///home/shay/project/backend/iam-service/src/models.rs):DB Model 与请求/响应 DTO(同时用于 OpenAPI Schema)
|
- [src/models.rs](file:///home/shay/project/backend/iam-service/src/models.rs):DB Model 与请求/响应 DTO(同时用于 OpenAPI Schema)
|
||||||
- [src/utils/mod.rs](file:///home/shay/project/backend/iam-service/src/utils/mod.rs):密码哈希与 JWT 签发工具
|
- [src/utils/mod.rs](file:///home/shay/project/backend/iam-service/src/utils/mod.rs):密码哈希与 JWT 签发工具
|
||||||
- [sql/schema_post_init.sql](file:///home/shay/project/backend/iam-service/sql/schema_post_init.sql):Schema 初始化(DDL+DML,适用于开发/测试)
|
- [scripts/db/README.md](file:///home/shay/project/backend/iam-service/scripts/db/README.md):数据库迁移/校验/回滚说明(开发/测试/生产)
|
||||||
- [scripts/db/rebuild_iam_db.sh](file:///home/shay/project/backend/iam-service/scripts/db/rebuild_iam_db.sh):一键重建 schema(可选备份+DROP+重建+校验)
|
- [scripts/db/rebuild_iam_db.sh](file:///home/shay/project/backend/iam-service/scripts/db/rebuild_iam_db.sh):开发用一键重建(会清库重建+迁移+校验,不适合生产)
|
||||||
- [docs/DB_PROVISIONING.md](file:///home/shay/project/backend/iam-service/docs/DB_PROVISIONING.md):数据库/用户创建与 schema 初始化说明(归档)
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
# 数据库初始化与权限(归档)
|
|
||||||
|
|
||||||
本项目的 schema 初始化已从历史的 `init.sql` 拆分为“基础设施初始化(DB/User)”与“schema 初始化(DDL/DML)”两部分:
|
|
||||||
|
|
||||||
- **基础设施初始化(DB/User)**:通常由 DBA/平台完成,适用于首次部署或新环境准备。
|
|
||||||
- **schema 初始化(DDL/DML)**:适用于开发/测试环境的可重复重建,见 `sql/schema_post_init.sql`,并由 `scripts/db/rebuild_iam_db.sh` 一键执行。
|
|
||||||
|
|
||||||
## 1) 创建用户与数据库(首次环境准备)
|
|
||||||
|
|
||||||
在具有足够权限的数据库账号下执行:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE USER iam_service_user WITH PASSWORD 'iam_service_password';
|
|
||||||
CREATE DATABASE iam_service_db OWNER iam_service_user;
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE iam_service_db TO iam_service_user;
|
|
||||||
```
|
|
||||||
|
|
||||||
注意事项:
|
|
||||||
|
|
||||||
- 生产环境不要在仓库内硬编码密码;应由密钥管理系统注入并轮换。
|
|
||||||
- 如果需要启用扩展(如 `uuid-ossp`),请确认应用用户是否有权限,或由 DBA 预先安装。
|
|
||||||
|
|
||||||
## 2) schema 重建(开发/CI)
|
|
||||||
|
|
||||||
推荐使用一键脚本(会 DROP 并重建表结构):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DATABASE_URL='postgres://iam_service_user:***@host:5432/iam_service_db'
|
|
||||||
BACKUP=1 ./scripts/db/rebuild_iam_db.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会按顺序执行:
|
|
||||||
|
|
||||||
- `sql/drop_iam_schema.sql`
|
|
||||||
- `sql/schema_post_init.sql`
|
|
||||||
- `sql/verify_iam_schema.sql`
|
|
||||||
|
|
||||||
## 3) 常见问题
|
|
||||||
|
|
||||||
### 3.1 `.env` 配了 DATABASE_URL 但脚本报 “DATABASE_URL is required”
|
|
||||||
|
|
||||||
`.env` 只是“文件”,不会自动变成进程环境变量。`cargo run` 会通过 `dotenvy` 加载 `.env`,但 bash 脚本默认不会。
|
|
||||||
|
|
||||||
解决方式:
|
|
||||||
|
|
||||||
- 直接 `export DATABASE_URL=...` 后再执行脚本;或
|
|
||||||
- 保持 `.env` 存在于项目根目录,脚本会自动读取其中的 `DATABASE_URL`。
|
|
||||||
|
|
||||||
### 3.2 执行脚本报 “psql: 未找到命令”
|
|
||||||
|
|
||||||
原因:系统未安装 PostgreSQL 客户端工具(`psql`/`pg_dump`),或不在 `PATH` 中。
|
|
||||||
|
|
||||||
安装方式:
|
|
||||||
|
|
||||||
- Ubuntu/Debian:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
|
||||||
```
|
|
||||||
|
|
||||||
- RHEL/CentOS/Fedora:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install -y postgresql
|
|
||||||
```
|
|
||||||
|
|
||||||
- Alpine:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apk add postgresql-client
|
|
||||||
```
|
|
||||||
|
|
||||||
- macOS(Homebrew):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install libpq
|
|
||||||
brew link --force libpq
|
|
||||||
```
|
|
||||||
|
|
||||||
验证方式:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
psql --version
|
|
||||||
```
|
|
||||||
|
|
||||||
安装完成后重新执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BACKUP=1 ./scripts/db/rebuild_iam_db.sh
|
|
||||||
```
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# IAM Service — Scalar 调用顺序指南
|
# IAM Service — Scalar 调用顺序指南(v0.1.0)
|
||||||
|
|
||||||
## Authentication(认证方式)
|
## Authentication(认证方式)
|
||||||
|
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
- 保护接口默认从 Token claim 的 `tenant_id` 推导租户
|
- 保护接口默认从 Token claim 的 `tenant_id` 推导租户
|
||||||
- 可选兼容 `X-Tenant-ID: <uuid>`,若同时提供 Header 与 Token,则必须一致,否则返回 403
|
- 可选兼容 `X-Tenant-ID: <uuid>`,若同时提供 Header 与 Token,则必须一致,否则返回 403
|
||||||
|
|
||||||
|
访问令牌(JWT)除 `tenant_id/user_id/roles/permissions` 外,还包含:
|
||||||
|
|
||||||
|
- `apps`:租户已开通应用列表(如 `["cms","tms"]`)
|
||||||
|
- `apps_version`:租户 enabled_apps 版本号(用于客户端判断是否需要刷新会话)
|
||||||
|
|
||||||
## 通用响应结构
|
## 通用响应结构
|
||||||
|
|
||||||
成功响应:
|
成功响应:
|
||||||
@@ -38,6 +43,22 @@
|
|||||||
|
|
||||||
## Step-by-step(可复制流程)
|
## Step-by-step(可复制流程)
|
||||||
|
|
||||||
|
### Step -1:数据库初始化 / 迁移(开发/测试/生产)
|
||||||
|
|
||||||
|
本服务新增了“租户已开通应用(enabled_apps)”与“平台超级管理员(SuperAdmin)”能力,对应数据库新增表:
|
||||||
|
|
||||||
|
- `apps`
|
||||||
|
- `tenant_entitlements`
|
||||||
|
- `tenant_enabled_apps_history`
|
||||||
|
|
||||||
|
推荐使用版本化迁移脚本初始化与升级:
|
||||||
|
|
||||||
|
- 执行迁移:`scripts/db/migrate.sh`
|
||||||
|
- 执行校验:`scripts/db/verify.sh`
|
||||||
|
- 回滚(按版本):`scripts/db/rollback.sh`
|
||||||
|
|
||||||
|
本仓库同时保留开发用的一键重建脚本:`scripts/db/rebuild_iam_db.sh`(会清库重建,不适合生产)。
|
||||||
|
|
||||||
### Step 0:创建租户(可选)
|
### Step 0:创建租户(可选)
|
||||||
|
|
||||||
**POST** `/tenants/register`
|
**POST** `/tenants/register`
|
||||||
@@ -57,6 +78,23 @@
|
|||||||
|
|
||||||
下一步依赖:`tenant_id`(用于注册/登录时的 `X-Tenant-ID`)。
|
下一步依赖:`tenant_id`(用于注册/登录时的 `X-Tenant-ID`)。
|
||||||
|
|
||||||
|
### Step 0.1:平台超级管理员(可选,但推荐先完成)
|
||||||
|
|
||||||
|
数据库已内置平台租户(Platform tenant):
|
||||||
|
|
||||||
|
- `tenant_id = 00000000-0000-0000-0000-000000000001`
|
||||||
|
|
||||||
|
在平台租户下注册首个用户,将自动获得平台级权限(用于管理各租户 enabled_apps):
|
||||||
|
|
||||||
|
**POST** `/auth/register`
|
||||||
|
|
||||||
|
- Header:`X-Tenant-ID: 00000000-0000-0000-0000-000000000001`
|
||||||
|
- Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "email": "superadmin@example.com", "password": "securePassword123" }
|
||||||
|
```
|
||||||
|
|
||||||
### Step 1:注册用户
|
### Step 1:注册用户
|
||||||
|
|
||||||
**POST** `/auth/register`
|
**POST** `/auth/register`
|
||||||
@@ -120,6 +158,34 @@
|
|||||||
|
|
||||||
下一步依赖:确认具备目标权限(例如 `user:read` / `role:read`)。
|
下一步依赖:确认具备目标权限(例如 `user:read` / `role:read`)。
|
||||||
|
|
||||||
|
### Step 4.1:平台层设置租户已开通应用(SuperAdmin)
|
||||||
|
|
||||||
|
该能力仅允许拥有平台级权限的用户调用:
|
||||||
|
|
||||||
|
- `iam:tenant:enabled_apps:read`
|
||||||
|
- `iam:tenant:enabled_apps:write`
|
||||||
|
|
||||||
|
#### 4.1.1 查询某租户 enabled_apps
|
||||||
|
|
||||||
|
**GET** `/platform/tenants/{tenant_id}/enabled-apps`
|
||||||
|
|
||||||
|
- Header:`Authorization: Bearer <access_token>`(平台租户下登录得到的 token)
|
||||||
|
|
||||||
|
#### 4.1.2 设置某租户 enabled_apps(全量覆盖,幂等)
|
||||||
|
|
||||||
|
**PUT** `/platform/tenants/{tenant_id}/enabled-apps`
|
||||||
|
|
||||||
|
- Header:`Authorization: Bearer <access_token>`
|
||||||
|
- Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "enabled_apps": ["cms", "tms"], "expected_version": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `expected_version` 可选,用于并发控制;不匹配会返回 409。
|
||||||
|
- 登录签发 token 时会自动把 `apps/apps_version` 注入到 JWT,并对 `permissions` 按 enabled_apps 过滤。
|
||||||
|
|
||||||
### Step 5:列出用户(User)
|
### Step 5:列出用户(User)
|
||||||
|
|
||||||
**GET** `/users?page=1&page_size=20`
|
**GET** `/users?page=1&page_size=20`
|
||||||
@@ -147,9 +213,32 @@
|
|||||||
{ "code": 0, "message": "Success", "data": [{ "id": "<role_id>", "name": "Admin", "description": "..." }], "trace_id": null }
|
{ "code": 0, "message": "Success", "data": [{ "id": "<role_id>", "name": "Admin", "description": "..." }], "trace_id": null }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Step 7:用户-角色绑定(User)
|
||||||
|
|
||||||
|
用户注册后默认无角色;通常由具备 `user:write` 的管理员进行角色分配。
|
||||||
|
|
||||||
|
#### 7.1 查询用户角色列表(需要 user:read)
|
||||||
|
|
||||||
|
**GET** `/users/{id}/roles`
|
||||||
|
|
||||||
|
- Header:`Authorization: Bearer <access_token>`
|
||||||
|
|
||||||
|
#### 7.2 设置用户角色(全量覆盖,幂等;需要 user:write)
|
||||||
|
|
||||||
|
**PUT** `/users/{id}/roles`
|
||||||
|
|
||||||
|
- Header:`Authorization: Bearer <access_token>`
|
||||||
|
- Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "role_ids": ["<role_id_1>", "<role_id_2>"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `role_ids` 必须全部属于当前租户,否则返回 400。
|
||||||
|
|
||||||
## 限流说明(Auth)
|
## 限流说明(Auth)
|
||||||
|
|
||||||
- `/auth/login`:约 2 req/s,burst 10(同一 IP)
|
- `/auth/login`:约 2 req/s,burst 10(同一 IP)
|
||||||
- `/auth/register`:约 1 req/s,burst 5(同一 IP)
|
- `/auth/register`:约 1 req/s,burst 5(同一 IP)
|
||||||
- 触发后返回:HTTP 429 + `code=40000`
|
- 触发后返回:HTTP 429 + `code=40000`
|
||||||
|
|
||||||
|
|||||||
@@ -102,3 +102,32 @@
|
|||||||
|
|
||||||
说明:当前实现为物理删除。生产建议改为软删除并触发异步数据清理流程。
|
说明:当前实现为物理删除。生产建议改为软删除并触发异步数据清理流程。
|
||||||
|
|
||||||
|
## 平台级接口(超级管理员)
|
||||||
|
|
||||||
|
以下接口用于管理“租户已开通应用(enabled_apps)”,仅允许拥有平台级权限的用户调用:
|
||||||
|
|
||||||
|
- `iam:tenant:enabled_apps:read`
|
||||||
|
- `iam:tenant:enabled_apps:write`
|
||||||
|
|
||||||
|
### 6) 获取租户已开通应用
|
||||||
|
|
||||||
|
`GET /platform/tenants/{tenant_id}/enabled-apps`
|
||||||
|
|
||||||
|
响应:`TenantEnabledAppsResponse`
|
||||||
|
|
||||||
|
### 7) 更新租户已开通应用(全量覆盖,幂等)
|
||||||
|
|
||||||
|
`PUT /platform/tenants/{tenant_id}/enabled-apps`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled_apps": ["cms", "tms"],
|
||||||
|
"expected_version": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `expected_version` 用于并发控制;不传则按“最后写入覆盖”处理。
|
||||||
|
- 写入成功后会更新 `tenant_entitlements.version`,并将结果同步写入 `tenants.config.enabled_apps` 与 `tenants.config.enabled_apps_version`。
|
||||||
|
|||||||
99
scripts/db/README.md
Normal file
99
scripts/db/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# 数据库脚本(版本化迁移)
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- `migrations/`:按版本顺序的升级脚本(只增不改)
|
||||||
|
- `rollback/`:与 migration 一一对应的回滚脚本(按版本号匹配)
|
||||||
|
- `migrate.sh`:按顺序执行未应用的 migrations
|
||||||
|
- `rollback.sh`:按版本回滚(需要显式确认)
|
||||||
|
- `verify.sh`:执行校验脚本,确认表结构与种子数据满足要求
|
||||||
|
- `reset.sh`:开发/测试用清库重置(会删除所有 IAM 表与迁移记录,需显式确认)
|
||||||
|
- `rebuild_iam_db.sh`:开发环境一键重建(会清库重建,不适合生产)
|
||||||
|
|
||||||
|
## 版本与执行顺序
|
||||||
|
|
||||||
|
| 版本 | 脚本 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 0001 | `migrations/0001_core.sql` | IAM 核心表(tenant/user/role/permission 等)与基础种子数据 |
|
||||||
|
| 0002 | `migrations/0002_enabled_apps.sql` | enabled_apps(租户应用开通)、平台租户与平台权限(SuperAdmin) |
|
||||||
|
|
||||||
|
校验脚本映射(与 migrations 一一对应):
|
||||||
|
|
||||||
|
| 版本 | 校验脚本 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 0001 | `scripts/db/verify/0001_core.sql` | 校验核心表、索引与基础种子 |
|
||||||
|
| 0002 | `scripts/db/verify/0002_enabled_apps.sql` | 校验 enabled_apps 相关表与平台种子 |
|
||||||
|
|
||||||
|
## 执行方式
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
- `DATABASE_URL`:必填(也可放在项目根目录 `.env` 中)
|
||||||
|
依赖:
|
||||||
|
- 需要安装 PostgreSQL 客户端工具(`psql`);可选 `pg_dump`(仅当你要做备份)。
|
||||||
|
|
||||||
|
执行迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/db/migrate.sh
|
||||||
|
./scripts/db/verify.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
开发/测试环境清库重置(危险):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONFIRM=1 ./scripts/db/reset.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚到指定版本(示例:回滚到 0001):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONFIRM=1 TARGET_VERSION=0001 ./scripts/db/rollback.sh
|
||||||
|
./scripts/db/verify.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
验证步骤
|
||||||
|
|
||||||
|
- 执行 `./scripts/db/verify.sh`,应无错误退出
|
||||||
|
- 启动服务后通过 Scalar 访问 `/scalar`,检查 OpenAPI 中包含:
|
||||||
|
- `/platform/tenants/{tenant_id}/enabled-apps`
|
||||||
|
- `/users/{id}/roles`
|
||||||
|
|
||||||
|
## 首次环境准备(DB/User)
|
||||||
|
|
||||||
|
在具有足够权限的数据库账号下执行(示例):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER iam_service_user WITH PASSWORD 'iam_service_password';
|
||||||
|
CREATE DATABASE iam_service_db OWNER iam_service_user;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE iam_service_db TO iam_service_user;
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 生产环境不要在仓库内硬编码密码;应由密钥管理系统注入并轮换。
|
||||||
|
- 如需启用扩展(如 `uuid-ossp`),请确认应用账号是否有权限,或由 DBA 预先安装。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### `.env` 配了 DATABASE_URL 但脚本报 “DATABASE_URL is required”
|
||||||
|
|
||||||
|
`.env` 只是文件,不会自动变成进程环境变量。Rust 程序会通过 `dotenvy` 读取 `.env`,但 bash 脚本默认不会。
|
||||||
|
|
||||||
|
解决方式:
|
||||||
|
- 直接 `export DATABASE_URL=...` 后再执行脚本;或
|
||||||
|
- 保持项目根目录存在 `.env`,脚本会尝试读取其中的 `DATABASE_URL`。
|
||||||
|
|
||||||
|
### 执行脚本报 “psql not found”
|
||||||
|
|
||||||
|
原因:系统未安装 PostgreSQL 客户端工具(`psql`/`pg_dump`),或不在 `PATH` 中。
|
||||||
|
|
||||||
|
安装方式:
|
||||||
|
- Ubuntu/Debian:`sudo apt-get update && sudo apt-get install -y postgresql-client`
|
||||||
|
- RHEL/CentOS/Fedora:`sudo dnf install -y postgresql`
|
||||||
|
- Alpine:`sudo apk add postgresql-client`
|
||||||
|
- macOS(Homebrew):`brew install libpq && brew link --force libpq`
|
||||||
|
|
||||||
|
验证方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql --version
|
||||||
|
```
|
||||||
74
scripts/db/migrate.sh
Normal file
74
scripts/db/migrate.sh
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
|
||||||
|
load_database_url_from_env_file() {
|
||||||
|
local env_file="$1"
|
||||||
|
local line value
|
||||||
|
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||||
|
line="${line#export }"
|
||||||
|
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||||
|
value="${line#DATABASE_URL=}"
|
||||||
|
value="${value%$'\r'}"
|
||||||
|
value="${value%\"}"
|
||||||
|
value="${value#\"}"
|
||||||
|
value="${value%\'}"
|
||||||
|
value="${value#\'}"
|
||||||
|
printf '%s' "${value}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < "${env_file}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
DATABASE_URL="${DATABASE_URL:-}"
|
||||||
|
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||||
|
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${DATABASE_URL}" ]]; then
|
||||||
|
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v psql >/dev/null 2>&1; then
|
||||||
|
echo "psql not found in PATH"
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_VERSION="${TARGET_VERSION:-}"
|
||||||
|
DRY_RUN="${DRY_RUN:-0}"
|
||||||
|
|
||||||
|
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS iam_schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
migrations=( "${SCRIPT_DIR}/migrations/"*.sql )
|
||||||
|
if [[ ${#migrations[@]} -eq 0 ]]; then
|
||||||
|
echo "No migration files found: ${SCRIPT_DIR}/migrations/*.sql"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for file in "${migrations[@]}"; do
|
||||||
|
base="$(basename "${file}")"
|
||||||
|
version="${base%%_*}"
|
||||||
|
if [[ -n "${TARGET_VERSION}" && "${version}" > "${TARGET_VERSION}" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
applied="$(psql "${DATABASE_URL}" -At -c "SELECT 1 FROM iam_schema_migrations WHERE version='${version}' LIMIT 1" || true)"
|
||||||
|
if [[ "${applied}" == "1" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Applying ${version} (${base})"
|
||||||
|
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${file}"
|
||||||
|
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "INSERT INTO iam_schema_migrations(version) VALUES ('${version}') ON CONFLICT DO NOTHING"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Migrations completed"
|
||||||
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
CREATE TABLE tenants (
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
status VARCHAR(50) DEFAULT 'active',
|
status VARCHAR(50) DEFAULT 'active',
|
||||||
@@ -9,7 +11,7 @@ CREATE TABLE tenants (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
email VARCHAR(255) NOT NULL,
|
email VARCHAR(255) NOT NULL,
|
||||||
@@ -25,9 +27,9 @@ CREATE TABLE users (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX idx_users_tenant_email ON users(tenant_id, email);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_email ON users(tenant_id, email);
|
||||||
|
|
||||||
CREATE TABLE roles (
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
@@ -38,7 +40,7 @@ CREATE TABLE roles (
|
|||||||
UNIQUE(tenant_id, name)
|
UNIQUE(tenant_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE permissions (
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
code VARCHAR(100) NOT NULL UNIQUE,
|
code VARCHAR(100) NOT NULL UNIQUE,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -47,21 +49,21 @@ CREATE TABLE permissions (
|
|||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE role_permissions (
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
PRIMARY KEY (role_id, permission_id)
|
PRIMARY KEY (role_id, permission_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE user_roles (
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
PRIMARY KEY (user_id, role_id)
|
PRIMARY KEY (user_id, role_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
token_hash VARCHAR(255) NOT NULL,
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
@@ -71,10 +73,10 @@ CREATE TABLE refresh_tokens (
|
|||||||
replaced_by_token_hash VARCHAR(255)
|
replaced_by_token_hash VARCHAR(255)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
|
|
||||||
CREATE TABLE audit_logs (
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
tenant_id UUID REFERENCES tenants(id),
|
tenant_id UUID REFERENCES tenants(id),
|
||||||
user_id UUID,
|
user_id UUID,
|
||||||
@@ -87,7 +89,9 @@ CREATE TABLE audit_logs (
|
|||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO tenants (id, name) VALUES ('11111111-1111-1111-1111-111111111111', 'Default Corp');
|
INSERT INTO tenants (id, name)
|
||||||
|
VALUES ('11111111-1111-1111-1111-111111111111', 'Default Corp')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO permissions (code, description, resource, action) VALUES
|
INSERT INTO permissions (code, description, resource, action) VALUES
|
||||||
('user:read', 'View users', 'user', 'read'),
|
('user:read', 'View users', 'user', 'read'),
|
||||||
@@ -95,12 +99,19 @@ INSERT INTO permissions (code, description, resource, action) VALUES
|
|||||||
('role:read', 'View roles', 'role', 'read'),
|
('role:read', 'View roles', 'role', 'read'),
|
||||||
('role:write', 'Manage roles', 'role', 'write'),
|
('role:write', 'Manage roles', 'role', 'write'),
|
||||||
('tenant:read', 'View tenant info', 'tenant', 'read'),
|
('tenant:read', 'View tenant info', 'tenant', 'read'),
|
||||||
('tenant:write', 'Manage tenant settings and status', 'tenant', 'write');
|
('tenant:write', 'Manage tenant settings and status', 'tenant', 'write')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO roles (tenant_id, name, description, is_system) VALUES
|
INSERT INTO roles (tenant_id, name, description, is_system)
|
||||||
('11111111-1111-1111-1111-111111111111', 'Admin', 'Administrator with full access', TRUE);
|
VALUES ('11111111-1111-1111-1111-111111111111', 'Admin', 'Administrator with full access', TRUE)
|
||||||
|
ON CONFLICT (tenant_id, name) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO role_permissions (role_id, permission_id)
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
SELECT r.id, p.id FROM roles r, permissions p
|
SELECT r.id, p.id
|
||||||
WHERE r.name = 'Admin' AND r.tenant_id = '11111111-1111-1111-1111-111111111111';
|
FROM roles r, permissions p
|
||||||
|
WHERE r.name = 'Admin'
|
||||||
|
AND r.tenant_id = '11111111-1111-1111-1111-111111111111'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
88
scripts/db/migrations/0002_enabled_apps.sql
Normal file
88
scripts/db/migrations/0002_enabled_apps.sql
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS apps (
|
||||||
|
id VARCHAR(32) PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_entitlements (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
enabled_apps TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_enabled_apps_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
enabled_apps TEXT[] NOT NULL,
|
||||||
|
actor_user_id UUID,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tenants (id, name, config)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000001',
|
||||||
|
'Platform',
|
||||||
|
jsonb_build_object('enabled_apps', jsonb_build_array(), 'enabled_apps_version', 0)
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO apps (id, name, description) VALUES
|
||||||
|
('iam', 'IAM', 'Identity and Access Management'),
|
||||||
|
('cms', 'CMS', 'Content Management Platform'),
|
||||||
|
('tms', 'TMS', 'Task Management Platform')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
|
||||||
|
SELECT t.id, '{}'::text[], 0
|
||||||
|
FROM tenants t
|
||||||
|
ON CONFLICT (tenant_id) DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE tenant_entitlements
|
||||||
|
SET enabled_apps = ARRAY['tms']::text[]
|
||||||
|
WHERE tenant_id = '11111111-1111-1111-1111-111111111111'
|
||||||
|
AND enabled_apps = '{}'::text[];
|
||||||
|
|
||||||
|
UPDATE tenants
|
||||||
|
SET config =
|
||||||
|
jsonb_set(
|
||||||
|
jsonb_set(COALESCE(config, '{}'::jsonb), '{enabled_apps}', to_jsonb(te.enabled_apps), true),
|
||||||
|
'{enabled_apps_version}', to_jsonb(te.version), true
|
||||||
|
)
|
||||||
|
FROM tenant_entitlements te
|
||||||
|
WHERE tenants.id = te.tenant_id;
|
||||||
|
|
||||||
|
INSERT INTO permissions (code, description, resource, action) VALUES
|
||||||
|
('iam:tenant:enabled_apps:read', 'Read tenant enabled apps', 'tenant_enabled_apps', 'read'),
|
||||||
|
('iam:tenant:enabled_apps:write', 'Manage tenant enabled apps', 'tenant_enabled_apps', 'write')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO roles (tenant_id, name, description, is_system)
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000001', 'SuperAdmin', 'Platform super administrator', TRUE)
|
||||||
|
ON CONFLICT (tenant_id, name) DO NOTHING;
|
||||||
|
|
||||||
|
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 = 'Admin'
|
||||||
|
AND r.tenant_id <> '00000000-0000-0000-0000-000000000001'
|
||||||
|
AND p.code LIKE 'iam:%';
|
||||||
|
|
||||||
|
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:tenant:enabled_apps:read', 'iam:tenant:enabled_apps:write')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ if [[ "${BACKUP}" == "1" ]]; then
|
|||||||
echo "Backup written: ${backup_file}"
|
echo "Backup written: ${backup_file}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/drop_iam_schema.sql"
|
CONFIRM=1 "${ROOT_DIR}/scripts/db/reset.sh"
|
||||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/schema_post_init.sql"
|
"${ROOT_DIR}/scripts/db/migrate.sh"
|
||||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/verify_iam_schema.sql"
|
"${ROOT_DIR}/scripts/db/verify.sh"
|
||||||
|
|
||||||
echo "Rebuild completed"
|
echo "Rebuild completed"
|
||||||
|
|||||||
52
scripts/db/reset.sh
Normal file
52
scripts/db/reset.sh
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
|
||||||
|
load_database_url_from_env_file() {
|
||||||
|
local env_file="$1"
|
||||||
|
local line value
|
||||||
|
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||||
|
line="${line#export }"
|
||||||
|
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||||
|
value="${line#DATABASE_URL=}"
|
||||||
|
value="${value%$'\r'}"
|
||||||
|
value="${value%\"}"
|
||||||
|
value="${value#\"}"
|
||||||
|
value="${value%\'}"
|
||||||
|
value="${value#\'}"
|
||||||
|
printf '%s' "${value}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < "${env_file}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
DATABASE_URL="${DATABASE_URL:-}"
|
||||||
|
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||||
|
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${DATABASE_URL}" ]]; then
|
||||||
|
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v psql >/dev/null 2>&1; then
|
||||||
|
echo "psql not found in PATH"
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIRM="${CONFIRM:-0}"
|
||||||
|
if [[ "${CONFIRM}" != "1" ]]; then
|
||||||
|
echo "Refusing to reset database without CONFIRM=1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIRM=1 TARGET_VERSION=0000 "${SCRIPT_DIR}/rollback.sh" || true
|
||||||
|
|
||||||
|
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS iam_schema_migrations"
|
||||||
|
|
||||||
|
echo "Reset completed"
|
||||||
101
scripts/db/rollback.sh
Normal file
101
scripts/db/rollback.sh
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
|
||||||
|
load_database_url_from_env_file() {
|
||||||
|
local env_file="$1"
|
||||||
|
local line value
|
||||||
|
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||||
|
line="${line#export }"
|
||||||
|
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||||
|
value="${line#DATABASE_URL=}"
|
||||||
|
value="${value%$'\r'}"
|
||||||
|
value="${value%\"}"
|
||||||
|
value="${value#\"}"
|
||||||
|
value="${value%\'}"
|
||||||
|
value="${value#\'}"
|
||||||
|
printf '%s' "${value}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < "${env_file}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
DATABASE_URL="${DATABASE_URL:-}"
|
||||||
|
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||||
|
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${DATABASE_URL}" ]]; then
|
||||||
|
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v psql >/dev/null 2>&1; then
|
||||||
|
echo "psql not found in PATH"
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIRM="${CONFIRM:-0}"
|
||||||
|
if [[ "${CONFIRM}" != "1" ]]; then
|
||||||
|
echo "Refusing to run rollback without CONFIRM=1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_VERSION="${TARGET_VERSION:-}"
|
||||||
|
STEPS="${STEPS:-1}"
|
||||||
|
|
||||||
|
if ! psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "SELECT 1 FROM iam_schema_migrations LIMIT 1" >/dev/null 2>&1; then
|
||||||
|
echo "No iam_schema_migrations table found; nothing to rollback"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
applied_versions=()
|
||||||
|
while IFS= read -r v; do
|
||||||
|
[[ -n "${v}" ]] && applied_versions+=( "${v}" )
|
||||||
|
done < <(psql "${DATABASE_URL}" -At -c "SELECT version FROM iam_schema_migrations ORDER BY version DESC")
|
||||||
|
|
||||||
|
if [[ ${#applied_versions[@]} -eq 0 ]]; then
|
||||||
|
echo "No applied migrations; nothing to rollback"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
to_rollback=()
|
||||||
|
if [[ -n "${TARGET_VERSION}" ]]; then
|
||||||
|
for v in "${applied_versions[@]}"; do
|
||||||
|
if [[ "${v}" > "${TARGET_VERSION}" ]]; then
|
||||||
|
to_rollback+=( "${v}" )
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
count=0
|
||||||
|
for v in "${applied_versions[@]}"; do
|
||||||
|
to_rollback+=( "${v}" )
|
||||||
|
count=$((count + 1))
|
||||||
|
if [[ "${count}" -ge "${STEPS}" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#to_rollback[@]} -eq 0 ]]; then
|
||||||
|
echo "No migrations to rollback"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for v in "${to_rollback[@]}"; do
|
||||||
|
down_file="${SCRIPT_DIR}/rollback/${v}.down.sql"
|
||||||
|
if [[ ! -f "${down_file}" ]]; then
|
||||||
|
echo "Missing rollback script: ${down_file}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Rolling back ${v}"
|
||||||
|
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${down_file}"
|
||||||
|
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "DELETE FROM iam_schema_migrations WHERE version='${v}'"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Rollback completed"
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS role_permissions;
|
DROP TABLE IF EXISTS role_permissions;
|
||||||
DROP TABLE IF EXISTS user_roles;
|
DROP TABLE IF EXISTS user_roles;
|
||||||
DROP TABLE IF EXISTS refresh_tokens;
|
DROP TABLE IF EXISTS refresh_tokens;
|
||||||
@@ -7,5 +8,6 @@ DROP TABLE IF EXISTS roles;
|
|||||||
DROP TABLE IF EXISTS permissions;
|
DROP TABLE IF EXISTS permissions;
|
||||||
DROP TABLE IF EXISTS users;
|
DROP TABLE IF EXISTS users;
|
||||||
DROP TABLE IF EXISTS tenants;
|
DROP TABLE IF EXISTS tenants;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
||||||
26
scripts/db/rollback/0002.down.sql
Normal file
26
scripts/db/rollback/0002.down.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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:tenant:enabled_apps:read', 'iam:tenant:enabled_apps:write');
|
||||||
|
|
||||||
|
DELETE FROM roles
|
||||||
|
WHERE tenant_id = '00000000-0000-0000-0000-000000000001'
|
||||||
|
AND name = 'SuperAdmin';
|
||||||
|
|
||||||
|
DELETE FROM permissions
|
||||||
|
WHERE code IN ('iam:tenant:enabled_apps:read', 'iam:tenant:enabled_apps:write');
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS tenant_enabled_apps_history;
|
||||||
|
DROP TABLE IF EXISTS tenant_entitlements;
|
||||||
|
DROP TABLE IF EXISTS apps;
|
||||||
|
|
||||||
|
DELETE FROM tenants
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
88
scripts/db/verify.sh
Normal file
88
scripts/db/verify.sh
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
|
||||||
|
load_database_url_from_env_file() {
|
||||||
|
local env_file="$1"
|
||||||
|
local line value
|
||||||
|
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||||
|
line="${line#export }"
|
||||||
|
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||||
|
value="${line#DATABASE_URL=}"
|
||||||
|
value="${value%$'\r'}"
|
||||||
|
value="${value%\"}"
|
||||||
|
value="${value#\"}"
|
||||||
|
value="${value%\'}"
|
||||||
|
value="${value#\'}"
|
||||||
|
printf '%s' "${value}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < "${env_file}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
DATABASE_URL="${DATABASE_URL:-}"
|
||||||
|
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||||
|
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${DATABASE_URL}" ]]; then
|
||||||
|
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v psql >/dev/null 2>&1; then
|
||||||
|
echo "psql not found in PATH"
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "SELECT 1 FROM iam_schema_migrations LIMIT 1" >/dev/null 2>&1; then
|
||||||
|
echo "iam_schema_migrations not found"
|
||||||
|
echo "Run migrations first: ./scripts/db/migrate.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STRICT="${STRICT:-1}"
|
||||||
|
verify_dir="${SCRIPT_DIR}/verify"
|
||||||
|
if [[ ! -d "${verify_dir}" ]]; then
|
||||||
|
echo "Missing verify directory: ${verify_dir}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
applied_versions=()
|
||||||
|
while IFS= read -r v; do
|
||||||
|
[[ -n "${v}" ]] && applied_versions+=( "${v}" )
|
||||||
|
done < <(psql "${DATABASE_URL}" -At -c "SELECT version FROM iam_schema_migrations ORDER BY version ASC")
|
||||||
|
|
||||||
|
declare -A verify_by_version=()
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in "${verify_dir}/"*.sql; do
|
||||||
|
base="$(basename "${f}")"
|
||||||
|
version="${base%%_*}"
|
||||||
|
version="${version%%.*}"
|
||||||
|
verify_by_version["${version}"]="${f}"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${STRICT}" == "1" ]]; then
|
||||||
|
for v in "${applied_versions[@]}"; do
|
||||||
|
if [[ -z "${verify_by_version[${v}]:-}" ]]; then
|
||||||
|
echo "Missing verify script for applied migration version: ${v}"
|
||||||
|
echo "Expected: ${verify_dir}/${v}_*.sql"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
for v in "${applied_versions[@]}"; do
|
||||||
|
f="${verify_by_version[${v}]:-}"
|
||||||
|
if [[ -z "${f}" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "Verifying ${v} ($(basename "${f}"))"
|
||||||
|
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${f}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Verify completed"
|
||||||
31
scripts/db/verify/0002_enabled_apps.sql
Normal file
31
scripts/db/verify/0002_enabled_apps.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.apps') IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'missing table: apps';
|
||||||
|
END IF;
|
||||||
|
IF to_regclass('public.tenant_entitlements') IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'missing table: tenant_entitlements';
|
||||||
|
END IF;
|
||||||
|
IF to_regclass('public.tenant_enabled_apps_history') IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'missing table: tenant_enabled_apps_history';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM tenants WHERE id = '00000000-0000-0000-0000-000000000001'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'missing seed tenant Platform';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'iam:tenant:enabled_apps:write') THEN
|
||||||
|
RAISE EXCEPTION 'missing seed permission iam:tenant:enabled_apps:write';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM apps WHERE id = 'tms') THEN
|
||||||
|
RAISE EXCEPTION 'missing seed app tms';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM tenant_entitlements WHERE tenant_id = '11111111-1111-1111-1111-111111111111') THEN
|
||||||
|
RAISE EXCEPTION 'missing tenant_entitlements row for Default Corp';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
24
src/docs.rs
24
src/docs.rs
@@ -1,8 +1,9 @@
|
|||||||
use crate::handlers;
|
use crate::handlers;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
CreateRoleRequest, CreateTenantRequest, CreateUserRequest, LoginRequest, LoginResponse, Role,
|
CreateRoleRequest, CreateTenantRequest, CreateUserRequest, LoginRequest, LoginResponse, Role,
|
||||||
RoleResponse, Tenant, TenantResponse, UpdateTenantRequest, UpdateTenantStatusRequest,
|
RoleResponse, Tenant, TenantEnabledAppsResponse, TenantResponse,
|
||||||
UpdateUserRequest, User, UserResponse,
|
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
|
||||||
|
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse,
|
||||||
};
|
};
|
||||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||||
use utoipa::{Modify, OpenApi};
|
use utoipa::{Modify, OpenApi};
|
||||||
@@ -34,10 +35,22 @@ impl Modify for SecurityAddon {
|
|||||||
version = "0.1.0",
|
version = "0.1.0",
|
||||||
description = include_str!("../docs/SCALAR_GUIDE.md")
|
description = include_str!("../docs/SCALAR_GUIDE.md")
|
||||||
),
|
),
|
||||||
|
servers(
|
||||||
|
(
|
||||||
|
url = "https://{env}/api",
|
||||||
|
description = "Environment server",
|
||||||
|
variables(
|
||||||
|
("env" = (default = "dev", enum_values("dev", "staging", "prod"))),
|
||||||
|
("port" = (default = "5010"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
paths(
|
paths(
|
||||||
handlers::auth::register_handler,
|
handlers::auth::register_handler,
|
||||||
handlers::auth::login_handler,
|
handlers::auth::login_handler,
|
||||||
handlers::authorization::my_permissions_handler,
|
handlers::authorization::my_permissions_handler,
|
||||||
|
handlers::platform::get_tenant_enabled_apps_handler,
|
||||||
|
handlers::platform::set_tenant_enabled_apps_handler,
|
||||||
handlers::tenant::create_tenant_handler,
|
handlers::tenant::create_tenant_handler,
|
||||||
handlers::tenant::get_tenant_handler,
|
handlers::tenant::get_tenant_handler,
|
||||||
handlers::tenant::update_tenant_handler,
|
handlers::tenant::update_tenant_handler,
|
||||||
@@ -49,6 +62,8 @@ impl Modify for SecurityAddon {
|
|||||||
handlers::user::get_user_handler,
|
handlers::user::get_user_handler,
|
||||||
handlers::user::update_user_handler,
|
handlers::user::update_user_handler,
|
||||||
handlers::user::delete_user_handler,
|
handlers::user::delete_user_handler,
|
||||||
|
handlers::user::list_user_roles_handler,
|
||||||
|
handlers::user::set_user_roles_handler,
|
||||||
// Add other handlers here as you implement them
|
// Add other handlers here as you implement them
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
@@ -66,7 +81,10 @@ impl Modify for SecurityAddon {
|
|||||||
TenantResponse,
|
TenantResponse,
|
||||||
CreateTenantRequest,
|
CreateTenantRequest,
|
||||||
UpdateTenantRequest,
|
UpdateTenantRequest,
|
||||||
UpdateTenantStatusRequest
|
UpdateTenantStatusRequest,
|
||||||
|
UpdateTenantEnabledAppsRequest,
|
||||||
|
TenantEnabledAppsResponse,
|
||||||
|
UpdateUserRolesRequest
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod authorization;
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod authorization;
|
||||||
|
pub mod platform;
|
||||||
pub mod role;
|
pub mod role;
|
||||||
pub mod tenant;
|
pub mod tenant;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
@@ -8,13 +9,15 @@ use crate::services::{AuthService, AuthorizationService, RoleService, TenantServ
|
|||||||
|
|
||||||
pub use auth::{login_handler, register_handler};
|
pub use auth::{login_handler, register_handler};
|
||||||
pub use authorization::my_permissions_handler;
|
pub use authorization::my_permissions_handler;
|
||||||
|
pub use platform::{get_tenant_enabled_apps_handler, set_tenant_enabled_apps_handler};
|
||||||
pub use role::{create_role_handler, list_roles_handler};
|
pub use role::{create_role_handler, list_roles_handler};
|
||||||
pub use tenant::{
|
pub use tenant::{
|
||||||
create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler,
|
create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler,
|
||||||
update_tenant_status_handler,
|
update_tenant_status_handler,
|
||||||
};
|
};
|
||||||
pub use user::{
|
pub use user::{
|
||||||
delete_user_handler, get_user_handler, list_users_handler, update_user_handler,
|
delete_user_handler, get_user_handler, list_user_roles_handler, list_users_handler,
|
||||||
|
set_user_roles_handler, update_user_handler,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 状态对象,包含 Service
|
// 状态对象,包含 Service
|
||||||
|
|||||||
92
src/handlers/platform.rs
Normal file
92
src/handlers/platform.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
use crate::handlers::AppState;
|
||||||
|
use crate::middleware::auth::AuthContext;
|
||||||
|
use crate::models::{TenantEnabledAppsResponse, UpdateTenantEnabledAppsRequest};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
};
|
||||||
|
use common_telemetry::{AppError, AppResponse};
|
||||||
|
use tracing::instrument;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/platform/tenants/{tenant_id}/enabled-apps",
|
||||||
|
tag = "Tenant",
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "获取租户已开通应用列表", body = TenantEnabledAppsResponse),
|
||||||
|
(status = 401, description = "未认证"),
|
||||||
|
(status = 403, description = "无权限"),
|
||||||
|
(status = 404, description = "未找到")
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||||
|
("tenant_id" = String, Path, description = "租户 UUID")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[instrument(skip(state))]
|
||||||
|
pub async fn get_tenant_enabled_apps_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthContext { user_id, .. }: AuthContext,
|
||||||
|
Path(tenant_id): Path<Uuid>,
|
||||||
|
) -> Result<AppResponse<TenantEnabledAppsResponse>, AppError> {
|
||||||
|
state
|
||||||
|
.authorization_service
|
||||||
|
.require_platform_permission(user_id, "iam:tenant:enabled_apps:read")
|
||||||
|
.await?;
|
||||||
|
let (enabled_apps, version, updated_at) = state.tenant_service.get_enabled_apps(tenant_id).await?;
|
||||||
|
Ok(AppResponse::ok(TenantEnabledAppsResponse {
|
||||||
|
tenant_id,
|
||||||
|
enabled_apps,
|
||||||
|
version,
|
||||||
|
updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/platform/tenants/{tenant_id}/enabled-apps",
|
||||||
|
tag = "Tenant",
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
),
|
||||||
|
request_body = UpdateTenantEnabledAppsRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "更新租户已开通应用列表", body = TenantEnabledAppsResponse),
|
||||||
|
(status = 400, description = "请求参数错误"),
|
||||||
|
(status = 401, description = "未认证"),
|
||||||
|
(status = 403, description = "无权限"),
|
||||||
|
(status = 404, description = "未找到"),
|
||||||
|
(status = 409, description = "版本冲突")
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||||
|
("tenant_id" = String, Path, description = "租户 UUID")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[instrument(skip(state, payload))]
|
||||||
|
pub async fn set_tenant_enabled_apps_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthContext { user_id, .. }: AuthContext,
|
||||||
|
Path(tenant_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateTenantEnabledAppsRequest>,
|
||||||
|
) -> Result<AppResponse<TenantEnabledAppsResponse>, AppError> {
|
||||||
|
state
|
||||||
|
.authorization_service
|
||||||
|
.require_platform_permission(user_id, "iam:tenant:enabled_apps:write")
|
||||||
|
.await?;
|
||||||
|
let (enabled_apps, version, updated_at) = state
|
||||||
|
.tenant_service
|
||||||
|
.set_enabled_apps(tenant_id, payload.enabled_apps, payload.expected_version, user_id)
|
||||||
|
.await?;
|
||||||
|
Ok(AppResponse::ok(TenantEnabledAppsResponse {
|
||||||
|
tenant_id,
|
||||||
|
enabled_apps,
|
||||||
|
version,
|
||||||
|
updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::handlers::AppState;
|
use crate::handlers::AppState;
|
||||||
use crate::middleware::TenantId;
|
use crate::middleware::TenantId;
|
||||||
use crate::middleware::auth::AuthContext;
|
use crate::middleware::auth::AuthContext;
|
||||||
use crate::models::{UpdateUserRequest, UserResponse};
|
use crate::models::{RoleResponse, UpdateUserRequest, UpdateUserRolesRequest, UserResponse};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -291,3 +291,133 @@ pub async fn delete_user_handler(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(AppResponse::ok_empty())
|
Ok(AppResponse::ok_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/users/{id}/roles",
|
||||||
|
tag = "User",
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "用户角色列表", body = [RoleResponse]),
|
||||||
|
(status = 401, description = "未认证"),
|
||||||
|
(status = 403, description = "无权限"),
|
||||||
|
(status = 404, description = "未找到")
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||||
|
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"),
|
||||||
|
("id" = String, Path, description = "用户 UUID")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[instrument(skip(state))]
|
||||||
|
/// 查询用户已绑定的角色列表。
|
||||||
|
///
|
||||||
|
/// 业务规则:
|
||||||
|
/// - 仅允许在当前租户内查询;租户不一致返回 403。
|
||||||
|
/// - 需要具备 `user:read` 权限。
|
||||||
|
///
|
||||||
|
/// 输出:
|
||||||
|
/// - `200`:角色列表(角色名称与描述)
|
||||||
|
pub async fn list_user_roles_handler(
|
||||||
|
TenantId(tenant_id): TenantId,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthContext {
|
||||||
|
tenant_id: auth_tenant_id,
|
||||||
|
user_id: actor_user_id,
|
||||||
|
..
|
||||||
|
}: AuthContext,
|
||||||
|
Path(target_user_id): Path<Uuid>,
|
||||||
|
) -> Result<AppResponse<Vec<RoleResponse>>, AppError> {
|
||||||
|
if auth_tenant_id != tenant_id {
|
||||||
|
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.authorization_service
|
||||||
|
.require_permission(tenant_id, actor_user_id, "user:read")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.user_service
|
||||||
|
.get_user_by_id(tenant_id, target_user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let roles = state
|
||||||
|
.role_service
|
||||||
|
.list_roles_for_user(tenant_id, target_user_id)
|
||||||
|
.await?;
|
||||||
|
let response = roles
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| RoleResponse {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(AppResponse::ok(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/users/{id}/roles",
|
||||||
|
tag = "User",
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
),
|
||||||
|
request_body = UpdateUserRolesRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "用户角色更新成功", body = [RoleResponse]),
|
||||||
|
(status = 400, description = "请求参数错误"),
|
||||||
|
(status = 401, description = "未认证"),
|
||||||
|
(status = 403, description = "无权限"),
|
||||||
|
(status = 404, description = "未找到")
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||||
|
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"),
|
||||||
|
("id" = String, Path, description = "用户 UUID")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[instrument(skip(state, payload))]
|
||||||
|
/// 设置用户的角色绑定(全量覆盖,幂等)。
|
||||||
|
///
|
||||||
|
/// 业务规则:
|
||||||
|
/// - 仅允许在当前租户内操作;租户不一致返回 403。
|
||||||
|
/// - 需要具备 `user:write` 权限。
|
||||||
|
/// - `role_ids` 必须全部属于当前租户,否则返回 400。
|
||||||
|
/// - 该接口为“全量覆盖”:会先清空用户在当前租户下的角色绑定,再按 `role_ids` 重新写入。
|
||||||
|
pub async fn set_user_roles_handler(
|
||||||
|
TenantId(tenant_id): TenantId,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthContext {
|
||||||
|
tenant_id: auth_tenant_id,
|
||||||
|
user_id: actor_user_id,
|
||||||
|
..
|
||||||
|
}: AuthContext,
|
||||||
|
Path(target_user_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateUserRolesRequest>,
|
||||||
|
) -> Result<AppResponse<Vec<RoleResponse>>, AppError> {
|
||||||
|
if auth_tenant_id != tenant_id {
|
||||||
|
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.authorization_service
|
||||||
|
.require_permission(tenant_id, actor_user_id, "user:write")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let roles = state
|
||||||
|
.role_service
|
||||||
|
.set_roles_for_user(tenant_id, target_user_id, payload.role_ids)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response = roles
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| RoleResponse {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(AppResponse::ok(response))
|
||||||
|
}
|
||||||
|
|||||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod docs;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod models;
|
||||||
|
pub mod services;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
23
src/main.rs
23
src/main.rs
@@ -16,9 +16,11 @@ use axum::{
|
|||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use handlers::{
|
use handlers::{
|
||||||
AppState, create_role_handler, create_tenant_handler, delete_tenant_handler,
|
AppState, create_role_handler, create_tenant_handler, delete_tenant_handler,
|
||||||
delete_user_handler, get_tenant_handler, get_user_handler, list_roles_handler,
|
delete_user_handler, get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler,
|
||||||
list_users_handler, login_handler, my_permissions_handler, register_handler,
|
list_roles_handler, list_user_roles_handler, list_users_handler, login_handler,
|
||||||
update_tenant_handler, update_tenant_status_handler, update_user_handler,
|
my_permissions_handler, register_handler, set_tenant_enabled_apps_handler,
|
||||||
|
set_user_roles_handler, update_tenant_handler, update_tenant_status_handler,
|
||||||
|
update_user_handler,
|
||||||
};
|
};
|
||||||
use services::{AuthService, AuthorizationService, RoleService, TenantService, UserService};
|
use services::{AuthService, AuthorizationService, RoleService, TenantService, UserService};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -110,6 +112,10 @@ async fn main() {
|
|||||||
.patch(update_user_handler)
|
.patch(update_user_handler)
|
||||||
.delete(delete_user_handler),
|
.delete(delete_user_handler),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/roles",
|
||||||
|
get(list_user_roles_handler).put(set_user_roles_handler),
|
||||||
|
)
|
||||||
.route("/roles", get(list_roles_handler).post(create_role_handler))
|
.route("/roles", get(list_roles_handler).post(create_role_handler))
|
||||||
.layer(from_fn(middleware::resolve_tenant))
|
.layer(from_fn(middleware::resolve_tenant))
|
||||||
.layer(from_fn(middleware::auth::authenticate))
|
.layer(from_fn(middleware::auth::authenticate))
|
||||||
@@ -117,9 +123,20 @@ async fn main() {
|
|||||||
common_telemetry::axum_middleware::trace_http_request,
|
common_telemetry::axum_middleware::trace_http_request,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let platform_api = Router::new()
|
||||||
|
.route(
|
||||||
|
"/platform/tenants/{tenant_id}/enabled-apps",
|
||||||
|
get(get_tenant_enabled_apps_handler).put(set_tenant_enabled_apps_handler),
|
||||||
|
)
|
||||||
|
.layer(from_fn(middleware::auth::authenticate))
|
||||||
|
.layer(from_fn(
|
||||||
|
common_telemetry::axum_middleware::trace_http_request,
|
||||||
|
));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
|
.route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
|
||||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||||
|
.merge(platform_api)
|
||||||
.merge(api)
|
.merge(api)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -192,3 +192,29 @@ pub struct TenantResponse {
|
|||||||
#[serde(default = "default_json_object")]
|
#[serde(default = "default_json_object")]
|
||||||
pub config: Value,
|
pub config: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
|
pub struct UpdateTenantEnabledAppsRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled_apps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expected_version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema, IntoParams)]
|
||||||
|
pub struct TenantEnabledAppsResponse {
|
||||||
|
#[serde(default = "default_uuid")]
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled_apps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
|
pub struct UpdateUserRolesRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub role_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User};
|
use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User};
|
||||||
|
use crate::utils::authz::filter_permissions_by_enabled_apps;
|
||||||
use crate::utils::{hash_password, sign, verify_password};
|
use crate::utils::{hash_password, sign, verify_password};
|
||||||
use common_telemetry::AppError;
|
use common_telemetry::AppError;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -143,8 +144,29 @@ impl AuthService {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec<String>, i32)>(
|
||||||
|
r#"
|
||||||
|
SELECT enabled_apps, version
|
||||||
|
FROM tenant_entitlements
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user.tenant_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| (vec![], 0));
|
||||||
|
|
||||||
|
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
|
||||||
|
|
||||||
// 3. 签发 Access Token
|
// 3. 签发 Access Token
|
||||||
let access_token = sign(user.id, user.tenant_id, roles, permissions)?;
|
let access_token = sign(
|
||||||
|
user.id,
|
||||||
|
user.tenant_id,
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
enabled_apps,
|
||||||
|
apps_version,
|
||||||
|
)?;
|
||||||
|
|
||||||
// 4. 生成 Refresh Token
|
// 4. 生成 Refresh Token
|
||||||
let mut refresh_bytes = [0u8; 32];
|
let mut refresh_bytes = [0u8; 32];
|
||||||
@@ -197,11 +219,14 @@ impl AuthService {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO role_permissions (role_id, permission_id)
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
SELECT $1, p.id FROM permissions p
|
SELECT $1, p.id
|
||||||
|
FROM permissions p
|
||||||
|
WHERE ($2::uuid = '00000000-0000-0000-0000-000000000001' OR p.code NOT LIKE 'iam:%')
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(role_id)
|
.bind(role_id)
|
||||||
|
.bind(tenant_id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::utils::authz::filter_permissions_by_enabled_apps;
|
||||||
use common_telemetry::AppError;
|
use common_telemetry::AppError;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
@@ -34,6 +35,13 @@ impl AuthorizationService {
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<String>, AppError> {
|
) -> Result<Vec<String>, AppError> {
|
||||||
|
let enabled_apps: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT enabled_apps FROM tenant_entitlements WHERE tenant_id = $1")
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let query = r#"
|
let query = r#"
|
||||||
SELECT DISTINCT p.code
|
SELECT DISTINCT p.code
|
||||||
FROM permissions p
|
FROM permissions p
|
||||||
@@ -47,7 +55,7 @@ impl AuthorizationService {
|
|||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows)
|
Ok(filter_permissions_by_enabled_apps(rows, &enabled_apps))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
@@ -76,4 +84,38 @@ impl AuthorizationService {
|
|||||||
Err(AppError::PermissionDenied(permission_code.to_string()))
|
Err(AppError::PermissionDenied(permission_code.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn list_platform_permissions_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Vec<String>, AppError> {
|
||||||
|
let query = r#"
|
||||||
|
SELECT DISTINCT p.code
|
||||||
|
FROM permissions p
|
||||||
|
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||||
|
JOIN user_roles ur ON ur.role_id = rp.role_id
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = $1 AND r.is_system = TRUE
|
||||||
|
"#;
|
||||||
|
let rows = sqlx::query_scalar::<_, String>(query)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn require_platform_permission(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
permission_code: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let permissions = self.list_platform_permissions_for_user(user_id).await?;
|
||||||
|
if permissions.iter().any(|p| p == permission_code) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::PermissionDenied(permission_code.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,123 @@ impl RoleService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::DbError(e))
|
.map_err(|e| AppError::DbError(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, role_ids))]
|
||||||
|
pub async fn list_roles_by_ids(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
role_ids: &[Uuid],
|
||||||
|
) -> Result<Vec<Role>, AppError> {
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let query = "SELECT id, tenant_id, name, description FROM roles WHERE tenant_id = $1 AND id = ANY($2)";
|
||||||
|
sqlx::query_as::<_, Role>(query)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(role_ids)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::DbError(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn list_roles_for_user(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
target_user_id: Uuid,
|
||||||
|
) -> Result<Vec<Role>, AppError> {
|
||||||
|
let query = r#"
|
||||||
|
SELECT r.id, r.tenant_id, r.name, r.description
|
||||||
|
FROM roles r
|
||||||
|
JOIN user_roles ur ON ur.role_id = r.id
|
||||||
|
WHERE r.tenant_id = $1 AND ur.user_id = $2
|
||||||
|
"#;
|
||||||
|
sqlx::query_as::<_, Role>(query)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(target_user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::DbError(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, role_ids))]
|
||||||
|
pub async fn set_roles_for_user(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
target_user_id: Uuid,
|
||||||
|
role_ids: Vec<Uuid>,
|
||||||
|
) -> Result<Vec<Role>, AppError> {
|
||||||
|
let unique: Vec<Uuid> = {
|
||||||
|
let mut s = std::collections::HashSet::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for id in role_ids {
|
||||||
|
if s.insert(id) {
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
let exists: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM users WHERE tenant_id = $1 AND id = $2",
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(target_user_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(AppError::NotFound("User not found".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let roles = if unique.is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
let found = sqlx::query_as::<_, Role>(
|
||||||
|
"SELECT id, tenant_id, name, description FROM roles WHERE tenant_id = $1 AND id = ANY($2)",
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(&unique)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::DbError)?;
|
||||||
|
|
||||||
|
if found.len() != unique.len() {
|
||||||
|
return Err(AppError::BadRequest("Invalid role_ids".into()));
|
||||||
|
}
|
||||||
|
found
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
DELETE FROM user_roles ur
|
||||||
|
USING roles r
|
||||||
|
WHERE ur.role_id = r.id
|
||||||
|
AND r.tenant_id = $1
|
||||||
|
AND ur.user_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(target_user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !unique.is_empty() {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO user_roles (user_id, role_id)
|
||||||
|
SELECT $1, UNNEST($2::uuid[])
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(target_user_id)
|
||||||
|
.bind(&unique)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(roles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
use crate::models::{
|
use crate::models::{CreateTenantRequest, Tenant, UpdateTenantRequest, UpdateTenantStatusRequest};
|
||||||
CreateTenantRequest, Tenant, UpdateTenantRequest, UpdateTenantStatusRequest,
|
|
||||||
};
|
|
||||||
use common_telemetry::AppError;
|
use common_telemetry::AppError;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct EnabledAppsCacheEntry {
|
||||||
|
enabled_apps: Vec<String>,
|
||||||
|
version: i32,
|
||||||
|
updated_at: String,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TenantService {
|
pub struct TenantService {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
|
enabled_apps_cache: Arc<RwLock<HashMap<Uuid, EnabledAppsCacheEntry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TenantService {
|
impl TenantService {
|
||||||
/// 创建租户服务实例。
|
/// 创建租户服务实例。
|
||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self {
|
||||||
|
pool,
|
||||||
|
enabled_apps_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, req))]
|
#[instrument(skip(self, req))]
|
||||||
@@ -31,17 +45,43 @@ impl TenantService {
|
|||||||
/// 异常:
|
/// 异常:
|
||||||
/// - 数据库写入失败(如连接异常、约束失败等)
|
/// - 数据库写入失败(如连接异常、约束失败等)
|
||||||
pub async fn create_tenant(&self, req: CreateTenantRequest) -> Result<Tenant, AppError> {
|
pub async fn create_tenant(&self, req: CreateTenantRequest) -> Result<Tenant, AppError> {
|
||||||
let config = req.config.unwrap_or_else(|| Value::Object(Default::default()));
|
let mut config = req
|
||||||
|
.config
|
||||||
|
.unwrap_or_else(|| Value::Object(Default::default()));
|
||||||
|
if !config.is_object() {
|
||||||
|
config = Value::Object(Default::default());
|
||||||
|
}
|
||||||
|
if let Some(obj) = config.as_object_mut() {
|
||||||
|
obj.insert("enabled_apps".to_string(), Value::Array(vec![]));
|
||||||
|
obj.insert(
|
||||||
|
"enabled_apps_version".to_string(),
|
||||||
|
Value::Number(serde_json::Number::from(0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
let query = r#"
|
let query = r#"
|
||||||
INSERT INTO tenants (name, status, config)
|
INSERT INTO tenants (name, status, config)
|
||||||
VALUES ($1, 'active', $2)
|
VALUES ($1, 'active', $2)
|
||||||
RETURNING id, name, status, config
|
RETURNING id, name, status, config
|
||||||
"#;
|
"#;
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
let tenant = sqlx::query_as::<_, Tenant>(query)
|
let tenant = sqlx::query_as::<_, Tenant>(query)
|
||||||
.bind(req.name)
|
.bind(req.name)
|
||||||
.bind(config)
|
.bind(config)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
|
||||||
|
VALUES ($1, '{}'::text[], 0)
|
||||||
|
ON CONFLICT (tenant_id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
Ok(tenant)
|
Ok(tenant)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,4 +171,228 @@ impl TenantService {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn get_enabled_apps(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> Result<(Vec<String>, i32, String), AppError> {
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Some(hit) = self
|
||||||
|
.enabled_apps_cache
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(&tenant_id)
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
if hit.expires_at > now {
|
||||||
|
return Ok((hit.enabled_apps, hit.version, hit.updated_at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, (Vec<String>, i32, chrono::DateTime<chrono::Utc>)>(
|
||||||
|
r#"
|
||||||
|
SELECT enabled_apps, version, updated_at
|
||||||
|
FROM tenant_entitlements
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (enabled_apps, version, updated_at) = match row {
|
||||||
|
Some((apps, v, ts)) => (apps, v, ts.to_rfc3339()),
|
||||||
|
None => {
|
||||||
|
let exists: Option<Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM tenants WHERE id = $1")
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(AppError::NotFound("Tenant not found".into()));
|
||||||
|
}
|
||||||
|
let ts = chrono::Utc::now().to_rfc3339();
|
||||||
|
(vec![], 0, ts)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ttl = Duration::from_secs(60);
|
||||||
|
self.enabled_apps_cache.write().await.insert(
|
||||||
|
tenant_id,
|
||||||
|
EnabledAppsCacheEntry {
|
||||||
|
enabled_apps: enabled_apps.clone(),
|
||||||
|
version,
|
||||||
|
updated_at: updated_at.clone(),
|
||||||
|
expires_at: now + ttl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok((enabled_apps, version, updated_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, enabled_apps))]
|
||||||
|
pub async fn set_enabled_apps(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
enabled_apps: Vec<String>,
|
||||||
|
expected_version: Option<i32>,
|
||||||
|
actor_user_id: Uuid,
|
||||||
|
) -> Result<(Vec<String>, i32, String), AppError> {
|
||||||
|
let normalized = normalize_apps(enabled_apps);
|
||||||
|
self.validate_apps_exist(&normalized).await?;
|
||||||
|
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
let current = sqlx::query_as::<_, (Vec<String>, i32)>(
|
||||||
|
r#"
|
||||||
|
SELECT enabled_apps, version
|
||||||
|
FROM tenant_entitlements
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if current.is_none() {
|
||||||
|
let exists: Option<Uuid> = sqlx::query_scalar("SELECT id FROM tenants WHERE id = $1")
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(AppError::NotFound("Tenant not found".into()));
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
|
||||||
|
VALUES ($1, '{}'::text[], 0)
|
||||||
|
ON CONFLICT (tenant_id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (before_apps, before_version) = current.unwrap_or_else(|| (vec![], 0));
|
||||||
|
if let Some(ev) = expected_version {
|
||||||
|
if ev != before_version {
|
||||||
|
return Err(AppError::AlreadyExists(
|
||||||
|
"enabled_apps:version_conflict".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (new_version, updated_at): (i32, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
UPDATE tenant_entitlements
|
||||||
|
SET enabled_apps = $1,
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = $2
|
||||||
|
RETURNING version, updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&normalized)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE tenants
|
||||||
|
SET config =
|
||||||
|
jsonb_set(
|
||||||
|
jsonb_set(COALESCE(config, '{}'::jsonb), '{enabled_apps}', to_jsonb($1::text[]), true),
|
||||||
|
'{enabled_apps_version}', to_jsonb($2::int), true
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&normalized)
|
||||||
|
.bind(new_version)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tenant_enabled_apps_history (tenant_id, version, enabled_apps, actor_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(new_version)
|
||||||
|
.bind(&normalized)
|
||||||
|
.bind(actor_user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
|
||||||
|
VALUES ($1, $2, 'tenant.enabled_apps.update', 'tenant', 'allow', $3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(actor_user_id)
|
||||||
|
.bind(serde_json::json!({
|
||||||
|
"before": { "enabled_apps": before_apps, "version": before_version },
|
||||||
|
"after": { "enabled_apps": normalized, "version": new_version }
|
||||||
|
}))
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let ttl = Duration::from_secs(60);
|
||||||
|
let now = Instant::now();
|
||||||
|
let updated_at = updated_at.to_rfc3339();
|
||||||
|
self.enabled_apps_cache.write().await.insert(
|
||||||
|
tenant_id,
|
||||||
|
EnabledAppsCacheEntry {
|
||||||
|
enabled_apps: normalized.clone(),
|
||||||
|
version: new_version,
|
||||||
|
updated_at: updated_at.clone(),
|
||||||
|
expires_at: now + ttl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((normalized, new_version, updated_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_apps_exist(&self, enabled_apps: &[String]) -> Result<(), AppError> {
|
||||||
|
if enabled_apps.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let rows: Vec<String> = sqlx::query_scalar("SELECT id FROM apps WHERE id = ANY($1)")
|
||||||
|
.bind(enabled_apps)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let found: HashSet<String> = rows.into_iter().collect();
|
||||||
|
for app in enabled_apps {
|
||||||
|
if !found.contains(app) {
|
||||||
|
return Err(AppError::BadRequest(format!("Unknown app: {app}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_apps(enabled_apps: Vec<String>) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for a in enabled_apps {
|
||||||
|
let v = a.trim().to_lowercase();
|
||||||
|
if v.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if seen.insert(v.clone()) {
|
||||||
|
out.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/utils/authz.rs
Normal file
22
src/utils/authz.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub fn permission_prefix(permission_code: &str) -> Option<&str> {
|
||||||
|
permission_code.split_once(':').map(|(p, _)| p)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filter_permissions_by_enabled_apps(
|
||||||
|
permissions: Vec<String>,
|
||||||
|
enabled_apps: &[String],
|
||||||
|
) -> Vec<String> {
|
||||||
|
let enabled: HashSet<&str> = enabled_apps.iter().map(|s| s.as_str()).collect();
|
||||||
|
permissions
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| {
|
||||||
|
let Some(prefix) = permission_prefix(p) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
matches!(prefix, "user" | "role" | "tenant" | "iam") || enabled.contains(prefix)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -16,6 +16,10 @@ pub struct Claims {
|
|||||||
pub roles: Vec<String>,
|
pub roles: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permissions: Vec<String>,
|
pub permissions: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub apps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub apps_version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sign(
|
pub fn sign(
|
||||||
@@ -23,6 +27,8 @@ pub fn sign(
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
roles: Vec<String>,
|
roles: Vec<String>,
|
||||||
permissions: Vec<String>,
|
permissions: Vec<String>,
|
||||||
|
apps: Vec<String>,
|
||||||
|
apps_version: i32,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -39,6 +45,8 @@ pub fn sign(
|
|||||||
iss: "iam-service".to_string(),
|
iss: "iam-service".to_string(),
|
||||||
roles,
|
roles,
|
||||||
permissions,
|
permissions,
|
||||||
|
apps,
|
||||||
|
apps_version,
|
||||||
};
|
};
|
||||||
|
|
||||||
let keys = get_keys();
|
let keys = get_keys();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod keys;
|
pub mod keys;
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
pub mod authz;
|
||||||
|
|
||||||
pub use password::{hash_password, verify_password};
|
pub use password::{hash_password, verify_password};
|
||||||
pub use jwt::{sign, verify};
|
pub use jwt::{sign, verify};
|
||||||
|
|||||||
79
tests/enabled_apps_smoke.rs
Normal file
79
tests/enabled_apps_smoke.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use iam_service::services::TenantService;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tenant_enabled_apps_roundtrip_and_version_conflict()
|
||||||
|
-> 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 tenant_service = TenantService::new(pool.clone());
|
||||||
|
|
||||||
|
let test_app = format!("testapp{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||||
|
let tenant_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tenants (name, status, config)
|
||||||
|
VALUES ($1, 'active', '{}'::jsonb)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(format!("smoke-{}", Uuid::new_v4()))
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let actor_user_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
async fn cleanup(pool: &PgPool, tenant_id: Uuid, test_app: &str) {
|
||||||
|
let _ = sqlx::query("DELETE FROM tenants WHERE id = $1")
|
||||||
|
.bind(tenant_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
let _ = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||||
|
.bind(test_app)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO apps (id, name, description)
|
||||||
|
VALUES ($1, 'TestApp', 'Test')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&test_app)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
|
||||||
|
VALUES ($1, '{}'::text[], 0)
|
||||||
|
ON CONFLICT (tenant_id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let r1 = tenant_service
|
||||||
|
.set_enabled_apps(tenant_id, vec![test_app.clone()], Some(0), actor_user_id)
|
||||||
|
.await;
|
||||||
|
if r1.is_err() {
|
||||||
|
cleanup(&pool, tenant_id, &test_app).await;
|
||||||
|
}
|
||||||
|
let (apps, v1, _) = r1?;
|
||||||
|
assert_eq!(apps, vec![test_app.clone()]);
|
||||||
|
assert_eq!(v1, 1);
|
||||||
|
|
||||||
|
let r2 = tenant_service
|
||||||
|
.set_enabled_apps(tenant_id, vec![test_app.clone()], Some(0), actor_user_id)
|
||||||
|
.await;
|
||||||
|
assert!(r2.is_err());
|
||||||
|
|
||||||
|
cleanup(&pool, tenant_id, &test_app).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
100
tests/user_roles_smoke.rs
Normal file
100
tests/user_roles_smoke.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use iam_service::models::CreateRoleRequest;
|
||||||
|
use iam_service::services::{RoleService, TenantService};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_user_roles_is_idempotent_and_validates_tenant_roles(
|
||||||
|
) -> 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 tenant_service = TenantService::new(pool.clone());
|
||||||
|
let role_service = RoleService::new(pool.clone());
|
||||||
|
|
||||||
|
let tenant = tenant_service
|
||||||
|
.create_tenant(iam_service::models::CreateTenantRequest {
|
||||||
|
name: format!("smoke-{}", Uuid::new_v4()),
|
||||||
|
config: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (tenant_id, email, password_hash)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tenant.id)
|
||||||
|
.bind(format!("smoke-{}@example.com", Uuid::new_v4()))
|
||||||
|
.bind("hash")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let role1 = role_service
|
||||||
|
.create_role(
|
||||||
|
tenant.id,
|
||||||
|
CreateRoleRequest {
|
||||||
|
name: "R1".into(),
|
||||||
|
description: Some("role1".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let role2 = role_service
|
||||||
|
.create_role(
|
||||||
|
tenant.id,
|
||||||
|
CreateRoleRequest {
|
||||||
|
name: "R2".into(),
|
||||||
|
description: Some("role2".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let r1 = role_service
|
||||||
|
.set_roles_for_user(tenant.id, user_id, vec![role1.id, role2.id])
|
||||||
|
.await?;
|
||||||
|
assert_eq!(r1.len(), 2);
|
||||||
|
|
||||||
|
let r2 = role_service
|
||||||
|
.set_roles_for_user(tenant.id, user_id, vec![role1.id, role2.id])
|
||||||
|
.await?;
|
||||||
|
assert_eq!(r2.len(), 2);
|
||||||
|
|
||||||
|
let roles = role_service.list_roles_for_user(tenant.id, user_id).await?;
|
||||||
|
assert_eq!(roles.len(), 2);
|
||||||
|
|
||||||
|
let other_tenant = tenant_service
|
||||||
|
.create_tenant(iam_service::models::CreateTenantRequest {
|
||||||
|
name: format!("smoke-{}", Uuid::new_v4()),
|
||||||
|
config: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let other_role = role_service
|
||||||
|
.create_role(
|
||||||
|
other_tenant.id,
|
||||||
|
CreateRoleRequest {
|
||||||
|
name: "Other".into(),
|
||||||
|
description: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let bad = role_service
|
||||||
|
.set_roles_for_user(tenant.id, user_id, vec![other_role.id])
|
||||||
|
.await;
|
||||||
|
assert!(bad.is_err());
|
||||||
|
|
||||||
|
let _ = sqlx::query("DELETE FROM tenants WHERE id = $1")
|
||||||
|
.bind(tenant.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
let _ = sqlx::query("DELETE FROM tenants WHERE id = $1")
|
||||||
|
.bind(other_tenant.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user