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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user