diff --git a/README.md b/README.md index f1c0015..47955e1 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,8 @@ flowchart LR - [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/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/rebuild_iam_db.sh](file:///home/shay/project/backend/iam-service/scripts/db/rebuild_iam_db.sh):一键重建 schema(可选备份+DROP+重建+校验) -- [docs/DB_PROVISIONING.md](file:///home/shay/project/backend/iam-service/docs/DB_PROVISIONING.md):数据库/用户创建与 schema 初始化说明(归档) +- [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):开发用一键重建(会清库重建+迁移+校验,不适合生产) ## 快速开始 diff --git a/docs/DB_PROVISIONING.md b/docs/DB_PROVISIONING.md deleted file mode 100644 index 398df63..0000000 --- a/docs/DB_PROVISIONING.md +++ /dev/null @@ -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 -``` diff --git a/docs/SCALAR_GUIDE.md b/docs/SCALAR_GUIDE.md index 69d0969..62b5102 100644 --- a/docs/SCALAR_GUIDE.md +++ b/docs/SCALAR_GUIDE.md @@ -1,4 +1,4 @@ -# IAM Service — Scalar 调用顺序指南 +# IAM Service — Scalar 调用顺序指南(v0.1.0) ## Authentication(认证方式) @@ -11,6 +11,11 @@ - 保护接口默认从 Token claim 的 `tenant_id` 推导租户 - 可选兼容 `X-Tenant-ID: `,若同时提供 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 -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:创建租户(可选) **POST** `/tenants/register` @@ -57,6 +78,23 @@ 下一步依赖:`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:注册用户 **POST** `/auth/register` @@ -120,6 +158,34 @@ 下一步依赖:确认具备目标权限(例如 `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 `(平台租户下登录得到的 token) + +#### 4.1.2 设置某租户 enabled_apps(全量覆盖,幂等) + +**PUT** `/platform/tenants/{tenant_id}/enabled-apps` + +- Header:`Authorization: Bearer ` +- Body: + +```json +{ "enabled_apps": ["cms", "tms"], "expected_version": 0 } +``` + +说明: +- `expected_version` 可选,用于并发控制;不匹配会返回 409。 +- 登录签发 token 时会自动把 `apps/apps_version` 注入到 JWT,并对 `permissions` 按 enabled_apps 过滤。 + ### Step 5:列出用户(User) **GET** `/users?page=1&page_size=20` @@ -147,9 +213,32 @@ { "code": 0, "message": "Success", "data": [{ "id": "", "name": "Admin", "description": "..." }], "trace_id": null } ``` +### Step 7:用户-角色绑定(User) + +用户注册后默认无角色;通常由具备 `user:write` 的管理员进行角色分配。 + +#### 7.1 查询用户角色列表(需要 user:read) + +**GET** `/users/{id}/roles` + +- Header:`Authorization: Bearer ` + +#### 7.2 设置用户角色(全量覆盖,幂等;需要 user:write) + +**PUT** `/users/{id}/roles` + +- Header:`Authorization: Bearer ` +- Body: + +```json +{ "role_ids": ["", ""] } +``` + +说明: +- `role_ids` 必须全部属于当前租户,否则返回 400。 + ## 限流说明(Auth) - `/auth/login`:约 2 req/s,burst 10(同一 IP) - `/auth/register`:约 1 req/s,burst 5(同一 IP) - 触发后返回:HTTP 429 + `code=40000` - diff --git a/docs/TENANT_API.md b/docs/TENANT_API.md index dc00788..266feff 100644 --- a/docs/TENANT_API.md +++ b/docs/TENANT_API.md @@ -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`。 diff --git a/scripts/db/README.md b/scripts/db/README.md new file mode 100644 index 0000000..aee43a1 --- /dev/null +++ b/scripts/db/README.md @@ -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 +``` diff --git a/scripts/db/migrate.sh b/scripts/db/migrate.sh new file mode 100644 index 0000000..0b96589 --- /dev/null +++ b/scripts/db/migrate.sh @@ -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" + diff --git a/sql/schema_post_init.sql b/scripts/db/migrations/0001_core.sql similarity index 73% rename from sql/schema_post_init.sql rename to scripts/db/migrations/0001_core.sql index 40f18c5..fad276c 100644 --- a/sql/schema_post_init.sql +++ b/scripts/db/migrations/0001_core.sql @@ -1,6 +1,8 @@ +BEGIN; + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE TABLE tenants ( +CREATE TABLE IF NOT EXISTS tenants ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(255) NOT NULL, status VARCHAR(50) DEFAULT 'active', @@ -9,7 +11,7 @@ CREATE TABLE tenants ( 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(), tenant_id UUID NOT NULL REFERENCES tenants(id), email VARCHAR(255) NOT NULL, @@ -25,9 +27,9 @@ CREATE TABLE users ( 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(), tenant_id UUID NOT NULL REFERENCES tenants(id), name VARCHAR(50) NOT NULL, @@ -38,7 +40,7 @@ CREATE TABLE roles ( UNIQUE(tenant_id, name) ); -CREATE TABLE permissions ( +CREATE TABLE IF NOT EXISTS permissions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), code VARCHAR(100) NOT NULL UNIQUE, description TEXT, @@ -47,21 +49,21 @@ CREATE TABLE permissions ( 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, permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 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, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 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(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash VARCHAR(255) NOT NULL, @@ -71,10 +73,10 @@ CREATE TABLE refresh_tokens ( replaced_by_token_hash VARCHAR(255) ); -CREATE INDEX 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_user_id ON refresh_tokens(user_id); +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(), tenant_id UUID REFERENCES tenants(id), user_id UUID, @@ -87,7 +89,9 @@ CREATE TABLE audit_logs ( 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 ('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:write', 'Manage roles', 'role', 'write'), ('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 -('11111111-1111-1111-1111-111111111111', 'Admin', 'Administrator with full access', TRUE); +INSERT INTO roles (tenant_id, name, description, is_system) +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) -SELECT r.id, p.id FROM roles r, permissions p -WHERE r.name = 'Admin' AND r.tenant_id = '11111111-1111-1111-1111-111111111111'; +SELECT r.id, p.id +FROM roles r, permissions p +WHERE r.name = 'Admin' + AND r.tenant_id = '11111111-1111-1111-1111-111111111111' +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/scripts/db/migrations/0002_enabled_apps.sql b/scripts/db/migrations/0002_enabled_apps.sql new file mode 100644 index 0000000..ddc19a2 --- /dev/null +++ b/scripts/db/migrations/0002_enabled_apps.sql @@ -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; + diff --git a/scripts/db/rebuild_iam_db.sh b/scripts/db/rebuild_iam_db.sh index 8f76be8..08dc936 100755 --- a/scripts/db/rebuild_iam_db.sh +++ b/scripts/db/rebuild_iam_db.sh @@ -82,8 +82,8 @@ if [[ "${BACKUP}" == "1" ]]; then echo "Backup written: ${backup_file}" fi -psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/drop_iam_schema.sql" -psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/schema_post_init.sql" -psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/verify_iam_schema.sql" +CONFIRM=1 "${ROOT_DIR}/scripts/db/reset.sh" +"${ROOT_DIR}/scripts/db/migrate.sh" +"${ROOT_DIR}/scripts/db/verify.sh" echo "Rebuild completed" diff --git a/scripts/db/reset.sh b/scripts/db/reset.sh new file mode 100644 index 0000000..cd5bda8 --- /dev/null +++ b/scripts/db/reset.sh @@ -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" diff --git a/scripts/db/rollback.sh b/scripts/db/rollback.sh new file mode 100644 index 0000000..bdeeb7a --- /dev/null +++ b/scripts/db/rollback.sh @@ -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" + diff --git a/sql/drop_iam_schema.sql b/scripts/db/rollback/0001.down.sql similarity index 99% rename from sql/drop_iam_schema.sql rename to scripts/db/rollback/0001.down.sql index aa47b51..704348a 100644 --- a/sql/drop_iam_schema.sql +++ b/scripts/db/rollback/0001.down.sql @@ -1,4 +1,5 @@ BEGIN; + DROP TABLE IF EXISTS role_permissions; DROP TABLE IF EXISTS user_roles; DROP TABLE IF EXISTS refresh_tokens; @@ -7,5 +8,6 @@ DROP TABLE IF EXISTS roles; DROP TABLE IF EXISTS permissions; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS tenants; + COMMIT; diff --git a/scripts/db/rollback/0002.down.sql b/scripts/db/rollback/0002.down.sql new file mode 100644 index 0000000..6500569 --- /dev/null +++ b/scripts/db/rollback/0002.down.sql @@ -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; + diff --git a/scripts/db/verify.sh b/scripts/db/verify.sh new file mode 100644 index 0000000..918e265 --- /dev/null +++ b/scripts/db/verify.sh @@ -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" diff --git a/sql/verify_iam_schema.sql b/scripts/db/verify/0001_core.sql similarity index 100% rename from sql/verify_iam_schema.sql rename to scripts/db/verify/0001_core.sql diff --git a/scripts/db/verify/0002_enabled_apps.sql b/scripts/db/verify/0002_enabled_apps.sql new file mode 100644 index 0000000..1315972 --- /dev/null +++ b/scripts/db/verify/0002_enabled_apps.sql @@ -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 $$; + diff --git a/src/docs.rs b/src/docs.rs index 1598f28..0d0e8a6 100644 --- a/src/docs.rs +++ b/src/docs.rs @@ -1,8 +1,9 @@ use crate::handlers; use crate::models::{ CreateRoleRequest, CreateTenantRequest, CreateUserRequest, LoginRequest, LoginResponse, Role, - RoleResponse, Tenant, TenantResponse, UpdateTenantRequest, UpdateTenantStatusRequest, - UpdateUserRequest, User, UserResponse, + RoleResponse, Tenant, TenantEnabledAppsResponse, TenantResponse, + UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest, + UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse, }; use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa::{Modify, OpenApi}; @@ -34,10 +35,22 @@ impl Modify for SecurityAddon { version = "0.1.0", 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( handlers::auth::register_handler, handlers::auth::login_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::get_tenant_handler, handlers::tenant::update_tenant_handler, @@ -49,6 +62,8 @@ impl Modify for SecurityAddon { handlers::user::get_user_handler, handlers::user::update_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 ), components( @@ -66,7 +81,10 @@ impl Modify for SecurityAddon { TenantResponse, CreateTenantRequest, UpdateTenantRequest, - UpdateTenantStatusRequest + UpdateTenantStatusRequest, + UpdateTenantEnabledAppsRequest, + TenantEnabledAppsResponse, + UpdateUserRolesRequest ) ), tags( diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 8320205..3b09d32 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,6 @@ -pub mod authorization; pub mod auth; +pub mod authorization; +pub mod platform; pub mod role; pub mod tenant; pub mod user; @@ -8,13 +9,15 @@ use crate::services::{AuthService, AuthorizationService, RoleService, TenantServ 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}; pub use role::{create_role_handler, list_roles_handler}; pub use tenant::{ create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler, update_tenant_status_handler, }; 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 diff --git a/src/handlers/platform.rs b/src/handlers/platform.rs new file mode 100644 index 0000000..39e93d5 --- /dev/null +++ b/src/handlers/platform.rs @@ -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 (访问令牌)"), + ("tenant_id" = String, Path, description = "租户 UUID") + ) +)] +#[instrument(skip(state))] +pub async fn get_tenant_enabled_apps_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(tenant_id): Path, +) -> Result, 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 (访问令牌)"), + ("tenant_id" = String, Path, description = "租户 UUID") + ) +)] +#[instrument(skip(state, payload))] +pub async fn set_tenant_enabled_apps_handler( + State(state): State, + AuthContext { user_id, .. }: AuthContext, + Path(tenant_id): Path, + Json(payload): Json, +) -> Result, 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, + })) +} + diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 606b4c4..7fcb1ba 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -1,7 +1,7 @@ use crate::handlers::AppState; use crate::middleware::TenantId; use crate::middleware::auth::AuthContext; -use crate::models::{UpdateUserRequest, UserResponse}; +use crate::models::{RoleResponse, UpdateUserRequest, UpdateUserRolesRequest, UserResponse}; use axum::{ Json, extract::{Path, Query, State}, @@ -291,3 +291,133 @@ pub async fn delete_user_handler( .await?; Ok(AppResponse::ok_empty()) } + +#[utoipa::path( + get, + path = "/users/{id}/roles", + tag = "User", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "用户角色列表", body = [RoleResponse]), + (status = 401, description = "未认证"), + (status = 403, description = "无权限"), + (status = 404, description = "未找到") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id: actor_user_id, + .. + }: AuthContext, + Path(target_user_id): Path, +) -> 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: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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id: actor_user_id, + .. + }: AuthContext, + 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: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)) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..93327b2 --- /dev/null +++ b/src/lib.rs @@ -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; + diff --git a/src/main.rs b/src/main.rs index 4a2d0ac..cff6984 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,9 +16,11 @@ use axum::{ use config::AppConfig; use handlers::{ AppState, create_role_handler, create_tenant_handler, delete_tenant_handler, - delete_user_handler, get_tenant_handler, get_user_handler, list_roles_handler, - list_users_handler, login_handler, my_permissions_handler, register_handler, - update_tenant_handler, update_tenant_status_handler, update_user_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, }; use services::{AuthService, AuthorizationService, RoleService, TenantService, UserService}; use std::net::SocketAddr; @@ -110,6 +112,10 @@ async fn main() { .patch(update_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)) .layer(from_fn(middleware::resolve_tenant)) .layer(from_fn(middleware::auth::authenticate)) @@ -117,9 +123,20 @@ async fn main() { 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() .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT })) .merge(Scalar::with_url("/scalar", ApiDoc::openapi())) + .merge(platform_api) .merge(api) .with_state(state); diff --git a/src/models.rs b/src/models.rs index d7dc927..16bc3c8 100644 --- a/src/models.rs +++ b/src/models.rs @@ -192,3 +192,29 @@ pub struct TenantResponse { #[serde(default = "default_json_object")] pub config: Value, } + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct UpdateTenantEnabledAppsRequest { + #[serde(default)] + pub enabled_apps: Vec, + #[serde(default)] + pub expected_version: Option, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +pub struct TenantEnabledAppsResponse { + #[serde(default = "default_uuid")] + pub tenant_id: Uuid, + #[serde(default)] + pub enabled_apps: Vec, + #[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, +} diff --git a/src/services/auth.rs b/src/services/auth.rs index 5dd2a9b..82746d3 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -1,4 +1,5 @@ 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 common_telemetry::AppError; use rand::RngCore; @@ -143,8 +144,29 @@ impl AuthService { .fetch_all(&self.pool) .await?; + let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec, 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 - 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 let mut refresh_bytes = [0u8; 32]; @@ -197,11 +219,14 @@ impl AuthService { sqlx::query( r#" 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 "#, ) .bind(role_id) + .bind(tenant_id) .execute(&mut **tx) .await?; diff --git a/src/services/authorization.rs b/src/services/authorization.rs index dbceae6..ab257c3 100644 --- a/src/services/authorization.rs +++ b/src/services/authorization.rs @@ -1,3 +1,4 @@ +use crate::utils::authz::filter_permissions_by_enabled_apps; use common_telemetry::AppError; use sqlx::PgPool; use tracing::instrument; @@ -34,6 +35,13 @@ impl AuthorizationService { tenant_id: Uuid, user_id: Uuid, ) -> Result, AppError> { + let enabled_apps: Vec = + 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#" SELECT DISTINCT p.code FROM permissions p @@ -47,7 +55,7 @@ impl AuthorizationService { .bind(user_id) .fetch_all(&self.pool) .await?; - Ok(rows) + Ok(filter_permissions_by_enabled_apps(rows, &enabled_apps)) } #[instrument(skip(self))] @@ -76,4 +84,38 @@ impl AuthorizationService { Err(AppError::PermissionDenied(permission_code.to_string())) } } + + #[instrument(skip(self))] + pub async fn list_platform_permissions_for_user( + &self, + user_id: Uuid, + ) -> Result, 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())) + } + } } diff --git a/src/services/role.rs b/src/services/role.rs index 13407a4..0e13471 100644 --- a/src/services/role.rs +++ b/src/services/role.rs @@ -56,4 +56,123 @@ impl RoleService { .await .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, 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, 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, + ) -> Result, AppError> { + let unique: Vec = { + 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 = 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) + } } diff --git a/src/services/tenant.rs b/src/services/tenant.rs index 25968df..5077c2f 100644 --- a/src/services/tenant.rs +++ b/src/services/tenant.rs @@ -1,21 +1,35 @@ -use crate::models::{ - CreateTenantRequest, Tenant, UpdateTenantRequest, UpdateTenantStatusRequest, -}; +use crate::models::{CreateTenantRequest, Tenant, UpdateTenantRequest, UpdateTenantStatusRequest}; use common_telemetry::AppError; use serde_json::Value; 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 uuid::Uuid; +#[derive(Clone)] +struct EnabledAppsCacheEntry { + enabled_apps: Vec, + version: i32, + updated_at: String, + expires_at: Instant, +} + #[derive(Clone)] pub struct TenantService { pool: PgPool, + enabled_apps_cache: Arc>>, } impl TenantService { /// 创建租户服务实例。 pub fn new(pool: PgPool) -> Self { - Self { pool } + Self { + pool, + enabled_apps_cache: Arc::new(RwLock::new(HashMap::new())), + } } #[instrument(skip(self, req))] @@ -31,17 +45,43 @@ impl TenantService { /// 异常: /// - 数据库写入失败(如连接异常、约束失败等) pub async fn create_tenant(&self, req: CreateTenantRequest) -> Result { - 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#" INSERT INTO tenants (name, status, config) VALUES ($1, 'active', $2) RETURNING id, name, status, config "#; + let mut tx = self.pool.begin().await?; let tenant = sqlx::query_as::<_, Tenant>(query) .bind(req.name) .bind(config) - .fetch_one(&self.pool) + .fetch_one(&mut *tx) .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) } @@ -131,4 +171,228 @@ impl TenantService { } Ok(()) } + + #[instrument(skip(self))] + pub async fn get_enabled_apps( + &self, + tenant_id: Uuid, + ) -> Result<(Vec, 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, i32, chrono::DateTime)>( + 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 = + 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, + expected_version: Option, + actor_user_id: Uuid, + ) -> Result<(Vec, 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, 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 = 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) = 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 = sqlx::query_scalar("SELECT id FROM apps WHERE id = ANY($1)") + .bind(enabled_apps) + .fetch_all(&self.pool) + .await?; + let found: HashSet = 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) -> Vec { + 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 } diff --git a/src/utils/authz.rs b/src/utils/authz.rs new file mode 100644 index 0000000..ce60b9a --- /dev/null +++ b/src/utils/authz.rs @@ -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, + enabled_apps: &[String], +) -> Vec { + 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() +} + diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index e447a90..bd7f46a 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -16,6 +16,10 @@ pub struct Claims { pub roles: Vec, #[serde(default)] pub permissions: Vec, + #[serde(default)] + pub apps: Vec, + #[serde(default)] + pub apps_version: i32, } pub fn sign( @@ -23,6 +27,8 @@ pub fn sign( tenant_id: Uuid, roles: Vec, permissions: Vec, + apps: Vec, + apps_version: i32, ) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -39,6 +45,8 @@ pub fn sign( iss: "iam-service".to_string(), roles, permissions, + apps, + apps_version, }; let keys = get_keys(); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 8271a98..9a2f3dd 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,7 @@ pub mod keys; pub mod jwt; pub mod password; +pub mod authz; pub use password::{hash_password, verify_password}; pub use jwt::{sign, verify}; diff --git a/tests/enabled_apps_smoke.rs b/tests/enabled_apps_smoke.rs new file mode 100644 index 0000000..8905969 --- /dev/null +++ b/tests/enabled_apps_smoke.rs @@ -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> { + 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(()) +} diff --git a/tests/user_roles_smoke.rs b/tests/user_roles_smoke.rs new file mode 100644 index 0000000..0a291ff --- /dev/null +++ b/tests/user_roles_smoke.rs @@ -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> { + 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(()) +}