diff --git a/prisma/migrations/20261007180000_granular_product_permissions/migration.sql b/prisma/migrations/20261007180000_granular_product_permissions/migration.sql new file mode 100644 index 0000000..fe7ff7d --- /dev/null +++ b/prisma/migrations/20261007180000_granular_product_permissions/migration.sql @@ -0,0 +1,21 @@ +-- Split hamkar/khanegi access into sub-sections (manage / settings / ui). +-- Existing admins keep what they had: hamkar:* also grants hamkar.ui:*, +-- khanegi:* also grants khanegi.ui:* and khanegi.settings:*. + +UPDATE "users" SET "permissions" = "permissions" || '["hamkar.ui:read"]'::jsonb +WHERE "permissions" @> '["hamkar:read"]'::jsonb AND NOT "permissions" @> '["hamkar.ui:read"]'::jsonb; + +UPDATE "users" SET "permissions" = "permissions" || '["hamkar.ui:write"]'::jsonb +WHERE "permissions" @> '["hamkar:write"]'::jsonb AND NOT "permissions" @> '["hamkar.ui:write"]'::jsonb; + +UPDATE "users" SET "permissions" = "permissions" || '["khanegi.ui:read"]'::jsonb +WHERE "permissions" @> '["khanegi:read"]'::jsonb AND NOT "permissions" @> '["khanegi.ui:read"]'::jsonb; + +UPDATE "users" SET "permissions" = "permissions" || '["khanegi.ui:write"]'::jsonb +WHERE "permissions" @> '["khanegi:write"]'::jsonb AND NOT "permissions" @> '["khanegi.ui:write"]'::jsonb; + +UPDATE "users" SET "permissions" = "permissions" || '["khanegi.settings:read"]'::jsonb +WHERE "permissions" @> '["khanegi:read"]'::jsonb AND NOT "permissions" @> '["khanegi.settings:read"]'::jsonb; + +UPDATE "users" SET "permissions" = "permissions" || '["khanegi.settings:write"]'::jsonb +WHERE "permissions" @> '["khanegi:write"]'::jsonb AND NOT "permissions" @> '["khanegi.settings:write"]'::jsonb; diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index c24265c..2006e9b 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -10,8 +10,14 @@ export const PERMISSIONS = [ 'support:write', 'hamkar:read', 'hamkar:write', + 'hamkar.ui:read', + 'hamkar.ui:write', 'khanegi:read', 'khanegi:write', + 'khanegi.settings:read', + 'khanegi.settings:write', + 'khanegi.ui:read', + 'khanegi.ui:write', 'others:read', 'others:write', 'admins:read', @@ -36,8 +42,16 @@ export const PERMISSION_MODULES: PermissionModule[] = [ { key: 'servers', label: 'سرور', read: 'servers:read', write: 'servers:write' }, { key: 'plugins', label: 'افزونه', read: 'plugins:read', write: 'plugins:write' }, { key: 'support', label: 'پشتیبانی', read: 'support:read', write: 'support:write' }, - { key: 'hamkar', label: 'همکار', read: 'hamkar:read', write: 'hamkar:write' }, - { key: 'khanegi', label: 'خانگی', read: 'khanegi:read', write: 'khanegi:write' }, + { key: 'hamkar', label: 'همکار — مدیریت', read: 'hamkar:read', write: 'hamkar:write' }, + { key: 'hamkar.ui', label: 'همکار — ظاهر', read: 'hamkar.ui:read', write: 'hamkar.ui:write' }, + { key: 'khanegi', label: 'خانگی — مدیریت', read: 'khanegi:read', write: 'khanegi:write' }, + { + key: 'khanegi.settings', + label: 'خانگی — تنظیمات', + read: 'khanegi.settings:read', + write: 'khanegi.settings:write', + }, + { key: 'khanegi.ui', label: 'خانگی — ظاهر', read: 'khanegi.ui:read', write: 'khanegi.ui:write' }, { key: 'others', label: 'سایت‌های دیگر', read: 'others:read', write: 'others:write' }, { key: 'admins', label: 'مدیران', read: 'admins:read', write: 'admins:write' }, { key: 'audit', label: 'لاگ', read: 'audit:read' }, @@ -107,3 +121,31 @@ export function productWritePermission(product: string): PermissionCode { if (product === 'other') return 'others:write'; return 'khanegi:write'; } + +export function productUiReadPermission(product: string): PermissionCode | null { + if (product === 'hamkar') return 'hamkar.ui:read'; + if (product === 'khanegi') return 'khanegi.ui:read'; + return null; +} + +export function productUiWritePermission(product: string): PermissionCode | null { + if (product === 'hamkar') return 'hamkar.ui:write'; + if (product === 'khanegi') return 'khanegi.ui:write'; + return null; +} + +/** Any read access to a product section (list / manage / ui / settings). */ +export function productAnyReadPermissions(product: string): PermissionCode[] { + if (product === 'hamkar') return ['hamkar:read', 'hamkar.ui:read']; + if (product === 'other') return ['others:read']; + return ['khanegi:read', 'khanegi.ui:read', 'khanegi.settings:read']; +} + +export const ANY_TENANT_READ: PermissionCode[] = [ + 'hamkar:read', + 'hamkar.ui:read', + 'khanegi:read', + 'khanegi.ui:read', + 'khanegi.settings:read', + 'others:read', +]; diff --git a/src/program-settings/program-settings.controller.ts b/src/program-settings/program-settings.controller.ts index 5021282..f4d08bf 100644 --- a/src/program-settings/program-settings.controller.ts +++ b/src/program-settings/program-settings.controller.ts @@ -13,13 +13,13 @@ export class ProgramSettingsController { constructor(private readonly programSettings: ProgramSettingsService) {} @Get() - @RequirePermissions('khanegi:read') + @RequirePermissions('khanegi.settings:read') get(@Param('tenantId') tenantId: string) { return this.programSettings.get(tenantId); } @Patch() - @RequirePermissions('khanegi:write') + @RequirePermissions('khanegi.settings:write') update( @Param('tenantId') tenantId: string, @Body() body: UpdateProgramSettingsDto, @@ -29,7 +29,7 @@ export class ProgramSettingsController { } @Post('import') - @RequirePermissions('khanegi:write') + @RequirePermissions('khanegi.settings:write') import( @Param('tenantId') tenantId: string, @Body() body: ImportProgramSettingsDto, @@ -52,7 +52,7 @@ export class ProgramSettingsController { * در سوپرادمین می‌مانند؛ بقیه از جدول settings همین tenant حذف می‌شوند. */ @Post('purge-non-owned') - @RequirePermissions('khanegi:write') + @RequirePermissions('khanegi.settings:write') purgeNonOwned(@Param('tenantId') tenantId: string, @CurrentUser() user: { id: string }) { return this.programSettings.purgeNonOwnedFromTenant(tenantId, user.id); } diff --git a/src/program-settings/setting-templates.controller.ts b/src/program-settings/setting-templates.controller.ts index 1911af8..e27927c 100644 --- a/src/program-settings/setting-templates.controller.ts +++ b/src/program-settings/setting-templates.controller.ts @@ -12,19 +12,19 @@ export class SettingTemplatesController { constructor(private readonly programSettings: ProgramSettingsService) {} @Get() - @RequirePermissions('khanegi:read') + @RequirePermissions('khanegi.settings:read') list() { return this.programSettings.listTemplates(); } @Post('keys') - @RequirePermissions('khanegi:write') + @RequirePermissions('khanegi.settings:write') addKeys(@Body() body: AddSettingKeysDto, @CurrentUser() user: { id: string }) { return this.programSettings.addKeys(body.items || [], user.id); } @Delete('keys') - @RequirePermissions('khanegi:write') + @RequirePermissions('khanegi.settings:write') removeKey( @Query('key') keyQuery: string | undefined, @Body() body: DeleteSettingKeyDto | undefined, @@ -35,7 +35,7 @@ export class SettingTemplatesController { } @Post('seed') - @RequirePermissions('khanegi:write') + @RequirePermissions('khanegi.settings:write') async seed() { const seeded = await this.programSettings.ensureTemplatesSeeded(); const purged = await this.programSettings.purgeEditableFromSuperAdmin(); @@ -43,7 +43,7 @@ export class SettingTemplatesController { } @Post('purge-editable') - @RequirePermissions('khanegi:write') + @RequirePermissions('khanegi.settings:write') purgeEditable() { return this.programSettings.purgeEditableFromSuperAdmin(); } diff --git a/src/tenants/tenants.controller.ts b/src/tenants/tenants.controller.ts index 59320d7..ee68027 100644 --- a/src/tenants/tenants.controller.ts +++ b/src/tenants/tenants.controller.ts @@ -23,8 +23,12 @@ import { PermissionsGuard } from '../auth/permissions.guard'; import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { + ANY_TENANT_READ, + productAnyReadPermissions, productReadPermission, + productUiWritePermission, productWritePermission, + userHasAnyPermission, userHasPermission, } from '../auth/permissions'; import { @@ -38,6 +42,15 @@ import { TenantsService } from './tenants.service'; const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i; +/** Fields edited from the appearance pages; guarded by `.ui:write`. */ +const APPEARANCE_FIELDS = [ + 'appUiConfig', + 'uiVariant', + 'primaryColor', + 'secondaryColor', + 'backgroundColor', +] as const; + type AuthUser = { id: string; role?: string; permissions?: unknown }; @Controller('tenants') @@ -49,18 +62,18 @@ export class TenantsController { ) {} @Get() - @RequirePermissions('hamkar:read', 'khanegi:read', 'others:read', { mode: 'any' }) + @RequirePermissions(...ANY_TENANT_READ, { mode: 'any' }) async findAll( @Query('product') product?: ProductType, @CurrentUser() user?: AuthUser, ) { if (product) { - this.assertProductAccess(user, product, 'read'); + this.assertAnyProductRead(user, product); return this.tenantsService.findAll(product); } const rows = await this.tenantsService.findAll(); return rows.filter((row) => - userHasPermission(user || {}, productReadPermission(row.product)), + userHasAnyPermission(user || {}, productAnyReadPermissions(row.product)), ); } @@ -103,10 +116,10 @@ export class TenantsController { } @Get(':id') - @RequirePermissions('hamkar:read', 'khanegi:read', 'others:read', { mode: 'any' }) + @RequirePermissions(...ANY_TENANT_READ, { mode: 'any' }) async findOne(@Param('id') id: string, @CurrentUser() user: AuthUser) { const tenant = await this.tenantsService.findOne(id); - this.assertProductAccess(user, tenant.product, 'read'); + this.assertAnyProductRead(user, tenant.product); return tenant; } @@ -118,14 +131,48 @@ export class TenantsController { } @Patch(':id') - @RequirePermissions('hamkar:write', 'khanegi:write', 'others:write', { mode: 'any' }) + @RequirePermissions( + 'hamkar:write', + 'hamkar.ui:write', + 'khanegi:write', + 'khanegi.ui:write', + 'others:write', + { mode: 'any' }, + ) async update( @Param('id') id: string, @Body() dto: UpdateTenantDto, @CurrentUser() user: AuthUser, ) { const tenant = await this.tenantsService.findOne(id); - this.assertProductAccess(user, tenant.product, 'write'); + const before = tenant as unknown as Record; + const body = dto as Record; + + const appearanceChanged = APPEARANCE_FIELDS.some((key) => { + if (body[key] === undefined) return false; + return JSON.stringify(body[key]) !== JSON.stringify(before[key] ?? null); + }); + const touchesIdentity = Object.keys(body).some( + (key) => + body[key] !== undefined && + !(APPEARANCE_FIELDS as readonly string[]).includes(key), + ); + + if (touchesIdentity) { + this.assertProductAccess(user, tenant.product, 'write'); + } + if (appearanceChanged) { + const uiCode = productUiWritePermission(tenant.product); + const allowed = uiCode + ? userHasPermission(user || {}, uiCode) + : userHasPermission(user || {}, productWritePermission(tenant.product)); + if (!allowed) { + throw new ForbiddenException('شما به ظاهر این محصول دسترسی ندارید'); + } + } + if (!touchesIdentity && !appearanceChanged) { + this.assertAnyProductRead(user, tenant.product); + } return this.tenantsService.update(id, dto, user.id); } @@ -150,4 +197,10 @@ export class TenantsController { throw new ForbiddenException('شما به این محصول دسترسی ندارید'); } } + + private assertAnyProductRead(user: AuthUser | undefined, product: string) { + if (!userHasAnyPermission(user || {}, productAnyReadPermissions(product))) { + throw new ForbiddenException('شما به این محصول دسترسی ندارید'); + } + } }