feat: granular hamkar/khanegi permissions (manage / settings / appearance)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||||
+44
-2
@@ -10,8 +10,14 @@ export const PERMISSIONS = [
|
|||||||
'support:write',
|
'support:write',
|
||||||
'hamkar:read',
|
'hamkar:read',
|
||||||
'hamkar:write',
|
'hamkar:write',
|
||||||
|
'hamkar.ui:read',
|
||||||
|
'hamkar.ui:write',
|
||||||
'khanegi:read',
|
'khanegi:read',
|
||||||
'khanegi:write',
|
'khanegi:write',
|
||||||
|
'khanegi.settings:read',
|
||||||
|
'khanegi.settings:write',
|
||||||
|
'khanegi.ui:read',
|
||||||
|
'khanegi.ui:write',
|
||||||
'others:read',
|
'others:read',
|
||||||
'others:write',
|
'others:write',
|
||||||
'admins:read',
|
'admins:read',
|
||||||
@@ -36,8 +42,16 @@ export const PERMISSION_MODULES: PermissionModule[] = [
|
|||||||
{ key: 'servers', label: 'سرور', read: 'servers:read', write: 'servers:write' },
|
{ key: 'servers', label: 'سرور', read: 'servers:read', write: 'servers:write' },
|
||||||
{ key: 'plugins', label: 'افزونه', read: 'plugins:read', write: 'plugins:write' },
|
{ key: 'plugins', label: 'افزونه', read: 'plugins:read', write: 'plugins:write' },
|
||||||
{ key: 'support', label: 'پشتیبانی', read: 'support:read', write: 'support:write' },
|
{ key: 'support', label: 'پشتیبانی', read: 'support:read', write: 'support:write' },
|
||||||
{ key: 'hamkar', label: 'همکار', read: 'hamkar:read', write: 'hamkar:write' },
|
{ key: 'hamkar', label: 'همکار — مدیریت', read: 'hamkar:read', write: 'hamkar:write' },
|
||||||
{ key: 'khanegi', label: 'خانگی', read: 'khanegi:read', write: 'khanegi: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: 'others', label: 'سایتهای دیگر', read: 'others:read', write: 'others:write' },
|
||||||
{ key: 'admins', label: 'مدیران', read: 'admins:read', write: 'admins:write' },
|
{ key: 'admins', label: 'مدیران', read: 'admins:read', write: 'admins:write' },
|
||||||
{ key: 'audit', label: 'لاگ', read: 'audit:read' },
|
{ key: 'audit', label: 'لاگ', read: 'audit:read' },
|
||||||
@@ -107,3 +121,31 @@ export function productWritePermission(product: string): PermissionCode {
|
|||||||
if (product === 'other') return 'others:write';
|
if (product === 'other') return 'others:write';
|
||||||
return 'khanegi: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',
|
||||||
|
];
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ export class ProgramSettingsController {
|
|||||||
constructor(private readonly programSettings: ProgramSettingsService) {}
|
constructor(private readonly programSettings: ProgramSettingsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('khanegi:read')
|
@RequirePermissions('khanegi.settings:read')
|
||||||
get(@Param('tenantId') tenantId: string) {
|
get(@Param('tenantId') tenantId: string) {
|
||||||
return this.programSettings.get(tenantId);
|
return this.programSettings.get(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch()
|
@Patch()
|
||||||
@RequirePermissions('khanegi:write')
|
@RequirePermissions('khanegi.settings:write')
|
||||||
update(
|
update(
|
||||||
@Param('tenantId') tenantId: string,
|
@Param('tenantId') tenantId: string,
|
||||||
@Body() body: UpdateProgramSettingsDto,
|
@Body() body: UpdateProgramSettingsDto,
|
||||||
@@ -29,7 +29,7 @@ export class ProgramSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePermissions('khanegi:write')
|
@RequirePermissions('khanegi.settings:write')
|
||||||
import(
|
import(
|
||||||
@Param('tenantId') tenantId: string,
|
@Param('tenantId') tenantId: string,
|
||||||
@Body() body: ImportProgramSettingsDto,
|
@Body() body: ImportProgramSettingsDto,
|
||||||
@@ -52,7 +52,7 @@ export class ProgramSettingsController {
|
|||||||
* در سوپرادمین میمانند؛ بقیه از جدول settings همین tenant حذف میشوند.
|
* در سوپرادمین میمانند؛ بقیه از جدول settings همین tenant حذف میشوند.
|
||||||
*/
|
*/
|
||||||
@Post('purge-non-owned')
|
@Post('purge-non-owned')
|
||||||
@RequirePermissions('khanegi:write')
|
@RequirePermissions('khanegi.settings:write')
|
||||||
purgeNonOwned(@Param('tenantId') tenantId: string, @CurrentUser() user: { id: string }) {
|
purgeNonOwned(@Param('tenantId') tenantId: string, @CurrentUser() user: { id: string }) {
|
||||||
return this.programSettings.purgeNonOwnedFromTenant(tenantId, user.id);
|
return this.programSettings.purgeNonOwnedFromTenant(tenantId, user.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ export class SettingTemplatesController {
|
|||||||
constructor(private readonly programSettings: ProgramSettingsService) {}
|
constructor(private readonly programSettings: ProgramSettingsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('khanegi:read')
|
@RequirePermissions('khanegi.settings:read')
|
||||||
list() {
|
list() {
|
||||||
return this.programSettings.listTemplates();
|
return this.programSettings.listTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('keys')
|
@Post('keys')
|
||||||
@RequirePermissions('khanegi:write')
|
@RequirePermissions('khanegi.settings:write')
|
||||||
addKeys(@Body() body: AddSettingKeysDto, @CurrentUser() user: { id: string }) {
|
addKeys(@Body() body: AddSettingKeysDto, @CurrentUser() user: { id: string }) {
|
||||||
return this.programSettings.addKeys(body.items || [], user.id);
|
return this.programSettings.addKeys(body.items || [], user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('keys')
|
@Delete('keys')
|
||||||
@RequirePermissions('khanegi:write')
|
@RequirePermissions('khanegi.settings:write')
|
||||||
removeKey(
|
removeKey(
|
||||||
@Query('key') keyQuery: string | undefined,
|
@Query('key') keyQuery: string | undefined,
|
||||||
@Body() body: DeleteSettingKeyDto | undefined,
|
@Body() body: DeleteSettingKeyDto | undefined,
|
||||||
@@ -35,7 +35,7 @@ export class SettingTemplatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('seed')
|
@Post('seed')
|
||||||
@RequirePermissions('khanegi:write')
|
@RequirePermissions('khanegi.settings:write')
|
||||||
async seed() {
|
async seed() {
|
||||||
const seeded = await this.programSettings.ensureTemplatesSeeded();
|
const seeded = await this.programSettings.ensureTemplatesSeeded();
|
||||||
const purged = await this.programSettings.purgeEditableFromSuperAdmin();
|
const purged = await this.programSettings.purgeEditableFromSuperAdmin();
|
||||||
@@ -43,7 +43,7 @@ export class SettingTemplatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('purge-editable')
|
@Post('purge-editable')
|
||||||
@RequirePermissions('khanegi:write')
|
@RequirePermissions('khanegi.settings:write')
|
||||||
purgeEditable() {
|
purgeEditable() {
|
||||||
return this.programSettings.purgeEditableFromSuperAdmin();
|
return this.programSettings.purgeEditableFromSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ import { PermissionsGuard } from '../auth/permissions.guard';
|
|||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import {
|
import {
|
||||||
|
ANY_TENANT_READ,
|
||||||
|
productAnyReadPermissions,
|
||||||
productReadPermission,
|
productReadPermission,
|
||||||
|
productUiWritePermission,
|
||||||
productWritePermission,
|
productWritePermission,
|
||||||
|
userHasAnyPermission,
|
||||||
userHasPermission,
|
userHasPermission,
|
||||||
} from '../auth/permissions';
|
} from '../auth/permissions';
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +42,15 @@ import { TenantsService } from './tenants.service';
|
|||||||
|
|
||||||
const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i;
|
const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i;
|
||||||
|
|
||||||
|
/** Fields edited from the appearance pages; guarded by `<product>.ui:write`. */
|
||||||
|
const APPEARANCE_FIELDS = [
|
||||||
|
'appUiConfig',
|
||||||
|
'uiVariant',
|
||||||
|
'primaryColor',
|
||||||
|
'secondaryColor',
|
||||||
|
'backgroundColor',
|
||||||
|
] as const;
|
||||||
|
|
||||||
type AuthUser = { id: string; role?: string; permissions?: unknown };
|
type AuthUser = { id: string; role?: string; permissions?: unknown };
|
||||||
|
|
||||||
@Controller('tenants')
|
@Controller('tenants')
|
||||||
@@ -49,18 +62,18 @@ export class TenantsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('hamkar:read', 'khanegi:read', 'others:read', { mode: 'any' })
|
@RequirePermissions(...ANY_TENANT_READ, { mode: 'any' })
|
||||||
async findAll(
|
async findAll(
|
||||||
@Query('product') product?: ProductType,
|
@Query('product') product?: ProductType,
|
||||||
@CurrentUser() user?: AuthUser,
|
@CurrentUser() user?: AuthUser,
|
||||||
) {
|
) {
|
||||||
if (product) {
|
if (product) {
|
||||||
this.assertProductAccess(user, product, 'read');
|
this.assertAnyProductRead(user, product);
|
||||||
return this.tenantsService.findAll(product);
|
return this.tenantsService.findAll(product);
|
||||||
}
|
}
|
||||||
const rows = await this.tenantsService.findAll();
|
const rows = await this.tenantsService.findAll();
|
||||||
return rows.filter((row) =>
|
return rows.filter((row) =>
|
||||||
userHasPermission(user || {}, productReadPermission(row.product)),
|
userHasAnyPermission(user || {}, productAnyReadPermissions(row.product)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +116,10 @@ export class TenantsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@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) {
|
async findOne(@Param('id') id: string, @CurrentUser() user: AuthUser) {
|
||||||
const tenant = await this.tenantsService.findOne(id);
|
const tenant = await this.tenantsService.findOne(id);
|
||||||
this.assertProductAccess(user, tenant.product, 'read');
|
this.assertAnyProductRead(user, tenant.product);
|
||||||
return tenant;
|
return tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,14 +131,48 @@ export class TenantsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@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(
|
async update(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateTenantDto,
|
@Body() dto: UpdateTenantDto,
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
) {
|
) {
|
||||||
const tenant = await this.tenantsService.findOne(id);
|
const tenant = await this.tenantsService.findOne(id);
|
||||||
this.assertProductAccess(user, tenant.product, 'write');
|
const before = tenant as unknown as Record<string, unknown>;
|
||||||
|
const body = dto as Record<string, unknown>;
|
||||||
|
|
||||||
|
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);
|
return this.tenantsService.update(id, dto, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,4 +197,10 @@ export class TenantsController {
|
|||||||
throw new ForbiddenException('شما به این محصول دسترسی ندارید');
|
throw new ForbiddenException('شما به این محصول دسترسی ندارید');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertAnyProductRead(user: AuthUser | undefined, product: string) {
|
||||||
|
if (!userHasAnyPermission(user || {}, productAnyReadPermissions(product))) {
|
||||||
|
throw new ForbiddenException('شما به این محصول دسترسی ندارید');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user