246 lines
8.8 KiB
TypeScript
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;
|
|
}
|