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
+465
View File
@@ -0,0 +1,465 @@
import type {
DataAccessPolicy,
DataAccessRule,
DataAccessSection,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_LEGACY_SCOPE,
DATA_ACCESS_SECTIONS,
migrateVisibilityToSectionAccess,
parseDataAccessPolicy,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import type { Actor } from './record-scope';
function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
export type SectionAccessContext = {
section: SectionDataAccess;
source: 'role' | 'override' | 'default';
};
function ownerIdList(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): string[] {
const ownerIds = new Set<string>();
if (!rule.owners?.length) return [];
if (rule.owners.includes('self')) ownerIds.add(ctx.userId);
if (rule.owners.includes('team')) {
const peers = ctx.teamPeerIds?.length ? ctx.teamPeerIds : [ctx.userId];
for (const id of peers) ownerIds.add(id);
}
return [...ownerIds];
}
/** Resolve یک بخش: override کاربر بر نقش؛ وگرنه مهاجرت از visibility قدیمی. */
export function resolveSectionAccess(
sectionKey: DataAccessSection,
input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
},
): SectionAccessContext {
if (elevated(input.role)) {
return { section: { mode: 'all', rules: [] }, source: 'default' };
}
const override = parseDataAccessPolicy(input.override);
if (override[sectionKey]) {
return { section: override[sectionKey]!, source: 'override' };
}
const rolePolicy = parseDataAccessPolicy(input.rolePolicy);
if (rolePolicy[sectionKey]) {
return { section: rolePolicy[sectionKey]!, source: 'role' };
}
if (input.legacyVisibility != null || input.legacyScopes) {
return {
section: migrateVisibilityToSectionAccess(
input.legacyVisibility,
input.legacyScopes,
DATA_ACCESS_LEGACY_SCOPE[sectionKey],
),
source: 'default',
};
}
return { section: { mode: 'all', rules: [] }, source: 'default' };
}
/** @deprecated */
export function resolveCustomersAccess(input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
}): SectionAccessContext {
return resolveSectionAccess('customers', input);
}
export function resolveAllSectionAccess(input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
}): DataAccessPolicy {
const out: DataAccessPolicy = {};
for (const key of DATA_ACCESS_SECTIONS) {
out[key] = resolveSectionAccess(key, input).section;
}
return out;
}
export function buildContactRuleWhere(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
if (rule.owners?.length) {
if (!ids.length) return null;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
}
if (rule.sources?.length) {
and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } });
}
if (!and.length) return null;
return and.length === 1 ? and[0]! : { AND: and };
}
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
if (!parts.length) return { id: '__no_access__' } as T & { id: string };
return parts.length === 1 ? parts[0]! : { OR: parts };
}
export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput {
if (elevated(actor.role)) return {};
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.PersonWhereInput[] = [];
for (const rule of section.rules ?? []) {
const w = buildContactRuleWhere(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (w) parts.push(w);
}
return orParts(parts);
}
export function customersCompanyWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.CompanyWhereInput {
if (elevated(actor.role)) return {};
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.CompanyWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
}
if (rule.sources?.length) {
and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (
rule.moods?.length &&
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function leadsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'leadsAccess' | 'teamPeerIds'>,
): Prisma.LeadWhereInput {
if (elevated(actor.role)) return {};
const section = actor.leadsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (rule.sources?.length) {
and.push({
OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const },
})),
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function dealsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'salesAccess' | 'teamPeerIds'>,
): Prisma.DealWhereInput {
if (elevated(actor.role)) return {};
const section = actor.salesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function activitiesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'activitiesAccess' | 'teamPeerIds'>,
): Prisma.ActivityWhereInput {
if (elevated(actor.role)) return {};
const section = actor.activitiesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
});
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function notesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'notesAccess' | 'teamPeerIds'>,
): Prisma.NoteWhereInput {
const personalGate: Prisma.NoteWhereInput = {
OR: [
{ visibility: { not: 'personal' } },
{ visibility: 'personal', authorId: actor.userId },
],
};
if (elevated(actor.role)) return personalGate;
const section = actor.notesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
});
}
if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return {
AND: [personalGate, orParts(parts)],
};
}
export function ticketsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'ticketsAccess' | 'teamPeerIds'>,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
const section = actor.ticketsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
});
}
if (rule.sources?.length) {
and.push({
status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
},
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function projectsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'projectsAccess' | 'teamPeerIds'>,
): Prisma.ProjectCardWhereInput {
if (elevated(actor.role)) return {};
const section = actor.projectsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function routinesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
): Prisma.RoutineTaskWhereInput {
if (elevated(actor.role)) return {};
const section = actor.routinesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
/** اتحاد شناسهٔ owners در قوانین؛ null = بدون محدودیت مالکی (یا mode=all) */
export function sectionOwnerIdUnion(
section: SectionDataAccess | null | undefined,
ctx: { userId: string; teamPeerIds: string[] | null },
): string[] | null {
if (!sectionHasDataAccessLimits(section)) return null;
const ids = new Set<string>();
let sawOwnerRule = false;
let sawNonOwnerRule = false;
for (const rule of section?.rules ?? []) {
if (rule.owners?.length) {
sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
rule.typeIds?.length) {
sawNonOwnerRule = true;
}
}
if (sawNonOwnerRule && !sawOwnerRule) return null;
if (!sawOwnerRule) return null;
return [...ids];
}
export function sectionNeedsTeamPeers(section: SectionDataAccess | null | undefined): boolean {
if (!sectionHasDataAccessLimits(section)) return false;
return (section?.rules ?? []).some((r) => r.owners?.includes('team'));
}
export function sanitizeDataAccessPolicyInput(raw: unknown): DataAccessPolicy {
return parseDataAccessPolicy(raw);
}
+59 -11
View File
@@ -1,6 +1,20 @@
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
import type {
DataScope,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import {
activitiesDeepWhere,
dealsDeepWhere,
notesDeepWhere,
projectsDeepWhere,
routinesDeepWhere,
ticketsDeepWhere,
} from './data-access';
export type ScopeVisibility = {
dataVisibility: DataVisibility;
@@ -20,6 +34,17 @@ export type Actor = {
visibleUserIds?: string[] | null;
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
/** عمق دادهٔ بخش‌ها (resolve شده از نقش + override) */
customersAccess?: SectionDataAccess;
leadsAccess?: SectionDataAccess;
salesAccess?: SectionDataAccess;
activitiesAccess?: SectionDataAccess;
notesAccess?: SectionDataAccess;
routinesAccess?: SectionDataAccess;
projectsAccess?: SectionDataAccess;
ticketsAccess?: SectionDataAccess;
/** هم‌تیمی‌ها برای قوانین owners:team (شامل خود کاربر) */
teamPeerIds?: string[] | null;
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
priorLens?: 'off' | 'current' | 'archive';
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */
@@ -93,10 +118,13 @@ export function ownerWhere(
return { ownerId: { in: ids } };
}
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
export function dealAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'salesAccess' | 'teamPeerIds'>,
): Prisma.DealWhereInput {
if (actor.salesAccess !== undefined) {
return dealsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'deals');
if (!ids) return {};
return {
@@ -107,10 +135,13 @@ export function dealAccessWhere(
};
}
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
export function projectCardAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'projectsAccess' | 'teamPeerIds'>,
): Prisma.ProjectCardWhereInput {
if (actor.projectsAccess !== undefined) {
return projectsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'projects');
if (!ids) return {};
return {
@@ -135,10 +166,13 @@ export function assigneeWhere(
};
}
/** دسترسی به فعالیت: مسئولان یا شرکت‌کنندگان (فیلد شرکت‌کنندگان) در محدوده دید */
/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
export function activityAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'activitiesAccess' | 'teamPeerIds'>,
): Prisma.ActivityWhereInput {
if (actor.activitiesAccess !== undefined) {
return activitiesDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'activities');
if (!ids) return {};
return {
@@ -150,10 +184,13 @@ export function activityAccessWhere(
};
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */
/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
export function noteAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'notesAccess' | 'teamPeerIds'>,
): Prisma.NoteWhereInput {
if (actor.notesAccess !== undefined) {
return notesDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'notes');
const personalGate: Prisma.NoteWhereInput = {
OR: [
@@ -195,10 +232,13 @@ export function resolveOwnerId(
return actor.userId;
}
/** تیکت: مسئول یا ثبت‌کننده در محدوده دید */
/** تیکت: عمق داده یا مدل قدیمی */
export function ticketAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'ticketsAccess' | 'teamPeerIds'>,
): Prisma.TicketWhereInput {
if (actor.ticketsAccess !== undefined) {
return ticketsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'tickets');
if (!ids) return {};
return {
@@ -206,6 +246,14 @@ export function ticketAccessWhere(
};
}
/** کار روتین: عمق داده (وقتی JWT ست کرده) */
export function routineTaskAccessWhere(
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
): Prisma.RoutineTaskWhereInput {
if (actor.routinesAccess === undefined) return {};
return routinesDeepWhere(actor);
}
export function canReassignOwner(role: string | null | undefined): boolean {
return elevated(role);
}