feat(acl): deep data access by section for roles and users

Role default + user override with OR rules; covers customers, leads, sales, activities, notes, routines, projects, and tickets.
This commit is contained in:
2026-10-22 11:53:26 +03:30
parent cb5944e767
commit 09a60b3b5a
25 changed files with 1997 additions and 122 deletions
+11
View File
@@ -112,3 +112,14 @@ export class UpdateUserVisibilityDto {
@IsObject()
scopes?: Record<string, 'own' | 'team' | 'org' | null>;
}
export class UpdateUserDataAccessDto {
/**
* null = پاک کردن override و ارث از نقش.
* آبجکت = جایگزینی کامل override.
*/
@ValidateIf((_, v) => v !== null)
@IsOptional()
@IsObject()
dataAccessOverride?: Record<string, unknown> | null;
}
+16
View File
@@ -20,6 +20,7 @@ import {
UpdateLogoutTimeDto,
UpdateUserRoleDto,
UpdateUserStatusDto,
UpdateUserDataAccessDto,
UpdateUserVisibilityDto,
} from './dto';
import { UsersService } from './users.service';
@@ -143,6 +144,21 @@ export class UsersController {
);
}
@Patch(':id/data-access')
@Roles('owner', 'admin')
updateDataAccess(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserDataAccessDto,
) {
return this.usersService.updateDataAccess(
user.organizationId,
user.role,
id,
dto,
);
}
@Patch(':id/role')
@Roles('owner', 'admin')
updateRole(
+46 -2
View File
@@ -14,7 +14,8 @@ import type {
UpdateUserVisibilityRequest,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES } from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
import { resolveAllSectionAccess } from '../common/data-access';
import { parseVisibilityScopes } from '../common/record-scope';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
@@ -44,11 +45,12 @@ type MembershipRow = {
commissionPercent: { toString(): string } | number | null;
dataVisibility?: string | null;
dataVisibilityScopes?: unknown;
dataAccessOverride?: unknown;
priorArchiveEnabled?: boolean;
logoutTime?: string | null;
blockLoginAfterLogout?: boolean;
createdAt: Date;
orgRole: { id: string; name: string; color: string } | null;
orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | null;
user: {
id: string;
email: string;
@@ -360,6 +362,31 @@ export class UsersService {
return this.dtoOf(organizationId, targetId);
}
async updateDataAccess(
organizationId: string,
actorRole: UserRole,
targetId: string,
dto: { dataAccessOverride?: Record<string, unknown> | null },
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
}
if (dto.dataAccessOverride === undefined) {
throw new BadRequestException('تغییری ارسال نشده است');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
}
const next =
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
await this.prisma.membership.update({
where: { id: target.id },
data: { dataAccessOverride: next },
});
return this.dtoOf(organizationId, targetId);
}
async updateRole(
organizationId: string,
actor: { userId: string; role: UserRole },
@@ -541,6 +568,23 @@ export class UsersService {
row.role === 'owner' || row.role === 'admin'
? {}
: parseVisibilityScopes(row.dataVisibilityScopes),
dataAccessOverride:
row.role === 'owner' || row.role === 'admin'
? {}
: parseDataAccessPolicy(row.dataAccessOverride),
...(() => {
const effectiveDataAccess = resolveAllSectionAccess({
role: row.role,
rolePolicy: row.orgRole?.dataAccessPolicy,
override: row.dataAccessOverride,
legacyVisibility: row.dataVisibility,
legacyScopes: parseVisibilityScopes(row.dataVisibilityScopes),
});
return {
effectiveDataAccess,
effectiveCustomersAccess: effectiveDataAccess.customers,
};
})(),
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
blockLoginAfterLogout: