Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/fileVisibility.server.ts
T

605 lines
22 KiB
TypeScript

import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import type { Prisma } from "@hivegrid/db/prisma-browser";
import type { TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { projectReadScopeWhere, type ProjectVisibilityContext } from "./projects/projectService.server";
import type { FileEntityType } from "./fileService.server";
export type FileVisibilityContext = ProjectVisibilityContext & {
/** The user's permission names for this tenant (from getPermissionsByUser). */
permissions: readonly string[];
};
export type FileVisibilityQueryState = {
projectContext?: Promise<ProjectVisibilityContext>;
};
export function createFileVisibilityQueryState(): FileVisibilityQueryState {
return {};
}
const PROJECT_READ_PERMISSIONS = ["projects.read_all", "projects.read_own", "projects.read_own_pl", "projects.read_own_member"] as const;
const CRM_READ_PERMISSIONS = ["crm.read_all", "crm.read_own"] as const;
const LEAD_READ_PERMISSIONS = ["leads.read_all", "leads.read_own"] as const;
const TASK_READ_PERMISSIONS = ["tasks.read_all", "tasks.read_own"] as const;
const BOOKING_READ_PERMISSIONS = ["bookings.read_all", "bookings.read_own", "bookings.write", "bookings.admin"] as const;
const SIGNATURE_READ_PERMISSIONS = ["signature.read", "signature.send", "signature.admin"] as const;
const FILE_READ_PERMISSIONS = ["files.read_own", "files.read_all"] as const;
const CHAT_READ_PERMISSIONS = ["chat.read_own_channels", "chat.read_all_channels"] as const;
const HR_READ_ALL_PERMISSIONS = ["hr.read_all", "hr.admin"] as const;
const FILE_ENTITY_TYPES = new Set<FileEntityType>([
"contact",
"company",
"lead",
"deal",
"project",
"user",
"form_submission",
"order",
"task",
"appointment",
"signature_document",
"email_message",
"chat_message",
]);
function hasPermission(ctx: FileVisibilityContext, permission: string): boolean {
return ctx.permissions.includes(permission);
}
function hasAnyPermission(ctx: FileVisibilityContext, permissions: readonly string[]): boolean {
return permissions.some((permission) => hasPermission(ctx, permission));
}
function canReadAllHrFiles(ctx: FileVisibilityContext): boolean {
return hasAnyPermission(ctx, HR_READ_ALL_PERMISSIONS);
}
function targetNotFound(entityType: FileEntityType, entityId: string): CatalogError {
return new CatalogError(foundationErrors.notFound, { entityType, entityId });
}
async function hydrateProjectVisibilityContextWithTx(tx: TenantRlsClient, tenantId: string, ctx: FileVisibilityContext): Promise<ProjectVisibilityContext> {
const [roleIds, groupIds] = await Promise.all([
ctx.roleIds === undefined
? tx.userRole.findMany({ where: { tenantId, userId: ctx.userId }, select: { roleId: true } }).then((rows) => rows.map(({ roleId }) => roleId))
: Promise.resolve([...ctx.roleIds]),
ctx.groupIds === undefined
? tx.groupUser
.findMany({ where: { userId: ctx.userId, group: { tenantId } }, select: { groupId: true } })
.then((rows) => rows.map(({ groupId }) => groupId))
: Promise.resolve([...ctx.groupIds]),
]);
return { ...ctx, roleIds, groupIds };
}
function getProjectVisibilityContextWithTx(
tx: TenantRlsClient,
tenantId: string,
ctx: FileVisibilityContext,
queryState?: FileVisibilityQueryState
): Promise<ProjectVisibilityContext> {
if (!queryState) return hydrateProjectVisibilityContextWithTx(tx, tenantId, ctx);
queryState.projectContext ??= hydrateProjectVisibilityContextWithTx(tx, tenantId, ctx);
return queryState.projectContext;
}
type FileLinkTarget = {
entityType: string;
entityId: string;
};
function isFileEntityType(value: string): value is FileEntityType {
return FILE_ENTITY_TYPES.has(value as FileEntityType);
}
/**
* Filter an ordered set of polymorphic file links through the same target
* scopes used for mutations. Targets are grouped so each entity type needs at
* most one query; unsupported runtime types fail closed without a query.
*/
export async function filterVisibleFileLinksWithTx<T extends FileLinkTarget>(
tx: TenantRlsClient,
tenantId: string,
links: readonly T[],
ctx: FileVisibilityContext,
queryState?: FileVisibilityQueryState
): Promise<T[]> {
if (links.length === 0) return [];
const candidateIds = new Map<FileEntityType, Set<string>>();
for (const link of links) {
if (!isFileEntityType(link.entityType)) continue;
const ids = candidateIds.get(link.entityType) ?? new Set<string>();
ids.add(link.entityId);
candidateIds.set(link.entityType, ids);
}
const idsFor = (entityType: FileEntityType): string[] => [...(candidateIds.get(entityType) ?? [])];
const visibleIds = new Map<FileEntityType, Set<string>>();
const markVisible = (entityType: FileEntityType, ids: readonly string[]): void => {
if (ids.length === 0) return;
visibleIds.set(entityType, new Set(ids));
};
const directProjectIds = [...new Set([...idsFor("project"), ...idsFor("deal"), ...idsFor("order")])];
const signatureDocumentIds = idsFor("signature_document");
const chatMessageIds = idsFor("chat_message");
const canReadProjects = hasAnyPermission(ctx, PROJECT_READ_PERMISSIONS);
const canReadSignatures = hasAnyPermission(ctx, SIGNATURE_READ_PERMISSIONS);
const needsProjectContext = canReadProjects && (directProjectIds.length > 0 || (canReadSignatures && signatureDocumentIds.length > 0));
const projectContextPromise = needsProjectContext ? getProjectVisibilityContextWithTx(tx, tenantId, ctx, queryState) : undefined;
const independentQueries: Promise<void>[] = [];
if (chatMessageIds.length > 0 && hasAnyPermission(ctx, CHAT_READ_PERMISSIONS)) {
independentQueries.push(
tx.teamChatMessage
.findMany({
where: {
id: { in: chatMessageIds },
tenantId,
chat: { tenantId, members: { some: { tenantId, userId: ctx.userId, leftAt: null } } },
},
select: { id: true },
})
.then((rows) =>
markVisible(
"chat_message",
rows.map(({ id }) => id)
)
)
);
}
for (const [entityType, kontaktTyp] of [
["contact", "privatperson"],
["company", "organisation"],
] as const) {
const ids = idsFor(entityType);
if (ids.length === 0 || !hasAnyPermission(ctx, CRM_READ_PERMISSIONS)) continue;
independentQueries.push(
tx.kontakt
.findMany({
where: {
id: { in: ids },
tenantId,
archivedAt: null,
kontaktTyp,
...(hasPermission(ctx, "crm.read_all") ? {} : { zustaendigerUserId: ctx.userId }),
},
select: { id: true },
})
.then((rows) =>
markVisible(
entityType,
rows.map(({ id }) => id)
)
)
);
}
const leadIds = idsFor("lead");
if (leadIds.length > 0 && hasAnyPermission(ctx, LEAD_READ_PERMISSIONS)) {
independentQueries.push(
tx.inboxLead
.findMany({
where: {
id: { in: leadIds },
tenantId,
archivedAt: null,
...(hasPermission(ctx, "leads.read_all") ? {} : { zustaendigerUserId: ctx.userId }),
},
select: { id: true },
})
.then((rows) =>
markVisible(
"lead",
rows.map(({ id }) => id)
)
)
);
}
const userIds = idsFor("user");
const readableUserIds = canReadAllHrFiles(ctx) ? userIds : userIds.filter((userId) => userId === ctx.userId);
if (readableUserIds.length > 0) {
independentQueries.push(
tx.tenantUser
.findMany({
where: { tenantId, userId: { in: readableUserIds }, status: TenantUserStatus.ACTIVE, user: { active: true } },
select: { userId: true },
})
.then((rows) =>
markVisible(
"user",
rows.map(({ userId }) => userId)
)
)
);
}
const submissionIds = idsFor("form_submission");
if (submissionIds.length > 0 && (hasPermission(ctx, "forms.basic") || hasPermission(ctx, "forms.admin"))) {
independentQueries.push(
tx.formSubmission
.findMany({
where: {
id: { in: submissionIds },
tenantId,
form: { tenantId, ...(hasPermission(ctx, "forms.admin") ? {} : { createdByUserId: ctx.userId }) },
},
select: { id: true },
})
.then((rows) =>
markVisible(
"form_submission",
rows.map(({ id }) => id)
)
)
);
}
const taskIds = idsFor("task");
if (taskIds.length > 0 && hasAnyPermission(ctx, TASK_READ_PERMISSIONS)) {
const groupIdsPromise = hasPermission(ctx, "tasks.read_all")
? Promise.resolve([] as string[])
: ctx.groupIds !== undefined
? Promise.resolve([...ctx.groupIds])
: projectContextPromise
? projectContextPromise.then((projectCtx) => [...(projectCtx.groupIds ?? [])])
: tx.groupUser
.findMany({ where: { userId: ctx.userId, group: { tenantId } }, select: { groupId: true } })
.then((rows) => rows.map(({ groupId }) => groupId));
independentQueries.push(
groupIdsPromise.then(async (groupIds) => {
const rows = await tx.task.findMany({
where: {
id: { in: taskIds },
tenantId,
archivedAt: null,
...(hasPermission(ctx, "tasks.read_all")
? {}
: {
OR: [
{ createdByUserId: ctx.userId },
{ assignedToUserId: ctx.userId },
{ watchers: { some: { tenantId, userId: ctx.userId } } },
...(groupIds.length > 0 ? [{ assignedToGroupId: { in: groupIds } }] : []),
],
}),
},
select: { id: true },
});
markVisible(
"task",
rows.map(({ id }) => id)
);
})
);
}
const appointmentIds = idsFor("appointment");
if (appointmentIds.length > 0 && hasAnyPermission(ctx, BOOKING_READ_PERMISSIONS)) {
independentQueries.push(
tx.appointment
.findMany({
where: {
id: { in: appointmentIds },
tenantId,
...(hasPermission(ctx, "bookings.read_all") || hasPermission(ctx, "bookings.admin") ? {} : { providerUserId: ctx.userId }),
},
select: { id: true },
})
.then((rows) =>
markVisible(
"appointment",
rows.map(({ id }) => id)
)
)
);
}
const signatureDocumentsPromise =
signatureDocumentIds.length > 0 && canReadSignatures && canReadProjects
? tx.projektSignatureDocument.findMany({
where: { id: { in: signatureDocumentIds }, tenantId, archivedAt: null },
select: { id: true, projektId: true },
})
: Promise.resolve([] as Array<{ id: string; projektId: string }>);
const [, signatureDocuments] = await Promise.all([Promise.all(independentQueries), signatureDocumentsPromise]);
const projectIdsToCheck = [...new Set([...directProjectIds, ...signatureDocuments.map(({ projektId }) => projektId)])];
if (projectIdsToCheck.length > 0 && projectContextPromise) {
const projectCtx = await projectContextPromise;
const projects = await tx.projekt.findMany({
where: {
id: { in: projectIdsToCheck },
tenantId,
archivedAt: null,
...projectReadScopeWhere(tenantId, projectCtx),
},
select: { id: true },
});
const visibleProjectIds = new Set(projects.map(({ id }) => id));
for (const entityType of ["project", "deal", "order"] as const) {
markVisible(
entityType,
idsFor(entityType).filter((id) => visibleProjectIds.has(id))
);
}
markVisible(
"signature_document",
signatureDocuments.filter(({ projektId }) => visibleProjectIds.has(projektId)).map(({ id }) => id)
);
}
return links.filter((link) => isFileEntityType(link.entityType) && Boolean(visibleIds.get(link.entityType)?.has(link.entityId)));
}
/**
* Verify that a polymorphic FileEntityLink target both exists in this tenant and
* is readable by the current user. Unknown or not-yet-typed targets fail closed.
*/
export async function assertFileLinkTargetVisibleWithTx(
tx: TenantRlsClient,
tenantId: string,
entityType: FileEntityType,
entityId: string,
ctx: FileVisibilityContext
): Promise<void> {
let target: { id: string } | null = null;
switch (entityType) {
case "contact":
case "company":
if (!hasAnyPermission(ctx, CRM_READ_PERMISSIONS)) throw targetNotFound(entityType, entityId);
target = await tx.kontakt.findFirst({
where: {
id: entityId,
tenantId,
archivedAt: null,
kontaktTyp: entityType === "company" ? "organisation" : "privatperson",
...(hasPermission(ctx, "crm.read_all") ? {} : { zustaendigerUserId: ctx.userId }),
},
select: { id: true },
});
break;
case "lead":
if (!hasAnyPermission(ctx, LEAD_READ_PERMISSIONS)) throw targetNotFound(entityType, entityId);
target = await tx.inboxLead.findFirst({
where: {
id: entityId,
tenantId,
archivedAt: null,
...(hasPermission(ctx, "leads.read_all") ? {} : { zustaendigerUserId: ctx.userId }),
},
select: { id: true },
});
break;
case "project":
case "deal":
case "order": {
if (!hasAnyPermission(ctx, PROJECT_READ_PERMISSIONS)) throw targetNotFound(entityType, entityId);
const projectCtx = await hydrateProjectVisibilityContextWithTx(tx, tenantId, ctx);
target = await tx.projekt.findFirst({
where: { id: entityId, tenantId, archivedAt: null, ...projectReadScopeWhere(tenantId, projectCtx) },
select: { id: true },
});
break;
}
case "user":
if (entityId !== ctx.userId && !canReadAllHrFiles(ctx)) throw targetNotFound(entityType, entityId);
target = await tx.tenantUser.findFirst({
where: { tenantId, userId: entityId, status: TenantUserStatus.ACTIVE, user: { active: true } },
select: { id: true },
});
break;
case "form_submission":
if (!hasPermission(ctx, "forms.basic") && !hasPermission(ctx, "forms.admin")) throw targetNotFound(entityType, entityId);
target = await tx.formSubmission.findFirst({
where: {
id: entityId,
tenantId,
form: { tenantId, ...(hasPermission(ctx, "forms.admin") ? {} : { createdByUserId: ctx.userId }) },
},
select: { id: true },
});
break;
case "task": {
if (!hasAnyPermission(ctx, TASK_READ_PERMISSIONS)) throw targetNotFound(entityType, entityId);
const groupIds =
ctx.groupIds === undefined
? await tx.groupUser
.findMany({ where: { userId: ctx.userId, group: { tenantId } }, select: { groupId: true } })
.then((rows) => rows.map(({ groupId }) => groupId))
: [...ctx.groupIds];
target = await tx.task.findFirst({
where: {
id: entityId,
tenantId,
archivedAt: null,
...(hasPermission(ctx, "tasks.read_all")
? {}
: {
OR: [
{ createdByUserId: ctx.userId },
{ assignedToUserId: ctx.userId },
{ watchers: { some: { tenantId, userId: ctx.userId } } },
...(groupIds.length > 0 ? [{ assignedToGroupId: { in: groupIds } }] : []),
],
}),
},
select: { id: true },
});
break;
}
case "appointment":
if (!hasAnyPermission(ctx, BOOKING_READ_PERMISSIONS)) throw targetNotFound(entityType, entityId);
target = await tx.appointment.findFirst({
where: {
id: entityId,
tenantId,
...(hasPermission(ctx, "bookings.read_all") || hasPermission(ctx, "bookings.admin") ? {} : { providerUserId: ctx.userId }),
},
select: { id: true },
});
break;
case "signature_document": {
if (!hasAnyPermission(ctx, SIGNATURE_READ_PERMISSIONS)) throw targetNotFound(entityType, entityId);
if (!hasAnyPermission(ctx, PROJECT_READ_PERMISSIONS)) throw targetNotFound(entityType, entityId);
const projectCtx = await hydrateProjectVisibilityContextWithTx(tx, tenantId, ctx);
const document = await tx.projektSignatureDocument.findFirst({
where: { id: entityId, tenantId, archivedAt: null },
select: { id: true, projektId: true },
});
if (document) {
const project = await tx.projekt.findFirst({
where: { id: document.projektId, tenantId, archivedAt: null, ...projectReadScopeWhere(tenantId, projectCtx) },
select: { id: true },
});
target = project ? { id: document.id } : null;
}
break;
}
case "chat_message":
if (!hasAnyPermission(ctx, CHAT_READ_PERMISSIONS)) throw targetNotFound(entityType, entityId);
target = await tx.teamChatMessage.findFirst({
where: {
id: entityId,
tenantId,
chat: { tenantId, members: { some: { tenantId, userId: ctx.userId, leftAt: null } } },
},
select: { id: true },
});
break;
default:
throw targetNotFound(entityType, entityId);
}
if (!target) throw targetNotFound(entityType, entityId);
}
async function loadVisibleLinkedProjectIdsWithTx(
tx: TenantRlsClient,
tenantId: string,
ctx: FileVisibilityContext,
candidateFileIds?: readonly string[],
queryState?: FileVisibilityQueryState
): Promise<string[]> {
if (!PROJECT_READ_PERMISSIONS.some((permission) => hasPermission(ctx, permission))) return [];
let projectIds: string[] | undefined;
if (candidateFileIds !== undefined) {
if (candidateFileIds.length === 0) return [];
const links = await tx.fileEntityLink.findMany({
where: {
tenantId,
entityType: "project",
fileId: { in: [...candidateFileIds] },
},
select: { entityId: true },
distinct: ["entityId"],
});
projectIds = [...new Set(links.map(({ entityId }) => entityId))];
if (projectIds.length === 0) return [];
}
const projectCtx = await getProjectVisibilityContextWithTx(tx, tenantId, ctx, queryState);
const visibleProjects = await tx.projekt.findMany({
where: {
tenantId,
...(projectIds ? { id: { in: projectIds } } : {}),
archivedAt: null,
...projectReadScopeWhere(tenantId, projectCtx),
},
select: { id: true },
});
return visibleProjects.map(({ id }) => id);
}
async function loadVisibleLinkedChatMessageIdsWithTx(
tx: TenantRlsClient,
tenantId: string,
ctx: FileVisibilityContext,
candidateFileIds?: readonly string[]
): Promise<string[]> {
if (!hasAnyPermission(ctx, CHAT_READ_PERMISSIONS)) return [];
let messageIds: string[] | undefined;
if (candidateFileIds !== undefined) {
if (candidateFileIds.length === 0) return [];
const links = await tx.fileEntityLink.findMany({
where: { tenantId, entityType: "chat_message", fileId: { in: [...candidateFileIds] } },
select: { entityId: true },
distinct: ["entityId"],
});
messageIds = [...new Set(links.map(({ entityId }) => entityId))];
if (messageIds.length === 0) return [];
}
const messages = await tx.teamChatMessage.findMany({
where: {
tenantId,
...(messageIds ? { id: { in: messageIds } } : {}),
chat: { tenantId, members: { some: { tenantId, userId: ctx.userId, leftAt: null } } },
},
select: { id: true },
});
return messages.map(({ id }) => id);
}
/**
* Build the authoritative file read scope. A polymorphic link never grants
* access by itself: only project links whose target project is currently
* readable (including PipelineAccess deny rules) extend files.read_own.
*/
export async function fileReadScopeWhereWithTx(
tx: TenantRlsClient,
tenantId: string,
ctx: FileVisibilityContext,
candidateFileIds?: readonly string[],
queryState?: FileVisibilityQueryState
): Promise<Prisma.FileWhereInput> {
const canReadFiles = hasAnyPermission(ctx, FILE_READ_PERMISSIONS);
const canReadChat = hasAnyPermission(ctx, CHAT_READ_PERMISSIONS);
if (!canReadFiles && !canReadChat) return { id: { in: [] } };
const readsAllFiles = canReadFiles && hasPermission(ctx, "files.read_all");
const readsAllHrFiles = canReadAllHrFiles(ctx);
const [projectIds, chatMessageIds] = readsAllFiles
? [[], []]
: await Promise.all([
canReadFiles ? loadVisibleLinkedProjectIdsWithTx(tx, tenantId, ctx, candidateFileIds, queryState) : Promise.resolve([]),
canReadChat ? loadVisibleLinkedChatMessageIdsWithTx(tx, tenantId, ctx, candidateFileIds) : Promise.resolve([]),
]);
const scopedBranches: Prisma.FileWhereInput[] = [
...(canReadFiles
? [
{ createdByUserId: ctx.userId },
{ assignedToUserId: ctx.userId },
...(projectIds.length > 0 ? [{ links: { some: { tenantId, entityType: "project" as const, entityId: { in: projectIds } } } }] : []),
{
links: {
some: {
tenantId,
entityType: "user" as const,
...(readsAllHrFiles ? {} : { entityId: ctx.userId }),
},
},
},
]
: []),
...(chatMessageIds.length > 0 ? [{ links: { some: { tenantId, entityType: "chat_message" as const, entityId: { in: chatMessageIds } } } }] : []),
];
const baseScope: Prisma.FileWhereInput = readsAllFiles ? {} : scopedBranches.length > 0 ? { OR: scopedBranches } : { id: { in: [] } };
if (readsAllHrFiles) return baseScope;
const hrScope: Prisma.FileWhereInput = {
links: {
none: {
tenantId,
entityType: "user",
entityId: { not: ctx.userId },
},
},
};
return Object.keys(baseScope).length === 0 ? hrScope : { AND: [baseScope, hrScope] };
}