From b262e87af9af2f8e528411ecef9c2f7628989cc5 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 00:29:31 +0800 Subject: [PATCH] feat: add tenant role templates --- README.md | 4 +- apps/api/src/features/tenant-admin/auth.ts | 85 +++++- apps/api/src/features/tenant-admin/index.ts | 6 + apps/api/src/features/tenant-admin/routes.ts | 249 +++++++++++++++++- apps/api/src/features/tenant-content/auth.ts | 37 ++- docs/refactor/backend-capability-status.md | 6 +- docs/refactor/backend-handoff-roadmap.md | 4 +- docs/refactor/backend-progress.md | 3 +- docs/refactor/frontend-handoff-index.md | 3 +- .../multitenant-auth-security-contract.md | 12 +- docs/refactor/next-development-todo.md | 8 +- docs/refactor/taro-frontend-integration.md | 5 +- scripts/api-integration-test.js | 75 +++++- .../202606290001_tenant_role_templates.sql | 49 ++++ 14 files changed, 504 insertions(+), 42 deletions(-) create mode 100644 supabase/migrations/202606290001_tenant_role_templates.sql diff --git a/README.md b/README.md index 874eeff7..7fdb1ed4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、审计日志。 +- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、自定义角色模板、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 @@ -174,7 +174,7 @@ npm run check:refactor 优先继续补: -1. 真实云端 Auth/JWKS 回归、RLS 深测、自定义角色模板和菜单/模块/字段级权限。 +1. 真实云端 Auth/JWKS 回归、RLS 深测、班级/教师/学生范围权限。 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。 4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index c79b571d..fd89f50e 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -24,7 +24,15 @@ export interface TenantAdminAuth { tenantId: string; userId: string; role: string; + roleTemplateId: string | null; + roleTemplateCode: string | null; + roleTemplateName: string | null; permissions: Record; + templatePermissions: Record; + menuPermissions: Record; + modulePermissions: Record; + fieldPermissions: Record; + dataScope: Record; } function permissionKeys(permission: string) { @@ -49,6 +57,9 @@ export function hasTenantPermission(auth: TenantAdminAuth, permission: string) { const explicit = explicitPermission(auth.permissions, permission); if (explicit !== null) return explicit; + const templateExplicit = explicitPermission(auth.templatePermissions, permission); + if (templateExplicit !== null) return templateExplicit; + const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || []; return defaults.some(defaultPermission => { if (defaultPermission === '*') return true; @@ -93,9 +104,31 @@ export function tenantPermissionCatalog() { { key: 'crm:write', label: 'CRM 入队和重试' }, { key: 'members:read', label: '成员查看' }, { key: 'members:write', label: '成员管理' }, + { key: 'roles:read', label: '角色模板查看' }, + { key: 'roles:write', label: '角色模板管理' }, { key: 'audit:read', label: '审计日志查看' }, { key: 'content:*', label: '内容维护' }, ], + menuGroups: [ + { key: 'dashboard', label: '数据看板' }, + { key: 'content', label: '题库内容' }, + { key: 'students', label: '学生管理' }, + { key: 'teachers', label: '教师/班级' }, + { key: 'marketing', label: '营销中心' }, + { key: 'sales', label: '销售/代理' }, + { key: 'crm', label: 'CRM' }, + { key: 'commerce', label: '订单/权益' }, + { key: 'settings', label: '租户设置' }, + { key: 'audit', label: '审计日志' }, + ], + fieldGroups: [ + { key: 'student.phone', label: '学生手机号' }, + { key: 'student.wechat', label: '学生微信' }, + { key: 'student.exam_intent', label: '考试意向' }, + { key: 'order.amount', label: '订单金额' }, + { key: 'referral.owner', label: '客资归属' }, + { key: 'payment.secret_mask', label: '商户密钥掩码' }, + ], roleDefaults: ROLE_PERMISSION_DEFAULTS, }; } @@ -104,14 +137,37 @@ export async function requireTenantAdmin(ctx: RequestContext): Promise }>( + const membership = await queryOne<{ + role: string; + roleTemplateId: string | null; + roleTemplateCode: string | null; + roleTemplateName: string | null; + permissions: Record; + templatePermissions: Record; + menuPermissions: Record; + modulePermissions: Record; + fieldPermissions: Record; + dataScope: Record; + }>( ` - select role, permissions - from public.tenant_memberships - where tenant_id = $1 - and user_id = $2 - and status = 'active' - and role = any($3::text[]) + select tm.role, tm.permissions, + tm.role_template_id as "roleTemplateId", + rt.code as "roleTemplateCode", + rt.name as "roleTemplateName", + coalesce(rt.permissions, '{}'::jsonb) as "templatePermissions", + coalesce(rt.menu_permissions, '{}'::jsonb) as "menuPermissions", + coalesce(rt.module_permissions, '{}'::jsonb) as "modulePermissions", + coalesce(rt.field_permissions, '{}'::jsonb) as "fieldPermissions", + coalesce(rt.data_scope, '{}'::jsonb) as "dataScope" + from public.tenant_memberships tm + left join public.tenant_role_templates rt + on rt.id = tm.role_template_id + and rt.tenant_id = tm.tenant_id + and rt.status = 'active' + where tm.tenant_id = $1 + and tm.user_id = $2 + and tm.status = 'active' + and tm.role = any($3::text[]) order by case role when 'tenant_owner' then 1 when 'tenant_admin' then 2 @@ -126,5 +182,18 @@ export async function requireTenantAdmin(ctx: RequestContext): Promise = {}; + for (const [key, raw] of Object.entries(source)) { + if (typeof raw !== 'boolean') { + throw new HttpError(400, `${name}.${key} must be boolean`, 'INVALID_ACCESS_CONTROL_VALUE'); + } + if (!/^[a-z][a-z0-9_.:-]*$/i.test(key)) { + throw new HttpError(400, `Invalid ${name} key: ${key}`, 'INVALID_ACCESS_CONTROL_KEY'); + } + result[key] = raw; + } + return result; +} + +function dataScopeValue(value: unknown) { + const scope = objectValue(value); + const allowed = new Set(['mode', 'regionIds', 'contentNodeIds', 'classIds', 'ownLeadsOnly', 'teamScope', 'metadata']); + for (const key of Object.keys(scope)) { + if (!allowed.has(key)) { + throw new HttpError(400, `Invalid dataScope key: ${key}`, 'INVALID_DATA_SCOPE_KEY'); + } + } + return scope; +} + function requiredMemberRole(value: unknown) { return optionalChoice(value, TENANT_MEMBER_ROLES, 'student'); } @@ -241,6 +277,30 @@ function ensureCanGrantRole(auth: TenantAdminAuth, role: string, permissions: Re } } +async function resolveRoleTemplateForMember(client: pg.PoolClient, auth: TenantAdminAuth, roleTemplateId: string | null) { + if (!roleTemplateId) return null; + const template = await client.query<{ + id: string; + baseRole: string; + permissions: Record; + status: string; + }>( + ` + select id, base_role as "baseRole", permissions, status + from public.tenant_role_templates + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, roleTemplateId], + ); + const item = template.rows[0]; + if (!item || item.status !== 'active') { + throw new HttpError(404, 'Role template not found or inactive', 'ROLE_TEMPLATE_NOT_FOUND'); + } + ensureCanGrantRole(auth, item.baseRole, item.permissions || {}); + return item; +} + async function ensureOwnerRemains( client: pg.PoolClient, tenantId: string, @@ -346,12 +406,172 @@ export async function tenantPermissionsRoute(ctx: RequestContext) { current: { userId: auth.userId, role: auth.role, + roleTemplateId: auth.roleTemplateId, + roleTemplateCode: auth.roleTemplateCode, + roleTemplateName: auth.roleTemplateName, permissions: auth.permissions, + templatePermissions: auth.templatePermissions, + effectivePermissions: { + ...auth.templatePermissions, + ...auth.permissions, + }, + menuPermissions: auth.menuPermissions, + modulePermissions: auth.modulePermissions, + fieldPermissions: auth.fieldPermissions, + dataScope: auth.dataScope, }, ...tenantPermissionCatalog(), }; } +export async function tenantRoleTemplatesRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'roles:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const status = stringParam(ctx, 'status'); + const params: unknown[] = [auth.tenantId]; + const filters = ['tenant_id = $1']; + if (status) { + if (!ROLE_TEMPLATE_STATUSES.includes(status)) { + throw new HttpError(400, `Invalid role template status: ${status}`, 'INVALID_ROLE_TEMPLATE_STATUS'); + } + params.push(status); + filters.push(`status = $${params.length}`); + } + params.push(limit); + + const items = await query( + ` + select id, code, name, description, base_role as "baseRole", status, permissions, + menu_permissions as "menuPermissions", module_permissions as "modulePermissions", + field_permissions as "fieldPermissions", data_scope as "dataScope", + is_system as "isSystem", sort_order as "sortOrder", + created_by as "createdBy", updated_by as "updatedBy", + created_at as "createdAt", updated_at as "updatedAt" + from public.tenant_role_templates + where ${filters.join(' and ')} + order by sort_order asc, created_at asc + limit $${params.length} + `, + params, + ); + + return { items }; +} + +export async function upsertTenantRoleTemplateRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'roles:write'); + const body = await readJsonBody(ctx); + const templateId = nullableString(body.id); + const baseRole = requiredMemberRole(body.baseRole || body.role); + const permissions = permissionValue(body.permissions); + ensureCanGrantRole(auth, baseRole, permissions); + + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.tenant_role_templates ( + id, tenant_id, code, name, description, base_role, status, permissions, + menu_permissions, module_permissions, field_permissions, data_scope, + sort_order, created_by, updated_by + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6, $7, $8::jsonb, + $9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb, $13, $14, $14 + ) + on conflict (tenant_id, code) + do update set name = excluded.name, + description = excluded.description, + base_role = excluded.base_role, + status = excluded.status, + permissions = excluded.permissions, + menu_permissions = excluded.menu_permissions, + module_permissions = excluded.module_permissions, + field_permissions = excluded.field_permissions, + data_scope = excluded.data_scope, + sort_order = excluded.sort_order, + updated_by = excluded.updated_by, + updated_at = now() + returning id, code, name, description, base_role as "baseRole", status, permissions, + menu_permissions as "menuPermissions", module_permissions as "modulePermissions", + field_permissions as "fieldPermissions", data_scope as "dataScope", + is_system as "isSystem", sort_order as "sortOrder", + created_by as "createdBy", updated_by as "updatedBy", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + templateId, + safeCodeValue(body.code, nullableString(body.name) || ''), + requiredString(body, 'name'), + nullableString(body.description), + baseRole, + optionalChoice(body.status, ROLE_TEMPLATE_STATUSES, 'active'), + JSON.stringify(permissions), + JSON.stringify(accessControlMapValue(body.menuPermissions, 'menuPermissions')), + JSON.stringify(accessControlMapValue(body.modulePermissions, 'modulePermissions')), + JSON.stringify(accessControlMapValue(body.fieldPermissions, 'fieldPermissions')), + JSON.stringify(dataScopeValue(body.dataScope)), + intValue(body.sortOrder, 100), + auth.userId, + ], + ); + await recordAudit(client, auth, 'tenant.role_template.upserted', 'tenant_role_templates', result.rows[0].id, { + code: result.rows[0].code, + baseRole, + permissionKeys: Object.keys(permissions), + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function disableTenantRoleTemplateRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'roles:write'); + const body = await readJsonBody(ctx); + const roleTemplateId = requiredString(body, 'roleTemplateId'); + + const item = await transaction(async client => { + const current = await client.query<{ id: string; permissions: Record; baseRole: string; isSystem: boolean }>( + ` + select id, permissions, base_role as "baseRole", is_system as "isSystem" + from public.tenant_role_templates + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, roleTemplateId], + ); + if (!current.rows[0]) throw new HttpError(404, 'Role template not found', 'ROLE_TEMPLATE_NOT_FOUND'); + if (current.rows[0].isSystem) throw new HttpError(400, 'System role template cannot be disabled', 'SYSTEM_ROLE_TEMPLATE_LOCKED'); + ensureCanGrantRole(auth, current.rows[0].baseRole, current.rows[0].permissions || {}); + + const result = await client.query( + ` + update public.tenant_role_templates + set status = 'disabled', + updated_by = $3, + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, code, name, description, base_role as "baseRole", status, permissions, + menu_permissions as "menuPermissions", module_permissions as "modulePermissions", + field_permissions as "fieldPermissions", data_scope as "dataScope", + is_system as "isSystem", sort_order as "sortOrder", + created_at as "createdAt", updated_at as "updatedAt" + `, + [auth.tenantId, roleTemplateId, auth.userId], + ); + await recordAudit(client, auth, 'tenant.role_template.disabled', 'tenant_role_templates', roleTemplateId, { + code: result.rows[0].code, + }); + return result.rows[0]; + }); + + return { item }; +} + export async function tenantOverviewRoute(ctx: RequestContext) { const auth = await requireTenantAdmin(ctx); requireTenantPermission(auth, 'tenant:overview:read'); @@ -1289,11 +1509,19 @@ export async function tenantMembersRoute(ctx: RequestContext) { const items = await query( ` select tm.id, tm.user_id as "userId", tm.role, tm.status, tm.permissions, + tm.role_template_id as "roleTemplateId", + rt.code as "roleTemplateCode", + rt.name as "roleTemplateName", + rt.menu_permissions as "menuPermissions", + rt.module_permissions as "modulePermissions", + rt.field_permissions as "fieldPermissions", + rt.data_scope as "dataScope", tm.legacy_role as "legacyRole", tm.created_at as "createdAt", tm.updated_at as "updatedAt", u.username, u.email::text as email, u.phone, u.name, u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.last_seen_at as "lastSeenAt" from public.tenant_memberships tm join public.platform_users u on u.id = tm.user_id + left join public.tenant_role_templates rt on rt.id = tm.role_template_id and rt.tenant_id = tm.tenant_id where ${filters.join(' and ')} order by case tm.role when 'tenant_owner' then 1 @@ -1317,12 +1545,16 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) { requireTenantPermission(auth, 'members:write'); const body = await readJsonBody(ctx); const membershipId = nullableString(body.membershipId) || nullableString(body.id); - const role = requiredMemberRole(body.role); + const roleTemplateId = nullableString(body.roleTemplateId); + let role = requiredMemberRole(body.role); const status = optionalChoice(body.status, TENANT_MEMBER_STATUSES, 'active'); const permissions = permissionValue(body.permissions); - ensureCanGrantRole(auth, role, permissions); const item = await transaction(async client => { + const template = await resolveRoleTemplateForMember(client, auth, roleTemplateId); + if (template) role = template.baseRole; + ensureCanGrantRole(auth, role, permissions); + const userId = await resolveOrCreateMemberUser(client, body); if (userId === auth.userId && status === 'disabled') { throw new HttpError(400, 'Cannot disable your own tenant membership', 'CANNOT_DISABLE_SELF'); @@ -1338,26 +1570,30 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) { role = $4, status = $5, permissions = $6::jsonb, + role_template_id = $7::uuid, updated_at = now() where tenant_id = $1 and id = $2 returning id, user_id as "userId", role, status, permissions, + role_template_id as "roleTemplateId", legacy_role as "legacyRole", created_at as "createdAt", updated_at as "updatedAt" `, - [auth.tenantId, membershipId, userId, role, status, JSON.stringify(permissions)], + [auth.tenantId, membershipId, userId, role, status, JSON.stringify(permissions), roleTemplateId], ); } else { result = await client.query( ` - insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions) - values ($1, $2, $3, $4, $5::jsonb) + insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions, role_template_id) + values ($1, $2, $3, $4, $5::jsonb, $6::uuid) on conflict (tenant_id, user_id, role) do update set status = excluded.status, permissions = excluded.permissions, + role_template_id = excluded.role_template_id, updated_at = now() returning id, user_id as "userId", role, status, permissions, + role_template_id as "roleTemplateId", legacy_role as "legacyRole", created_at as "createdAt", updated_at as "updatedAt" `, - [auth.tenantId, userId, role, status, JSON.stringify(permissions)], + [auth.tenantId, userId, role, status, JSON.stringify(permissions), roleTemplateId], ); } @@ -1367,6 +1603,7 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) { userId, role, status, + roleTemplateId, permissionKeys: Object.keys(permissions), }); diff --git a/apps/api/src/features/tenant-content/auth.ts b/apps/api/src/features/tenant-content/auth.ts index 9b3b84c3..7981da6a 100644 --- a/apps/api/src/features/tenant-content/auth.ts +++ b/apps/api/src/features/tenant-content/auth.ts @@ -9,21 +9,37 @@ export interface TenantContentAuth { userId: string; role: string; permissions: Record; + templatePermissions: Record; +} + +function hasContentPermission(permissions: Record) { + return permissions['*'] === true || permissions['content:*'] === true; } export async function requireTenantContentEditor(ctx: RequestContext): Promise { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); - const membership = await queryOne<{ role: string; permissions: Record }>( + const membership = await queryOne<{ role: string; permissions: Record; templatePermissions: Record }>( ` - select role, permissions - from public.tenant_memberships - where tenant_id = $1 - and user_id = $2 - and status = 'active' - and role = any($3::text[]) - order by case role + select tm.role, tm.permissions, + coalesce(rt.permissions, '{}'::jsonb) as "templatePermissions" + from public.tenant_memberships tm + left join public.tenant_role_templates rt + on rt.id = tm.role_template_id + and rt.tenant_id = tm.tenant_id + and rt.status = 'active' + where tm.tenant_id = $1 + and tm.user_id = $2 + and tm.status = 'active' + and ( + tm.role = any($3::text[]) + or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*' + or coalesce(rt.permissions, '{}'::jsonb) ? '*' + or tm.permissions ? 'content:*' + or tm.permissions ? '*' + ) + order by case tm.role when 'tenant_owner' then 1 when 'tenant_admin' then 2 when 'tenant_operator' then 3 @@ -38,6 +54,9 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 +- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index b5f7e840..676121cf 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -36,10 +36,11 @@ - H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。 - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。 - 可以接入租户品牌、主题、功能开关和域名/小程序参数解析。 +- 租户后台可以接入角色模板 API:`/api/tenant-admin/role-templates`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性。 ## 不能误认为已商用完成的部分 -- 生产鉴权已具备 Supabase JWT API 入口,但仍要做真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化;前端不要继续使用 `x-user-id`。 +- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`。 - 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。 - 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。 - 对象存储已完成签名 provider,但 PDF 预览、防盗链、视频水印、上传后校验还要补。 diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index 03981cdf..d68ce3d3 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -149,11 +149,17 @@ provider event id 幂等 | agent | 兑换码/优惠券只读、本人的客资 | | student | 无后台权限 | +已支持租户自定义角色模板: + +- `tenant_role_templates` 保存租户内模板,成员通过 `tenant_memberships.role_template_id` 绑定。 +- 权限判断顺序:成员 `permissions` 显式覆盖 > 角色模板 `permissions` > 系统角色默认权限。 +- 模板可保存 `menuPermissions`、`modulePermissions`、`fieldPermissions`、`dataScope`,供 Taro/管理台做菜单、模块、字段可见性和数据范围 UI。 +- 模板含 `*`、`tenant_owner`、`tenant_admin` 等管理员级能力时,只有租户 owner 可创建或授予;普通租户管理员不能自造全权限模板。 +- 角色模板创建、更新、禁用都会写入 `audit_logs`。 + 后续要补: -- 租户自定义角色模板。 -- 菜单级、模块级、字段级权限。 -- 权限变更审计。 +- 前端角色模板配置 UI。 - 班级/教师/学生范围权限。 ## 上线前安全验收清单 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 21638f0b..b0eb5715 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -30,7 +30,7 @@ 1. 生产鉴权 - 已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口;JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。 - 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试。 - - 继续补真实云端 Auth/JWKS 回归、RLS 深测、自定义角色模板和菜单/模块/字段级权限。 + - 已补自定义角色模板、菜单/模块/字段级配置 API;继续补真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限。 - 前端联调时禁止继续使用 `x-user-id`;`x-tenant-id` 只作为租户上下文,不能作为身份依据。 2. 对象存储 @@ -97,9 +97,9 @@ ### P2 运营体验和企业交付 1. 自定义角色 - - 租户内角色模板。 - - 菜单可见、模块可见、字段级权限。 - - 权限变更审计。 + - 已完成租户内角色模板、菜单可见、模块可见、字段级权限和权限变更审计基础 API。 + - 继续补租户后台可视化配置 UI。 + - 继续补班级/教师/学生范围权限。 2. 主题系统 - 平台默认三套主题。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 5df9f56e..4a5d5d9f 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -437,7 +437,7 @@ content_entries - 支付渠道从后端返回或租户配置读取,不在页面硬编码。 - 小程序分享路径必须带 tenantCode 和 referral code。 - 用户首绑归属由后端保护,前端不要提供“换绑销售”入口。 -- 管理后台菜单按 `GET /api/tenant-admin/permissions` 和用户权限渲染。 +- 管理后台菜单按 `GET /api/tenant-admin/permissions` 返回的 `current.permissions`、`current.templatePermissions`、`current.menuPermissions`、`current.modulePermissions` 渲染;接口权限仍以后端校验为准。 - H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。 ## 登录对接 @@ -602,13 +602,14 @@ GET /api/commerce/entitlements - 品牌/主题/域名/公开设置 - 支付账户/登录 provider/密钥引用 - 用户与成员权限 +- 角色模板:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable` - 内容入口/分类树/题目集合/练习蓝图 - 题目/单词/知识手册/分数线/视频维护 - JSON 导入 preview/import/issues - Banner/FAQ/公告/激活码/优惠券 - 销售/代理/CRM 队列 -租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。 +租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。 ## 联调顺序 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 7741666f..6b1b6f90 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -2348,8 +2348,63 @@ async function testTenantMemberPermissionsAndAudit() { userId: TENANT_ADMIN_USER_ID, }); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission'); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission'); + assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults'); + const roleTemplate = await request('/api/tenant-admin/role-templates', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: 'ops-marketing', + name: '运营活动模板', + description: '允许运营维护活动内容,同时可配置前端菜单和字段可见性', + baseRole: 'tenant_operator', + permissions: { + 'marketing:*': true, + 'tenant:payment:write': true, + }, + menuPermissions: { + dashboard: true, + marketing: true, + settings: false, + }, + modulePermissions: { + banners: true, + coupons: true, + }, + fieldPermissions: { + 'student.phone': false, + 'order.amount': true, + }, + dataScope: { + mode: 'tenant', + ownLeadsOnly: false, + }, + sortOrder: 10, + }, + }); + assert.equal(roleTemplate.item?.code, 'ops-marketing', 'tenant admin should upsert custom role template'); + assert.equal(roleTemplate.item?.menuPermissions?.marketing, true, 'role template should persist menu permissions'); + + const roleTemplates = await request('/api/tenant-admin/role-templates', { + userId: TENANT_ADMIN_USER_ID, + }); + assert.ok(roleTemplates.items?.some(item => item.id === roleTemplate.item.id), 'role template list should include custom template'); + + const wildcardTemplateDenied = await request('/api/tenant-admin/role-templates', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: 'super-admin-template', + name: '危险全权限模板', + baseRole: 'tenant_admin', + permissions: { '*': true }, + }, + expectStatus: 403, + }); + assert.equal(wildcardTemplateDenied.code, 'TENANT_OWNER_REQUIRED', 'tenant admin should not create owner/admin level role template'); + const operator = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', @@ -2359,6 +2414,7 @@ async function testTenantMemberPermissionsAndAudit() { phone: '13800000003', name: 'Integration Operator', role: 'tenant_operator', + roleTemplateId: roleTemplate.item.id, status: 'active', permissions: { 'marketing:*': true, @@ -2367,12 +2423,20 @@ async function testTenantMemberPermissionsAndAudit() { }, }); assert.equal(operator.item?.role, 'tenant_operator', 'tenant admin should create operator membership'); + assert.equal(operator.item?.roleTemplateId, roleTemplate.item.id, 'member should bind custom role template'); const members = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, query: { keyword: 'Integration Operator' }, }); assert.ok(members.items?.some(item => item.userId === TENANT_OPERATOR_USER_ID), 'member list should find operator'); + assert.ok(members.items?.some(item => item.userId === TENANT_OPERATOR_USER_ID && item.roleTemplateCode === 'ops-marketing'), 'member list should expose role template binding'); + + const operatorPermissionMatrix = await request('/api/tenant-admin/permissions', { + userId: TENANT_OPERATOR_USER_ID, + }); + assert.equal(operatorPermissionMatrix.current?.roleTemplateCode, 'ops-marketing', 'current permission matrix should include role template'); + assert.equal(operatorPermissionMatrix.current?.menuPermissions?.marketing, true, 'current permission matrix should expose menu permissions'); const operatorBanner = await request('/api/tenant-admin/banners', { userId: TENANT_OPERATOR_USER_ID, @@ -2468,12 +2532,21 @@ async function testTenantMemberPermissionsAndAudit() { }); assert.equal(disabledSalesDenied.code, 'TENANT_ADMIN_REQUIRED', 'disabled sales membership should lose tenant admin access'); + const disabledTemplate = await request('/api/tenant-admin/role-templates/disable', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { roleTemplateId: roleTemplate.item.id }, + }); + assert.equal(disabledTemplate.item?.status, 'disabled', 'tenant admin should disable custom role template'); + const auditLogs = await request('/api/tenant-admin/audit-logs', { userId: TENANT_ADMIN_USER_ID, - query: { action: 'tenant.member', limit: 20 }, + query: { action: 'tenant.', limit: 50 }, }); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.member.upserted'), 'audit logs should include member upsert'); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.member.disabled'), 'audit logs should include member disable'); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.role_template.upserted'), 'audit logs should include role template upsert'); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.role_template.disabled'), 'audit logs should include role template disable'); const partnerAuditDenied = await request('/api/tenant-admin/audit-logs', { tenantId: PARTNER_TENANT_ID, diff --git a/supabase/migrations/202606290001_tenant_role_templates.sql b/supabase/migrations/202606290001_tenant_role_templates.sql new file mode 100644 index 00000000..70859620 --- /dev/null +++ b/supabase/migrations/202606290001_tenant_role_templates.sql @@ -0,0 +1,49 @@ +create table if not exists public.tenant_role_templates ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + code text not null, + name text not null, + description text, + base_role text not null default 'tenant_operator' + check (base_role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student')), + status text not null default 'active' check (status in ('active', 'disabled', 'archived')), + permissions jsonb not null default '{}'::jsonb, + menu_permissions jsonb not null default '{}'::jsonb, + module_permissions jsonb not null default '{}'::jsonb, + field_permissions jsonb not null default '{}'::jsonb, + data_scope jsonb not null default '{}'::jsonb, + is_system boolean not null default false, + sort_order integer not null default 100, + created_by uuid references public.platform_users(id) on delete set null, + updated_by uuid references public.platform_users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, code) +); + +comment on table public.tenant_role_templates is + '租户自定义角色模板。成员仍保留系统 base role,模板提供权限、菜单、模块、字段和数据范围配置。'; + +alter table public.tenant_memberships + add column if not exists role_template_id uuid references public.tenant_role_templates(id) on delete restrict; + +create index if not exists idx_role_templates_tenant_status + on public.tenant_role_templates(tenant_id, status, sort_order); + +create index if not exists idx_memberships_role_template + on public.tenant_memberships(role_template_id) + where role_template_id is not null; + +alter table public.tenant_role_templates enable row level security; + +drop policy if exists tenant_isolation on public.tenant_role_templates; +create policy tenant_isolation on public.tenant_role_templates + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); + +drop trigger if exists set_updated_at on public.tenant_role_templates; +create trigger set_updated_at + before update on public.tenant_role_templates + for each row + execute function app.touch_updated_at();