Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/tenant-security.server.ts
T

299 lines
10 KiB
TypeScript

import { getDbClient } from "@hivegrid/db/client.server";
import { listAuditEvents } from "@hivegrid/db/audit-events-query.server";
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
import type {
TenantSecurityAuditInput,
TenantSecurityInput,
TenantSecurityIpLogInput,
TenantSecurityMagicLinkInput,
TenantSecuritySessionInput,
TenantSecurityTrustedDeviceInput,
TenantSecurityUserInput,
} from "@hivegrid/view-models";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { assertAdminActionReason, recordAdminAction } from "./admin-action-audit.server";
export type TenantSecurityDeps = {
readonly db?: ReturnType<typeof getDbClient>;
readonly now?: () => Date;
readonly listAuditEvents?: typeof listAuditEvents;
readonly recordAdminAction?: typeof recordAdminAction;
};
export class TenantSecurityError extends Error {
override readonly name = "TenantSecurityError";
readonly code: "TENANT_NOT_FOUND" | "SESSION_NOT_FOUND" | "TRUSTED_DEVICE_NOT_FOUND";
constructor(code: TenantSecurityError["code"]) {
super(code);
this.code = code;
}
}
export async function getTenantSecuritySnapshot(tenantId: string, deps: TenantSecurityDeps = {}): Promise<TenantSecurityInput> {
return runWithRlsContext({ tenantId }, () => getTenantSecuritySnapshotInContext(tenantId, deps));
}
async function getTenantSecuritySnapshotInContext(tenantId: string, deps: TenantSecurityDeps): Promise<TenantSecurityInput> {
const db = deps.db ?? getDbClient();
const now = deps.now?.() ?? new Date();
const auditEvents = deps.listAuditEvents ?? listAuditEvents;
const tenant = await db.tenant.findUnique({
where: { id: tenantId },
select: {
id: true,
name: true,
slug: true,
active: true,
users: {
select: {
status: true,
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
active: true,
totpEnabledAt: true,
totpPendingCreatedAt: true,
mfaRecoveryCodeHashes: true,
},
},
},
},
},
});
if (!tenant) throw new TenantSecurityError("TENANT_NOT_FOUND");
const userIds = tenant.users.map((item) => item.user.id);
const [sessions, trustedDevices, magicLinks, ipLogs, audit] = await Promise.all([
db.userSession.findMany({
where: { userId: { in: userIds } },
orderBy: { lastSeenAt: "desc" },
take: 50,
select: {
id: true,
userId: true,
deviceLabel: true,
userAgent: true,
approxLocation: true,
createdAt: true,
lastSeenAt: true,
expiresAt: true,
revokedAt: true,
trustedAt: true,
user: { select: { email: true } },
},
}),
db.trustedDevice.findMany({
where: { userId: { in: userIds } },
orderBy: { lastUsedAt: "desc" },
take: 50,
select: {
id: true,
userId: true,
deviceLabel: true,
approxLocation: true,
lastUsedAt: true,
expiresAt: true,
revokedAt: true,
user: { select: { email: true } },
},
}),
db.magicLinkToken.findMany({
where: { userId: { in: userIds } },
orderBy: { createdAt: "desc" },
take: 25,
select: {
id: true,
userId: true,
createdAt: true,
expiresAt: true,
consumedAt: true,
user: { select: { email: true } },
},
}),
db.tenantIpAddress.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
take: 25,
select: {
id: true,
ip: true,
fromUrl: true,
createdAt: true,
user: { select: { email: true } },
apiKey: { select: { alias: true } },
},
}),
auditEvents({
where: {
tenantId,
OR: [
{ outcome: { in: ["failure", "blocked"] } },
{ action: { contains: "login", mode: "insensitive" } },
{ action: { contains: "mfa", mode: "insensitive" } },
{ action: { contains: "magic", mode: "insensitive" } },
{ action: { contains: "session", mode: "insensitive" } },
],
},
take: 25,
skip: 0,
}),
]);
return {
now: now.toISOString(),
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug, active: tenant.active },
users: tenant.users.map<TenantSecurityUserInput>((item) => ({
id: item.user.id,
email: item.user.email,
name: `${item.user.firstName} ${item.user.lastName}`.trim() || item.user.email,
active: item.user.active,
membershipStatus: item.status === TenantUserStatus.ACTIVE ? "active" : "inactive",
mfaEnabled: item.user.totpEnabledAt !== null,
mfaPending: item.user.totpPendingCreatedAt !== null,
recoveryCodesConfigured: hasRecoveryCodes(item.user.mfaRecoveryCodeHashes),
})),
sessions: sessions.map<TenantSecuritySessionInput>((session) => ({
id: session.id,
userId: session.userId,
userEmail: session.user.email,
deviceLabel: session.deviceLabel,
userAgent: session.userAgent,
approxLocation: session.approxLocation,
createdAt: session.createdAt.toISOString(),
lastSeenAt: session.lastSeenAt.toISOString(),
expiresAt: session.expiresAt.toISOString(),
revokedAt: session.revokedAt?.toISOString() ?? null,
trustedAt: session.trustedAt?.toISOString() ?? null,
})),
trustedDevices: trustedDevices.map<TenantSecurityTrustedDeviceInput>((device) => ({
id: device.id,
userId: device.userId,
userEmail: device.user.email,
deviceLabel: device.deviceLabel,
approxLocation: device.approxLocation,
lastUsedAt: device.lastUsedAt.toISOString(),
expiresAt: device.expiresAt.toISOString(),
revokedAt: device.revokedAt?.toISOString() ?? null,
})),
magicLinks: magicLinks.map<TenantSecurityMagicLinkInput>((link) => ({
id: link.id,
userId: link.userId,
userEmail: link.user.email,
createdAt: link.createdAt.toISOString(),
expiresAt: link.expiresAt.toISOString(),
consumedAt: link.consumedAt?.toISOString() ?? null,
})),
ipLogs: ipLogs.map<TenantSecurityIpLogInput>((log) => ({
id: log.id,
ip: log.ip,
fromUrl: log.fromUrl,
createdAt: log.createdAt.toISOString(),
userEmail: log.user?.email ?? null,
apiKeyAlias: log.apiKey?.alias ?? null,
})),
auditEvents: audit.rows.map<TenantSecurityAuditInput>((event) => ({
id: event.id,
createdAt: event.createdAt.toISOString(),
action: event.action,
outcome: event.outcome,
actorEmail: event.actorEmail,
actorId: event.actorId,
summaryText: event.summaryText,
})),
};
}
export async function revokeTenantUserSession(input: {
readonly tenantId: string;
readonly sessionId: string;
readonly reason: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}, deps: TenantSecurityDeps = {}): Promise<void> {
return runWithRlsContext({ tenantId: input.tenantId }, () => revokeTenantUserSessionInContext(input, deps));
}
async function revokeTenantUserSessionInContext(input: {
readonly tenantId: string;
readonly sessionId: string;
readonly reason: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}, deps: TenantSecurityDeps): Promise<void> {
const db = deps.db ?? getDbClient();
const recordAction = deps.recordAdminAction ?? recordAdminAction;
const now = deps.now?.() ?? new Date();
const reason = assertAdminActionReason(input.reason);
const session = await db.userSession.findFirst({
where: { id: input.sessionId, user: { tenants: { some: { tenantId: input.tenantId } } } },
select: { id: true, revokedAt: true, userId: true, user: { select: { email: true } } },
});
if (!session) throw new TenantSecurityError("SESSION_NOT_FOUND");
await db.userSession.update({ where: { id: session.id }, data: { revokedAt: now } });
await recordAction({
action: "security.sessionRevoked",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail,
targetKind: "user_session",
targetId: session.id,
reason,
request: input.request,
extraMeta: { tenantId: input.tenantId, userId: session.userId, userEmail: session.user.email },
});
}
export async function forgetTenantTrustedDevice(input: {
readonly tenantId: string;
readonly trustedDeviceId: string;
readonly reason: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}, deps: TenantSecurityDeps = {}): Promise<void> {
return runWithRlsContext({ tenantId: input.tenantId }, () => forgetTenantTrustedDeviceInContext(input, deps));
}
async function forgetTenantTrustedDeviceInContext(input: {
readonly tenantId: string;
readonly trustedDeviceId: string;
readonly reason: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}, deps: TenantSecurityDeps): Promise<void> {
const db = deps.db ?? getDbClient();
const recordAction = deps.recordAdminAction ?? recordAdminAction;
const now = deps.now?.() ?? new Date();
const reason = assertAdminActionReason(input.reason);
const device = await db.trustedDevice.findFirst({
where: { id: input.trustedDeviceId, user: { tenants: { some: { tenantId: input.tenantId } } } },
select: { id: true, revokedAt: true, userId: true, user: { select: { email: true } } },
});
if (!device) throw new TenantSecurityError("TRUSTED_DEVICE_NOT_FOUND");
await db.trustedDevice.update({ where: { id: device.id }, data: { revokedAt: now } });
await recordAction({
action: "security.trustedDeviceForgotten",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail,
targetKind: "trusted_device",
targetId: device.id,
reason,
request: input.request,
extraMeta: { tenantId: input.tenantId, userId: device.userId, userEmail: device.user.email },
});
}
function hasRecoveryCodes(value: unknown): boolean {
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}