forked from wangziqi/gongxue-base
feat: add tenant role templates
This commit is contained in:
@@ -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 || {} };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user