feat: add tenant role templates

This commit is contained in:
Codex
2026-06-29 00:29:31 +08:00
parent 9553836ac7
commit b262e87af9
14 changed files with 504 additions and 42 deletions

View File

@@ -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 || {},
};
}

View File

@@ -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],

View File

@@ -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),
});

View File

@@ -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 || {} };
}