Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/db/permissions/userGroups.db.server.ts
T

246 lines
8.8 KiB
TypeScript

import { logActivity } from "@hivegrid/activity";
import { syncPermissionGroupChatsIfEnabledWithTx } from "@hivegrid/chat/group-sync.server";
import { syncProjectChannelsForPermissionGroupWithTx } from "@hivegrid/chat/project-sync.server";
import { lockPermissionGroupsWithTx } from "@hivegrid/chat/sync-locks.server";
import { publishAuditEventViaOutbox } from "@hivegrid/db/audit-events.server";
import type { OutboxPublisherTransaction } from "@hivegrid/db/outbox";
import type { DbTransactionClient } from "@hivegrid/db/transactions.server";
import { withTenantScope } from "@hivegrid/foundation";
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
export type GroupMembershipSyncContext = Readonly<{
actorUserId: string;
requestId: string;
}>;
export type PermissionGroupCreateInput = Readonly<{
createdByUserId: string;
name: string;
description: string;
color: number;
}>;
async function syncGroupMembershipDependents(
tx: DbTransactionClient,
tenantId: string,
groupId: string,
context: GroupMembershipSyncContext
): Promise<void> {
await syncPermissionGroupChatsIfEnabledWithTx(tx, {
tenantId,
groupId,
actorUserId: context.actorUserId,
requestId: `${context.requestId}:group-chats`,
});
await syncProjectChannelsForPermissionGroupWithTx(tx, {
tenantId,
groupId,
actorUserId: context.actorUserId,
requestId: `${context.requestId}:project-chats`,
});
}
async function requireTenantGroup(
tx: DbTransactionClient,
tenantId: string,
groupId: string
): Promise<Readonly<{ id: string; name: string }>> {
const group = await tx.group.findFirst({
where: withTenantScope(tenantId, { id: groupId }),
select: { id: true, name: true },
});
if (!group) throw new Error("Permission group is not available in the tenant");
return group;
}
async function requireActiveTenantUsers(tx: DbTransactionClient, tenantId: string, userIds: readonly string[]): Promise<void> {
if (userIds.length === 0) return;
const expectedUserIds = [...userIds].sort();
const memberships = await tx.tenantUser.findMany({
where: withTenantScope(tenantId, {
userId: { in: expectedUserIds },
status: 2,
user: { active: true, pendingDeletion: false, pseudonymized: false },
}),
select: { userId: true },
orderBy: { userId: "asc" },
});
const activeUserIds = memberships.map((membership) => membership.userId).sort();
if (activeUserIds.length !== expectedUserIds.length || activeUserIds.some((userId, index) => userId !== expectedUserIds[index])) {
throw new Error("Permission group contains a user outside the active tenant membership");
}
}
export async function createPermissionGroupWithUsers(
tenantId: string,
input: PermissionGroupCreateInput,
userIds: string[]
) {
const uniqueUserIds = [...new Set(userIds)];
return withTenantRLS(tenantId, async (tx) => {
await requireActiveTenantUsers(tx, tenantId, uniqueUserIds);
const group = await tx.group.create({
data: withTenantScope(tenantId, input),
});
if (uniqueUserIds.length > 0) {
await tx.groupUser.createMany({
data: uniqueUserIds.map((userId) => ({ userId, groupId: group.id })),
skipDuplicates: true,
});
}
return group;
});
}
export async function addGroupUsersAndSync(
groupId: string,
tenantId: string,
userIds: string[],
context: GroupMembershipSyncContext
) {
const uniqueUserIds = [...new Set(userIds)];
if (uniqueUserIds.length === 0) return { count: 0 };
return withTenantRLS(tenantId, async (tx) => {
await lockPermissionGroupsWithTx(tx, tenantId, [groupId]);
await requireTenantGroup(tx, tenantId, groupId);
await requireActiveTenantUsers(tx, tenantId, uniqueUserIds);
const result = await tx.groupUser.createMany({
data: uniqueUserIds.map((userId) => ({ userId, groupId })),
skipDuplicates: true,
});
if (result.count > 0) await syncGroupMembershipDependents(tx, tenantId, groupId, context);
return result;
});
}
export async function removeGroupUsersAndSync(
groupId: string,
tenantId: string,
userIds: string[],
context: GroupMembershipSyncContext
) {
const uniqueUserIds = [...new Set(userIds)];
if (uniqueUserIds.length === 0) return { count: 0 };
return withTenantRLS(tenantId, async (tx) => {
await lockPermissionGroupsWithTx(tx, tenantId, [groupId]);
await requireTenantGroup(tx, tenantId, groupId);
const result = await tx.groupUser.deleteMany({ where: { groupId, userId: { in: uniqueUserIds } } });
if (result.count > 0) await syncGroupMembershipDependents(tx, tenantId, groupId, context);
return result;
});
}
export async function replaceGroupUsers(
groupId: string,
tenantId: string,
userIds: string[],
context: GroupMembershipSyncContext
) {
const uniqueUserIds = [...new Set(userIds)];
return withTenantRLS(tenantId, async (tx) => {
await lockPermissionGroupsWithTx(tx, tenantId, [groupId]);
await requireTenantGroup(tx, tenantId, groupId);
await requireActiveTenantUsers(tx, tenantId, uniqueUserIds);
await tx.groupUser.deleteMany({
where: {
groupId,
group: { tenantId },
},
});
const result =
uniqueUserIds.length === 0
? { count: 0 }
: await tx.groupUser.createMany({
data: uniqueUserIds.map((userId) => ({ userId, groupId })),
skipDuplicates: true,
});
await syncGroupMembershipDependents(tx, tenantId, groupId, context);
return result;
});
}
export async function deletePermissionGroupAndSync(
groupId: string,
tenantId: string,
context: GroupMembershipSyncContext
) {
return withTenantRLS(tenantId, async (tx) => {
await lockPermissionGroupsWithTx(tx, tenantId, [groupId]);
const group = await requireTenantGroup(tx, tenantId, groupId);
const projectMemberships = await tx.projektMitglied.findMany({
where: withTenantScope(tenantId, { groupId, archivedAt: null }),
select: { id: true, projektId: true, rolle: true },
orderBy: [{ projektId: "asc" }, { id: "asc" }],
});
// Empty the source first so both chat reconcilers revoke group-derived
// access while the Group row is still available to their tenant checks.
await tx.groupUser.deleteMany({ where: { groupId, group: { tenantId } } });
await syncGroupMembershipDependents(tx, tenantId, groupId, context);
// Chat source rows describe live configuration and become invalid with the
// group. Project-member rows are business history, so archive rather than
// hard-delete them and retain the bare group id as the historical source.
await tx.teamChatGroupSource.deleteMany({ where: withTenantScope(tenantId, { groupId }) });
const archivedAt = new Date();
if (projectMemberships.length > 0) {
await tx.projektMitglied.updateMany({
where: withTenantScope(tenantId, { groupId, archivedAt: null }),
data: { archivedAt },
});
for (const [projectId, memberships] of groupProjectMemberships(projectMemberships)) {
await logActivity(
{
tenantId,
actorUserId: context.actorUserId,
actorKind: "user",
activityType: "project_member_removed",
subjectType: "project",
subjectId: projectId,
summaryText: "activity.runtime.permissions.groupProjectMembershipArchived",
metadata: {
groupId,
groupName: group.name,
membershipIds: memberships.map(({ id }) => id),
roles: [...new Set(memberships.map(({ rolle }) => rolle))].sort(),
archivedAt: archivedAt.toISOString(),
reason: "permission_group_deleted",
},
},
{ client: tx }
);
}
}
await publishAuditEventViaOutbox(tx as unknown as OutboxPublisherTransaction, {
tenantId,
actorId: context.actorUserId,
actorType: "user",
action: "permissions.group.deleted",
targetType: "Group",
targetId: groupId,
requestId: context.requestId,
sourceModule: "permissions",
dataSensitivity: "internal",
metadata: {
groupName: group.name,
archivedProjectMembershipIds: projectMemberships.map(({ id }) => id),
affectedProjectIds: [...new Set(projectMemberships.map(({ projektId }) => projektId))].sort(),
},
summaryText: "Eine Berechtigungsgruppe wurde gelöscht; Projektzuordnungen bleiben archiviert erhalten.",
});
return tx.group.deleteMany({ where: withTenantScope(tenantId, { id: groupId }) });
});
}
function groupProjectMemberships<T extends Readonly<{ projektId: string }>>(rows: readonly T[]): ReadonlyMap<string, readonly T[]> {
const grouped = new Map<string, T[]>();
for (const row of rows) {
const memberships = grouped.get(row.projektId) ?? [];
memberships.push(row);
grouped.set(row.projektId, memberships);
}
return grouped;
}