forked from wangziqi/gongxue-base
feat: add tenant role templates
This commit is contained in:
@@ -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 导入升级为可排队异步执行。
|
||||
|
||||
@@ -24,7 +24,15 @@ export interface TenantAdminAuth {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
roleTemplateId: string | null;
|
||||
roleTemplateCode: string | null;
|
||||
roleTemplateName: string | null;
|
||||
permissions: Record<string, unknown>;
|
||||
templatePermissions: Record<string, unknown>;
|
||||
menuPermissions: Record<string, unknown>;
|
||||
modulePermissions: Record<string, unknown>;
|
||||
fieldPermissions: Record<string, unknown>;
|
||||
dataScope: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<TenantAdm
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
|
||||
const membership = await queryOne<{
|
||||
role: string;
|
||||
roleTemplateId: string | null;
|
||||
roleTemplateCode: string | null;
|
||||
roleTemplateName: string | null;
|
||||
permissions: Record<string, unknown>;
|
||||
templatePermissions: Record<string, unknown>;
|
||||
menuPermissions: Record<string, unknown>;
|
||||
modulePermissions: Record<string, unknown>;
|
||||
fieldPermissions: Record<string, unknown>;
|
||||
dataScope: Record<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
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<TenantAdm
|
||||
throw new HttpError(403, 'Tenant admin access is required', 'TENANT_ADMIN_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
|
||||
return {
|
||||
tenantId,
|
||||
userId,
|
||||
role: membership.role,
|
||||
roleTemplateId: membership.roleTemplateId,
|
||||
roleTemplateCode: membership.roleTemplateCode,
|
||||
roleTemplateName: membership.roleTemplateName,
|
||||
permissions: membership.permissions || {},
|
||||
templatePermissions: membership.templatePermissions || {},
|
||||
menuPermissions: membership.menuPermissions || {},
|
||||
modulePermissions: membership.modulePermissions || {},
|
||||
fieldPermissions: membership.fieldPermissions || {},
|
||||
dataScope: membership.dataScope || {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
codeBatchesRoute,
|
||||
couponsRoute,
|
||||
createTenantDomainRoute,
|
||||
disableTenantRoleTemplateRoute,
|
||||
disableTenantMemberRoute,
|
||||
faqsAdminRoute,
|
||||
generateActivationCodesRoute,
|
||||
@@ -16,8 +17,10 @@ import {
|
||||
tenantMembersRoute,
|
||||
tenantOverviewRoute,
|
||||
tenantPermissionsRoute,
|
||||
tenantRoleTemplatesRoute,
|
||||
tenantSecretsRoute,
|
||||
upsertTenantMemberRoute,
|
||||
upsertTenantRoleTemplateRoute,
|
||||
upsertActivationCodeRoute,
|
||||
upsertAnnouncementRoute,
|
||||
upsertAuthProviderRoute,
|
||||
@@ -33,6 +36,9 @@ import {
|
||||
|
||||
export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-admin/permissions', tenantPermissionsRoute],
|
||||
['GET', '/api/tenant-admin/role-templates', tenantRoleTemplatesRoute],
|
||||
['PUT', '/api/tenant-admin/role-templates', upsertTenantRoleTemplateRoute],
|
||||
['POST', '/api/tenant-admin/role-templates/disable', disableTenantRoleTemplateRoute],
|
||||
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
|
||||
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
||||
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
||||
|
||||
@@ -20,6 +20,7 @@ const AUTH_STATUSES = ['active', 'disabled', 'testing'];
|
||||
const DISCOUNT_TYPES = ['percent', 'fixed'];
|
||||
const TENANT_MEMBER_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student'];
|
||||
const TENANT_MEMBER_STATUSES = ['active', 'invited', 'disabled'];
|
||||
const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived'];
|
||||
|
||||
function jsonBodyValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||
@@ -230,6 +231,41 @@ function permissionValue(value: unknown) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
function safeCodeValue(value: unknown, fallback = '') {
|
||||
const raw = nullableString(value) || fallback;
|
||||
const code = raw.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
if (!code || !/^[a-z][a-z0-9_-]{1,63}$/.test(code)) {
|
||||
throw new HttpError(400, 'Invalid role template code', 'INVALID_ROLE_TEMPLATE_CODE');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function accessControlMapValue(value: unknown, name: string) {
|
||||
const source = objectValue(value);
|
||||
const result: Record<string, boolean> = {};
|
||||
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<string, boolean>;
|
||||
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<string, boolean>; 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),
|
||||
});
|
||||
|
||||
|
||||
@@ -9,21 +9,37 @@ export interface TenantContentAuth {
|
||||
userId: string;
|
||||
role: string;
|
||||
permissions: Record<string, unknown>;
|
||||
templatePermissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasContentPermission(permissions: Record<string, unknown>) {
|
||||
return permissions['*'] === true || permissions['content:*'] === true;
|
||||
}
|
||||
|
||||
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown>; templatePermissions: Record<string, unknown> }>(
|
||||
`
|
||||
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<T
|
||||
if (!membership) {
|
||||
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
|
||||
}
|
||||
if (!CONTENT_ROLES.has(membership.role) && !hasContentPermission(membership.permissions || {}) && !hasContentPermission(membership.templatePermissions || {})) {
|
||||
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {}, templatePermissions: membership.templatePermissions || {} };
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
| 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 |
|
||||
| 微信网页/QQ OAuth | 待补齐 | 目前仍是 placeholder,需要 code 换 token、回调域名、账号合并和审计 |
|
||||
| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 |
|
||||
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions`,接口有权限点校验 |
|
||||
| 自定义角色模板 | 待补齐 | 当前有权限 JSON 覆盖,缺角色模板、菜单/模块/字段级权限配置 UI/API |
|
||||
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 |
|
||||
| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;前端 UI 和班级/学生范围继续补 |
|
||||
|
||||
## 学生端题库主链路
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
| 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption |
|
||||
| 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners`、`faqs`、`announcements` |
|
||||
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` |
|
||||
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members`、`permissions`、`audit-logs` |
|
||||
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members`、`permissions`、`role-templates`、`audit-logs` |
|
||||
| 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` |
|
||||
| 数据看板聚合接口 | 待补齐 | 表基础已有,缺完整 dashboard API |
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量 | 自动计费、平台审计、公共题库披露策略 |
|
||||
| 租户后台 | 基础完成 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限 | 自定义角色模板、菜单/模块可见性 UI、字段级权限 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限、角色模板、菜单/模块/字段权限配置 API | 前端权限 UI、班级/教师/学生范围权限 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏 | 完整模考交卷报告、专项策略、公题库采纳/授权、Excel 导入 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、JSON 导入 | 复习算法、每日计划、排行榜、Excel 导入 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
### P2:企业级体验和增长闭环
|
||||
|
||||
- 租户自定义角色、菜单可见、模块可见、字段级权限和权限审计。
|
||||
- 租户自定义角色模板基础 API 已完成;继续补权限配置 UI、班级/教师/学生范围权限和平台级审计报表。
|
||||
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。
|
||||
- CRM worker:钉钉、飞书、企微机器人,轮询/定向分配,失败重试。
|
||||
- 销售/代理分佣结算、销售团队看板、客资跟进效果。
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
- `commerce`:订单、支付确认、激活码兑换、权益查询。
|
||||
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。
|
||||
- `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
|
||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
|
||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、角色模板、权限矩阵、审计查询。
|
||||
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。
|
||||
- `tenant`:域名/租户解析。
|
||||
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> 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` 后恢复最小烟测数据。
|
||||
|
||||
@@ -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 预览、防盗链、视频水印、上传后校验还要补。
|
||||
|
||||
@@ -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。
|
||||
- 班级/教师/学生范围权限。
|
||||
|
||||
## 上线前安全验收清单
|
||||
|
||||
@@ -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. 主题系统
|
||||
- 平台默认三套主题。
|
||||
|
||||
@@ -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 执行真正的访问控制。
|
||||
|
||||
## 联调顺序
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
49
supabase/migrations/202606290001_tenant_role_templates.sql
Normal file
49
supabase/migrations/202606290001_tenant_role_templates.sql
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user