forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
130
apps/api/src/features/tenant-admin/auth.ts
Normal file
130
apps/api/src/features/tenant-admin/auth.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
|
||||
const TENANT_ADMIN_ROLES = new Set([
|
||||
'tenant_owner',
|
||||
'tenant_admin',
|
||||
'tenant_operator',
|
||||
'teacher',
|
||||
'sales',
|
||||
'agent',
|
||||
]);
|
||||
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
|
||||
tenant_owner: ['*'],
|
||||
tenant_admin: ['*'],
|
||||
tenant_operator: ['content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'],
|
||||
teacher: ['content:*'],
|
||||
sales: ['codes:*', 'coupons:*', 'referral:*'],
|
||||
agent: ['codes:read', 'coupons:read', 'referral:self'],
|
||||
student: [],
|
||||
};
|
||||
|
||||
export interface TenantAdminAuth {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
permissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function permissionKeys(permission: string) {
|
||||
const parts = permission.split(':').filter(Boolean);
|
||||
const keys = [permission];
|
||||
for (let i = parts.length - 1; i >= 1; i -= 1) {
|
||||
keys.push(`${parts.slice(0, i).join(':')}:*`);
|
||||
}
|
||||
keys.push('*');
|
||||
return keys;
|
||||
}
|
||||
|
||||
function explicitPermission(permissions: Record<string, unknown>, permission: string) {
|
||||
for (const key of permissionKeys(permission)) {
|
||||
const value = permissions[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
const explicit = explicitPermission(auth.permissions, permission);
|
||||
if (explicit !== null) return explicit;
|
||||
|
||||
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
|
||||
return defaults.some(defaultPermission => {
|
||||
if (defaultPermission === '*') return true;
|
||||
if (defaultPermission === permission) return true;
|
||||
if (defaultPermission.endsWith(':*')) {
|
||||
return permission.startsWith(defaultPermission.slice(0, -1));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function requireTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
if (!hasTenantPermission(auth, permission)) {
|
||||
throw new HttpError(403, `Tenant permission is required: ${permission}`, 'TENANT_PERMISSION_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
export function tenantPermissionCatalog() {
|
||||
return {
|
||||
permissions: [
|
||||
{ key: 'tenant:overview:read', label: '租户概览' },
|
||||
{ key: 'tenant:branding:write', label: '品牌配置' },
|
||||
{ key: 'tenant:settings:write', label: '公开设置' },
|
||||
{ key: 'tenant:domains:read', label: '域名查看' },
|
||||
{ key: 'tenant:domains:write', label: '域名管理' },
|
||||
{ key: 'tenant:payment:read', label: '商户配置查看' },
|
||||
{ key: 'tenant:payment:write', label: '商户配置管理' },
|
||||
{ key: 'tenant:auth:read', label: '登录配置查看' },
|
||||
{ key: 'tenant:auth:write', label: '登录配置管理' },
|
||||
{ key: 'tenant:secrets:read', label: '密钥掩码查看' },
|
||||
{ key: 'tenant:secrets:write', label: '密钥轮换' },
|
||||
{ key: 'marketing:read', label: '活动内容查看' },
|
||||
{ key: 'marketing:write', label: '活动内容管理' },
|
||||
{ key: 'codes:read', label: '激活码查看' },
|
||||
{ key: 'codes:write', label: '激活码管理' },
|
||||
{ key: 'coupons:read', label: '优惠券查看' },
|
||||
{ key: 'coupons:write', label: '优惠券管理' },
|
||||
{ key: 'referral:read', label: '客资全局查看' },
|
||||
{ key: 'referral:self', label: '本人客资查看' },
|
||||
{ key: 'referral:write', label: '客资归属管理' },
|
||||
{ key: 'crm:read', label: 'CRM 队列查看' },
|
||||
{ key: 'crm:write', label: 'CRM 入队和重试' },
|
||||
{ key: 'members:read', label: '成员查看' },
|
||||
{ key: 'members:write', label: '成员管理' },
|
||||
{ key: 'audit:read', label: '审计日志查看' },
|
||||
{ key: 'content:*', label: '内容维护' },
|
||||
],
|
||||
roleDefaults: ROLE_PERMISSION_DEFAULTS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdminAuth> {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: 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
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, Array.from(TENANT_ADMIN_ROLES)],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpError(403, 'Tenant admin access is required', 'TENANT_ADMIN_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
|
||||
}
|
||||
64
apps/api/src/features/tenant-admin/index.ts
Normal file
64
apps/api/src/features/tenant-admin/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
activationCodesRoute,
|
||||
announcementsAdminRoute,
|
||||
auditLogsRoute,
|
||||
authProvidersRoute,
|
||||
bannersAdminRoute,
|
||||
codeBatchesRoute,
|
||||
couponsRoute,
|
||||
createTenantDomainRoute,
|
||||
disableTenantMemberRoute,
|
||||
faqsAdminRoute,
|
||||
generateActivationCodesRoute,
|
||||
paymentAccountsRoute,
|
||||
tenantDomainsRoute,
|
||||
tenantMembersRoute,
|
||||
tenantOverviewRoute,
|
||||
tenantPermissionsRoute,
|
||||
tenantSecretsRoute,
|
||||
upsertTenantMemberRoute,
|
||||
upsertActivationCodeRoute,
|
||||
upsertAnnouncementRoute,
|
||||
upsertAuthProviderRoute,
|
||||
upsertBannerRoute,
|
||||
upsertCodeBatchRoute,
|
||||
upsertCouponRoute,
|
||||
upsertFaqRoute,
|
||||
updateTenantBrandingRoute,
|
||||
updateTenantSettingsRoute,
|
||||
upsertPaymentAccountRoute,
|
||||
upsertTenantSecretRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-admin/permissions', tenantPermissionsRoute],
|
||||
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
|
||||
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
||||
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
||||
['GET', '/api/tenant-admin/domains', tenantDomainsRoute],
|
||||
['POST', '/api/tenant-admin/domains', createTenantDomainRoute],
|
||||
['GET', '/api/tenant-admin/payment-accounts', paymentAccountsRoute],
|
||||
['PUT', '/api/tenant-admin/payment-accounts', upsertPaymentAccountRoute],
|
||||
['GET', '/api/tenant-admin/auth-providers', authProvidersRoute],
|
||||
['PUT', '/api/tenant-admin/auth-providers', upsertAuthProviderRoute],
|
||||
['GET', '/api/tenant-admin/secrets', tenantSecretsRoute],
|
||||
['PUT', '/api/tenant-admin/secrets', upsertTenantSecretRoute],
|
||||
['GET', '/api/tenant-admin/banners', bannersAdminRoute],
|
||||
['PUT', '/api/tenant-admin/banners', upsertBannerRoute],
|
||||
['GET', '/api/tenant-admin/faqs', faqsAdminRoute],
|
||||
['PUT', '/api/tenant-admin/faqs', upsertFaqRoute],
|
||||
['GET', '/api/tenant-admin/announcements', announcementsAdminRoute],
|
||||
['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute],
|
||||
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],
|
||||
['PUT', '/api/tenant-admin/code-batches', upsertCodeBatchRoute],
|
||||
['GET', '/api/tenant-admin/activation-codes', activationCodesRoute],
|
||||
['PUT', '/api/tenant-admin/activation-codes', upsertActivationCodeRoute],
|
||||
['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute],
|
||||
['GET', '/api/tenant-admin/coupons', couponsRoute],
|
||||
['PUT', '/api/tenant-admin/coupons', upsertCouponRoute],
|
||||
['GET', '/api/tenant-admin/members', tenantMembersRoute],
|
||||
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
|
||||
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],
|
||||
['GET', '/api/tenant-admin/audit-logs', auditLogsRoute],
|
||||
];
|
||||
1466
apps/api/src/features/tenant-admin/routes.ts
Normal file
1466
apps/api/src/features/tenant-admin/routes.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user