299 lines
10 KiB
TypeScript
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);
|
|
}
|