874 lines
34 KiB
TypeScript
874 lines
34 KiB
TypeScript
// P17 HR-Hub core (AK-P17a) §S4 — encrypted team password-safe service.
|
|
//
|
|
// Security model (designer brief P17 + DoD #3):
|
|
// - A vault is visible ONLY to a user holding an EmployeePasswordSafeAccess row
|
|
// (read/write/admin) — tenant membership alone grants nothing. The tenant-wide
|
|
// admin view (/password-safe/admin, gated by hr.passwordsafe.admin) can
|
|
// MANAGE vaults but NEVER sees entry contents without an access row.
|
|
// - Entry SECRET VALUES (username/password/notes) are ENVELOPE-ENCRYPTED (AD-8,
|
|
// via the hrFieldCrypto seam) and are NEVER selected into a list. Only an
|
|
// explicit, access-checked + AUDITED reveal decrypts a single value on demand.
|
|
// - DSGVO export decrypts a whole vault for the authorised user and is audited.
|
|
//
|
|
// RLS-ready (mirrors hrService): every tenant access runs inside withTenantRLS();
|
|
// internal composition reuses the active transaction via *WithTx helpers.
|
|
//
|
|
// The production crypto binding uses the tenant envelope provider. If its key
|
|
// material is unavailable, the M002 catalog error is surfaced fail-closed rather
|
|
// than storing or returning plaintext.
|
|
|
|
import { CatalogError, foundationErrors, notify } from "@hivegrid/foundation";
|
|
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
|
|
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
|
|
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
|
|
import { getHrFieldCrypto, type HrFieldCrypto } from "./hrFieldCrypto.server";
|
|
import { HrServiceError } from "./hrService.server";
|
|
|
|
const HR_AUDIT_MODULE = "hr";
|
|
const PASSWORD_SAFE_AUDIT_PREFIX = "HR_PASSWORD_SAFE_";
|
|
|
|
export type PasswordSafePermissionLevel = "read" | "write" | "admin";
|
|
export type PasswordSafeSecretField = "username" | "password" | "notes";
|
|
|
|
const LEVEL_RANK: Record<PasswordSafePermissionLevel, number> = { read: 1, write: 2, admin: 3 };
|
|
|
|
function meetsLevel(have: PasswordSafePermissionLevel, need: PasswordSafePermissionLevel): boolean {
|
|
return LEVEL_RANK[have] >= LEVEL_RANK[need];
|
|
}
|
|
|
|
async function auditPasswordSafe(input: {
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
action: string;
|
|
targetType: "EmployeePasswordSafeVault" | "EmployeePasswordSafeEntry" | "EmployeePasswordSafeAccess";
|
|
targetId: string;
|
|
metadata?: Record<string, string | number | boolean | null>;
|
|
confidential?: boolean;
|
|
regulatoryScope?: Array<"gdpr">;
|
|
}): Promise<void> {
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.actorUserId,
|
|
actorType: "user",
|
|
action: `${PASSWORD_SAFE_AUDIT_PREFIX}${input.action}`,
|
|
targetType: input.targetType,
|
|
targetId: input.targetId,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
dataSensitivity: input.confidential ? "confidential" : "internal",
|
|
regulatoryScope: input.regulatoryScope,
|
|
outcome: "success",
|
|
metadata: input.metadata,
|
|
});
|
|
}
|
|
|
|
// --- Vault rows -----------------------------------------------------------
|
|
|
|
export type VaultRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
name: string;
|
|
description: string | null;
|
|
createdByUserId: string;
|
|
isDemoData: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
archivedAt: Date | null;
|
|
};
|
|
|
|
/** An entry as shown in a list: titles/tags/url ONLY — secret values omitted,
|
|
* replaced by booleans signalling which fields hold a value. */
|
|
export type EntryListRow = {
|
|
id: string;
|
|
vaultId: string;
|
|
title: string;
|
|
tags: string[];
|
|
url: string | null;
|
|
hasUsername: boolean;
|
|
hasPassword: boolean;
|
|
hasNotes: boolean;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
// --- Access resolution ----------------------------------------------------
|
|
|
|
async function resolveVaultAccessWithTx(tx: TenantRlsClient, tenantId: string, vaultId: string, userId: string): Promise<PasswordSafePermissionLevel | null> {
|
|
const access = await tx.employeePasswordSafeAccess.findFirst({
|
|
where: { tenantId, vaultId, userId },
|
|
select: { permissionLevel: true },
|
|
});
|
|
return (access?.permissionLevel as PasswordSafePermissionLevel | undefined) ?? null;
|
|
}
|
|
|
|
/** The caller's access level on a vault (or null if none). */
|
|
export async function resolveVaultAccess(tenantId: string, vaultId: string, userId: string): Promise<PasswordSafePermissionLevel | null> {
|
|
return withTenantRLS(tenantId, (tx) => resolveVaultAccessWithTx(tx, tenantId, vaultId, userId));
|
|
}
|
|
|
|
/** Load a vault the caller has access to (id + name + own access level), or null. */
|
|
export async function getVaultForUser(
|
|
tenantId: string,
|
|
vaultId: string,
|
|
userId: string
|
|
): Promise<{ id: string; name: string; accessLevel: PasswordSafePermissionLevel } | null> {
|
|
return withTenantRLS(tenantId, async (tx) => {
|
|
const vault = await tx.employeePasswordSafeVault.findFirst({ where: { id: vaultId, tenantId, archivedAt: null }, select: { id: true, name: true } });
|
|
if (!vault) {
|
|
return null;
|
|
}
|
|
const level = await resolveVaultAccessWithTx(tx, tenantId, vaultId, userId);
|
|
if (!level) {
|
|
return null;
|
|
}
|
|
return { id: vault.id, name: vault.name, accessLevel: level };
|
|
});
|
|
}
|
|
|
|
/** Load a vault and assert the caller has at least `need` access, else 404. */
|
|
async function requireVaultWithAccessWithTx(
|
|
tx: TenantRlsClient,
|
|
tenantId: string,
|
|
vaultId: string,
|
|
userId: string,
|
|
need: PasswordSafePermissionLevel
|
|
): Promise<{ vault: VaultRow; level: PasswordSafePermissionLevel }> {
|
|
const vault = await tx.employeePasswordSafeVault.findFirst({ where: { id: vaultId, tenantId, archivedAt: null } });
|
|
if (!vault) {
|
|
throw new CatalogError(foundationErrors.notFound, { vaultId });
|
|
}
|
|
const level = await resolveVaultAccessWithTx(tx, tenantId, vaultId, userId);
|
|
// No access (or below the required level) reads as "not found" (A.5.3).
|
|
if (!level || !meetsLevel(level, need)) {
|
|
throw new CatalogError(foundationErrors.notFound, { vaultId });
|
|
}
|
|
return { vault: vault as VaultRow, level };
|
|
}
|
|
|
|
// --- Vault listing & management -------------------------------------------
|
|
|
|
/** Vaults the caller may see (has an access row for), newest first. */
|
|
export async function listAccessibleVaults(
|
|
tenantId: string,
|
|
userId: string,
|
|
opts?: { search?: string }
|
|
): Promise<Array<VaultRow & { accessLevel: PasswordSafePermissionLevel }>> {
|
|
const search = opts?.search?.trim().slice(0, 100) ?? "";
|
|
const rows = await withTenantRLS(tenantId, (tx) =>
|
|
tx.employeePasswordSafeVault.findMany({
|
|
where: {
|
|
tenantId,
|
|
archivedAt: null,
|
|
access: { some: { tenantId, userId } },
|
|
...(search
|
|
? {
|
|
OR: [
|
|
{ name: { contains: search, mode: "insensitive" } },
|
|
{ description: { contains: search, mode: "insensitive" } },
|
|
],
|
|
}
|
|
: {}),
|
|
},
|
|
include: { access: { where: { tenantId, userId }, select: { permissionLevel: true }, take: 1 } },
|
|
orderBy: { name: "asc" },
|
|
})
|
|
);
|
|
return rows.map(({ access, ...vault }) => ({
|
|
...(vault as VaultRow),
|
|
accessLevel: (access[0]?.permissionLevel as PasswordSafePermissionLevel | undefined) ?? "read",
|
|
}));
|
|
}
|
|
|
|
/** Tenant-wide vault list for the admin view — metadata only, NO content access.
|
|
* The route gates this behind hr.passwordsafe.admin. */
|
|
export async function adminListAllVaults(
|
|
tenantId: string,
|
|
opts?: { includeArchived?: boolean; userId?: string; search?: string }
|
|
): Promise<Array<VaultRow & { entryCount: number; accessCount: number; hasContentAccess: boolean }>> {
|
|
const search = opts?.search?.trim().slice(0, 100) ?? "";
|
|
return withTenantRLS(tenantId, async (tx) => {
|
|
const vaults = await tx.employeePasswordSafeVault.findMany({
|
|
where: {
|
|
tenantId,
|
|
...(opts?.includeArchived ? {} : { archivedAt: null }),
|
|
...(search
|
|
? {
|
|
OR: [
|
|
{ name: { contains: search, mode: "insensitive" } },
|
|
{ description: { contains: search, mode: "insensitive" } },
|
|
],
|
|
}
|
|
: {}),
|
|
},
|
|
orderBy: { name: "asc" },
|
|
});
|
|
const withCounts = await Promise.all(
|
|
vaults.map(async (vault) => {
|
|
const [entryCount, accessCount] = await Promise.all([
|
|
tx.employeePasswordSafeEntry.count({ where: { tenantId, vaultId: vault.id, archivedAt: null } }),
|
|
tx.employeePasswordSafeAccess.count({ where: { tenantId, vaultId: vault.id } }),
|
|
]);
|
|
const hasContentAccess = opts?.userId
|
|
? Boolean(
|
|
await tx.employeePasswordSafeAccess.findFirst({
|
|
where: { tenantId, vaultId: vault.id, userId: opts.userId },
|
|
select: { id: true },
|
|
})
|
|
)
|
|
: false;
|
|
return { ...(vault as VaultRow), entryCount, accessCount, hasContentAccess };
|
|
})
|
|
);
|
|
return withCounts;
|
|
});
|
|
}
|
|
|
|
export async function createVault(input: {
|
|
tenantId: string;
|
|
name: string;
|
|
description?: string | null;
|
|
createdByUserId: string;
|
|
isDemoData?: boolean;
|
|
}): Promise<VaultRow> {
|
|
const name = input.name.trim();
|
|
if (!name) {
|
|
throw new HrServiceError("hr.error.vaultNameRequired");
|
|
}
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
const vault = await tx.employeePasswordSafeVault.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
name,
|
|
description: input.description ?? null,
|
|
createdByUserId: input.createdByUserId,
|
|
isDemoData: input.isDemoData ?? false,
|
|
},
|
|
});
|
|
// Creator gets admin access by default so they can manage the new vault.
|
|
await tx.employeePasswordSafeAccess.create({
|
|
data: { tenantId: input.tenantId, vaultId: vault.id, userId: input.createdByUserId, permissionLevel: "admin", grantedByUserId: input.createdByUserId },
|
|
});
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.createdByUserId,
|
|
action: "VAULT_CREATE",
|
|
targetType: "EmployeePasswordSafeVault",
|
|
targetId: vault.id,
|
|
});
|
|
return vault as VaultRow;
|
|
});
|
|
}
|
|
|
|
/** Archive a vault. Allowed for a vault-admin OR a tenant password-safe admin. */
|
|
export async function archiveVault(input: { tenantId: string; vaultId: string; userId: string; isTenantPasswordSafeAdmin: boolean }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
if (!input.isTenantPasswordSafeAdmin) {
|
|
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "admin");
|
|
} else {
|
|
const exists = await tx.employeePasswordSafeVault.findFirst({ where: { id: input.vaultId, tenantId: input.tenantId }, select: { id: true } });
|
|
if (!exists) {
|
|
throw new CatalogError(foundationErrors.notFound, { vaultId: input.vaultId });
|
|
}
|
|
}
|
|
const result = await tx.employeePasswordSafeVault.updateMany({
|
|
where: { id: input.vaultId, tenantId: input.tenantId, archivedAt: null },
|
|
data: { archivedAt: new Date() },
|
|
});
|
|
if (result.count === 0) {
|
|
throw new CatalogError(foundationErrors.notFound, { vaultId: input.vaultId });
|
|
}
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.userId,
|
|
action: "VAULT_ARCHIVE",
|
|
targetType: "EmployeePasswordSafeVault",
|
|
targetId: input.vaultId,
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Restore an archived vault. This tenant-wide operation never grants content
|
|
* access; existing per-vault access rows remain authoritative. */
|
|
export async function restoreVault(input: { tenantId: string; vaultId: string; userId: string; isTenantPasswordSafeAdmin: boolean }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
if (!input.isTenantPasswordSafeAdmin) {
|
|
throw new CatalogError(foundationErrors.notFound, { vaultId: input.vaultId });
|
|
}
|
|
const result = await tx.employeePasswordSafeVault.updateMany({
|
|
where: { id: input.vaultId, tenantId: input.tenantId, archivedAt: { not: null } },
|
|
data: { archivedAt: null },
|
|
});
|
|
if (result.count === 0) {
|
|
throw new CatalogError(foundationErrors.notFound, { vaultId: input.vaultId });
|
|
}
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.userId,
|
|
action: "VAULT_RESTORE",
|
|
targetType: "EmployeePasswordSafeVault",
|
|
targetId: input.vaultId,
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Access management -----------------------------------------------------
|
|
|
|
async function requireActiveTenantMemberWithTx(tx: TenantRlsClient, tenantId: string, userId: string): Promise<void> {
|
|
const membership = await tx.tenantUser.findFirst({
|
|
where: { tenantId, userId, status: TenantUserStatus.ACTIVE, user: { active: true } },
|
|
select: { id: true },
|
|
});
|
|
if (!membership) throw new CatalogError(foundationErrors.notFound, { userId });
|
|
}
|
|
|
|
async function requireVaultAdminWithTx(tx: TenantRlsClient, tenantId: string, vaultId: string, userId: string): Promise<void> {
|
|
await requireVaultWithAccessWithTx(tx, tenantId, vaultId, userId, "admin");
|
|
}
|
|
|
|
async function protectLastVaultAdminWithTx(
|
|
tx: TenantRlsClient,
|
|
input: { tenantId: string; vaultId: string; targetUserId: string; nextLevel?: PasswordSafePermissionLevel }
|
|
): Promise<void> {
|
|
const current = await tx.employeePasswordSafeAccess.findFirst({
|
|
where: { tenantId: input.tenantId, vaultId: input.vaultId, userId: input.targetUserId },
|
|
select: { permissionLevel: true },
|
|
});
|
|
if (current?.permissionLevel !== "admin" || input.nextLevel === "admin") return;
|
|
const adminCount = await tx.employeePasswordSafeAccess.count({
|
|
where: { tenantId: input.tenantId, vaultId: input.vaultId, permissionLevel: "admin" },
|
|
});
|
|
if (adminCount <= 1) throw new HrServiceError("hr.error.lastVaultAdmin");
|
|
}
|
|
|
|
export async function grantAccess(input: {
|
|
tenantId: string;
|
|
vaultId: string;
|
|
targetUserId: string;
|
|
permissionLevel: PasswordSafePermissionLevel;
|
|
actorUserId: string;
|
|
}): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultAdminWithTx(tx, input.tenantId, input.vaultId, input.actorUserId);
|
|
await requireActiveTenantMemberWithTx(tx, input.tenantId, input.targetUserId);
|
|
await protectLastVaultAdminWithTx(tx, { ...input, nextLevel: input.permissionLevel });
|
|
await tx.employeePasswordSafeAccess.upsert({
|
|
where: { vaultId_userId: { vaultId: input.vaultId, userId: input.targetUserId } },
|
|
update: { permissionLevel: input.permissionLevel, grantedByUserId: input.actorUserId },
|
|
create: {
|
|
tenantId: input.tenantId,
|
|
vaultId: input.vaultId,
|
|
userId: input.targetUserId,
|
|
permissionLevel: input.permissionLevel,
|
|
grantedByUserId: input.actorUserId,
|
|
},
|
|
});
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.actorUserId,
|
|
action: "ACCESS_GRANT",
|
|
targetType: "EmployeePasswordSafeAccess",
|
|
targetId: `${input.vaultId}:${input.targetUserId}`,
|
|
metadata: { vaultId: input.vaultId, targetUserId: input.targetUserId, permissionLevel: input.permissionLevel },
|
|
});
|
|
if (input.targetUserId !== input.actorUserId) {
|
|
try {
|
|
await notify(
|
|
{
|
|
tenantId: input.tenantId,
|
|
userId: input.targetUserId,
|
|
channel: "IN_APP",
|
|
category: "hr",
|
|
messageKey: "hr.passwordSafe.notification.accessGranted",
|
|
sourceKey: `password-safe-access:${input.vaultId}:${input.targetUserId}:${input.permissionLevel}`,
|
|
payloadMetadata: { vaultId: input.vaultId, permissionLevel: input.permissionLevel },
|
|
},
|
|
tx as unknown as Parameters<typeof notify>[1]
|
|
);
|
|
} catch {
|
|
// Access changes remain authoritative when notification delivery fails.
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function revokeAccess(input: {
|
|
tenantId: string;
|
|
vaultId: string;
|
|
targetUserId: string;
|
|
actorUserId: string;
|
|
}): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultAdminWithTx(tx, input.tenantId, input.vaultId, input.actorUserId);
|
|
await protectLastVaultAdminWithTx(tx, input);
|
|
const deleted = await tx.employeePasswordSafeAccess.deleteMany({
|
|
where: { tenantId: input.tenantId, vaultId: input.vaultId, userId: input.targetUserId },
|
|
});
|
|
if (deleted.count === 0) throw new CatalogError(foundationErrors.notFound, { userId: input.targetUserId });
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.actorUserId,
|
|
action: "ACCESS_REVOKE",
|
|
targetType: "EmployeePasswordSafeAccess",
|
|
targetId: `${input.vaultId}:${input.targetUserId}`,
|
|
metadata: { vaultId: input.vaultId, targetUserId: input.targetUserId },
|
|
});
|
|
if (input.targetUserId !== input.actorUserId) {
|
|
try {
|
|
await notify(
|
|
{
|
|
tenantId: input.tenantId,
|
|
userId: input.targetUserId,
|
|
channel: "IN_APP",
|
|
category: "hr",
|
|
messageKey: "hr.passwordSafe.notification.accessRevoked",
|
|
sourceKey: `password-safe-access-revoked:${input.vaultId}:${input.targetUserId}`,
|
|
payloadMetadata: { vaultId: input.vaultId },
|
|
},
|
|
tx as unknown as Parameters<typeof notify>[1]
|
|
);
|
|
} catch {
|
|
// Access changes remain authoritative when notification delivery fails.
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export type VaultAccessRow = {
|
|
userId: string;
|
|
displayName: string;
|
|
email: string;
|
|
roleNames: string[];
|
|
permissionLevel: PasswordSafePermissionLevel;
|
|
};
|
|
|
|
export type VaultMemberOption = {
|
|
userId: string;
|
|
displayName: string;
|
|
email: string;
|
|
roleNames: string[];
|
|
hasAccess: boolean;
|
|
};
|
|
|
|
export async function listVaultAccess(input: {
|
|
tenantId: string;
|
|
vaultId: string;
|
|
userId: string;
|
|
}): Promise<VaultAccessRow[]> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultAdminWithTx(tx, input.tenantId, input.vaultId, input.userId);
|
|
const rows = await tx.employeePasswordSafeAccess.findMany({
|
|
where: { tenantId: input.tenantId, vaultId: input.vaultId },
|
|
select: { userId: true, permissionLevel: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
const memberships = await tx.tenantUser.findMany({
|
|
where: { tenantId: input.tenantId, userId: { in: rows.map(({ userId }) => userId) }, status: TenantUserStatus.ACTIVE },
|
|
select: { userId: true, user: { select: { firstName: true, lastName: true, email: true } } },
|
|
});
|
|
const roles = await tx.userRole.findMany({
|
|
where: { tenantId: input.tenantId, userId: { in: rows.map(({ userId }) => userId) } },
|
|
select: { userId: true, role: { select: { name: true } } },
|
|
});
|
|
const membershipByUser = new Map(memberships.map((membership) => [membership.userId, membership.user]));
|
|
return rows.flatMap((row) => {
|
|
const user = membershipByUser.get(row.userId);
|
|
if (!user) return [];
|
|
const name = `${user.firstName} ${user.lastName}`.trim();
|
|
return [{
|
|
userId: row.userId,
|
|
displayName: name || user.email,
|
|
email: user.email,
|
|
roleNames: roles.filter((role) => role.userId === row.userId).map((role) => role.role.name),
|
|
permissionLevel: row.permissionLevel as PasswordSafePermissionLevel,
|
|
}];
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function listVaultMemberOptions(input: { tenantId: string; vaultId: string; userId: string }): Promise<VaultMemberOption[]> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultAdminWithTx(tx, input.tenantId, input.vaultId, input.userId);
|
|
const [memberships, access, roles] = await Promise.all([
|
|
tx.tenantUser.findMany({
|
|
where: { tenantId: input.tenantId, status: TenantUserStatus.ACTIVE, user: { active: true } },
|
|
select: { userId: true, user: { select: { firstName: true, lastName: true, email: true } } },
|
|
orderBy: { user: { lastName: "asc" } },
|
|
}),
|
|
tx.employeePasswordSafeAccess.findMany({
|
|
where: { tenantId: input.tenantId, vaultId: input.vaultId },
|
|
select: { userId: true },
|
|
}),
|
|
tx.userRole.findMany({
|
|
where: { tenantId: input.tenantId },
|
|
select: { userId: true, role: { select: { name: true } } },
|
|
}),
|
|
]);
|
|
const usersWithAccess = new Set(access.map(({ userId }) => userId));
|
|
return memberships.map(({ userId, user }) => ({
|
|
userId,
|
|
displayName: `${user.firstName} ${user.lastName}`.trim() || user.email,
|
|
email: user.email,
|
|
roleNames: roles.filter((role) => role.userId === userId).map((role) => role.role.name),
|
|
hasAccess: usersWithAccess.has(userId),
|
|
}));
|
|
});
|
|
}
|
|
|
|
// --- Entries: list (NEVER values) ------------------------------------------
|
|
|
|
/** List a vault's entries WITHOUT secret values (read access required). */
|
|
export async function listEntries(input: { tenantId: string; vaultId: string; userId: string; archived?: boolean }): Promise<EntryListRow[]> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "read");
|
|
const entries = await tx.employeePasswordSafeEntry.findMany({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
vaultId: input.vaultId,
|
|
archivedAt: input.archived ? { not: null } : null,
|
|
},
|
|
// NOTE: ciphertext columns are deliberately NOT selected here.
|
|
select: {
|
|
id: true,
|
|
vaultId: true,
|
|
title: true,
|
|
tags: true,
|
|
url: true,
|
|
usernameCiphertext: true,
|
|
passwordCiphertext: true,
|
|
notesCiphertext: true,
|
|
updatedAt: true,
|
|
},
|
|
orderBy: { title: "asc" },
|
|
});
|
|
return entries.map((entry) => ({
|
|
id: entry.id,
|
|
vaultId: entry.vaultId,
|
|
title: entry.title,
|
|
tags: entry.tags,
|
|
url: entry.url,
|
|
hasUsername: entry.usernameCiphertext != null,
|
|
hasPassword: entry.passwordCiphertext != null,
|
|
hasNotes: entry.notesCiphertext != null,
|
|
updatedAt: entry.updatedAt,
|
|
}));
|
|
});
|
|
}
|
|
|
|
// --- Entries: create / update (encrypt via seam) ---------------------------
|
|
|
|
export type EntrySecretInput = { username?: string | null; password?: string | null; notes?: string | null };
|
|
|
|
async function encryptSecrets(
|
|
tenantId: string,
|
|
secrets: EntrySecretInput,
|
|
crypto: HrFieldCrypto
|
|
): Promise<{ usernameCiphertext?: string | null; passwordCiphertext?: string | null; notesCiphertext?: string | null }> {
|
|
const out: { usernameCiphertext?: string | null; passwordCiphertext?: string | null; notesCiphertext?: string | null } = {};
|
|
if (secrets.username !== undefined) {
|
|
out.usernameCiphertext = secrets.username ? await crypto.encrypt(tenantId, secrets.username) : null;
|
|
}
|
|
if (secrets.password !== undefined) {
|
|
out.passwordCiphertext = secrets.password ? await crypto.encrypt(tenantId, secrets.password) : null;
|
|
}
|
|
if (secrets.notes !== undefined) {
|
|
out.notesCiphertext = secrets.notes ? await crypto.encrypt(tenantId, secrets.notes) : null;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function createEntry(
|
|
input: {
|
|
tenantId: string;
|
|
vaultId: string;
|
|
userId: string;
|
|
title: string;
|
|
tags?: string[];
|
|
url?: string | null;
|
|
secrets: EntrySecretInput;
|
|
isDemoData?: boolean;
|
|
},
|
|
crypto: HrFieldCrypto = getHrFieldCrypto()
|
|
): Promise<{ id: string }> {
|
|
const title = input.title.trim();
|
|
if (!title) {
|
|
throw new HrServiceError("hr.error.entryTitleRequired");
|
|
}
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "write");
|
|
const ciphertext = await encryptSecrets(input.tenantId, input.secrets, crypto);
|
|
const created = await tx.employeePasswordSafeEntry.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
vaultId: input.vaultId,
|
|
title,
|
|
tags: input.tags ?? [],
|
|
url: input.url ?? null,
|
|
usernameCiphertext: ciphertext.usernameCiphertext ?? null,
|
|
passwordCiphertext: ciphertext.passwordCiphertext ?? null,
|
|
notesCiphertext: ciphertext.notesCiphertext ?? null,
|
|
isDemoData: input.isDemoData ?? false,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.userId,
|
|
action: "ENTRY_CREATE",
|
|
targetType: "EmployeePasswordSafeEntry",
|
|
targetId: created.id,
|
|
metadata: { vaultId: input.vaultId },
|
|
});
|
|
return created;
|
|
});
|
|
}
|
|
|
|
export async function updateEntry(
|
|
input: {
|
|
tenantId: string;
|
|
vaultId: string;
|
|
entryId: string;
|
|
userId: string;
|
|
title?: string;
|
|
tags?: string[];
|
|
url?: string | null;
|
|
secrets?: EntrySecretInput;
|
|
},
|
|
crypto: HrFieldCrypto = getHrFieldCrypto()
|
|
): Promise<void> {
|
|
if (input.title !== undefined && !input.title.trim()) {
|
|
throw new HrServiceError("hr.error.entryTitleRequired");
|
|
}
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "write");
|
|
const entry = await tx.employeePasswordSafeEntry.findFirst({
|
|
where: { id: input.entryId, tenantId: input.tenantId, vaultId: input.vaultId },
|
|
select: { id: true },
|
|
});
|
|
if (!entry) {
|
|
throw new CatalogError(foundationErrors.notFound, { entryId: input.entryId });
|
|
}
|
|
const ciphertext = input.secrets ? await encryptSecrets(input.tenantId, input.secrets, crypto) : {};
|
|
const updated = await tx.employeePasswordSafeEntry.updateMany({
|
|
where: { id: input.entryId, tenantId: input.tenantId, vaultId: input.vaultId },
|
|
data: {
|
|
...(input.title !== undefined ? { title: input.title.trim() } : {}),
|
|
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
|
...(input.url !== undefined ? { url: input.url } : {}),
|
|
...ciphertext,
|
|
},
|
|
});
|
|
if (updated.count === 0) throw new CatalogError(foundationErrors.notFound, { entryId: input.entryId });
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.userId,
|
|
action: "ENTRY_UPDATE",
|
|
targetType: "EmployeePasswordSafeEntry",
|
|
targetId: input.entryId,
|
|
metadata: { vaultId: input.vaultId },
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function archiveEntry(input: { tenantId: string; vaultId: string; entryId: string; userId: string }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "write");
|
|
const archived = await tx.employeePasswordSafeEntry.updateMany({
|
|
where: { id: input.entryId, tenantId: input.tenantId, vaultId: input.vaultId, archivedAt: null },
|
|
data: { archivedAt: new Date() },
|
|
});
|
|
if (archived.count === 0) throw new CatalogError(foundationErrors.notFound, { entryId: input.entryId });
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.userId,
|
|
action: "ENTRY_ARCHIVE",
|
|
targetType: "EmployeePasswordSafeEntry",
|
|
targetId: input.entryId,
|
|
metadata: { vaultId: input.vaultId },
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Restore an archived entry. Write access is required, matching archive. */
|
|
export async function restoreEntry(input: { tenantId: string; vaultId: string; entryId: string; userId: string }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "write");
|
|
const restored = await tx.employeePasswordSafeEntry.updateMany({
|
|
where: {
|
|
id: input.entryId,
|
|
tenantId: input.tenantId,
|
|
vaultId: input.vaultId,
|
|
archivedAt: { not: null },
|
|
},
|
|
data: { archivedAt: null },
|
|
});
|
|
if (restored.count === 0) {
|
|
throw new CatalogError(foundationErrors.notFound, { entryId: input.entryId });
|
|
}
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.userId,
|
|
action: "ENTRY_RESTORE",
|
|
targetType: "EmployeePasswordSafeEntry",
|
|
targetId: input.entryId,
|
|
metadata: { vaultId: input.vaultId },
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Reveal (decrypt single value) + audit ---------------------------------
|
|
|
|
/**
|
|
* Reveal ONE secret field of ONE entry. Requires read access. Writes a
|
|
* confidential audit event BEFORE decrypting/returning, so the reveal is logged
|
|
* even if decryption then fails. This is the audited, on-demand path the UI uses
|
|
* for "show"/"copy" (the UI clears the clipboard after 30s).
|
|
*/
|
|
async function readEntryValue(
|
|
input: { tenantId: string; vaultId: string; entryId: string; userId: string; field: PasswordSafeSecretField },
|
|
auditAction: "REVEAL" | "COPY",
|
|
crypto: HrFieldCrypto = getHrFieldCrypto()
|
|
): Promise<string | null> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "read");
|
|
const entry = await tx.employeePasswordSafeEntry.findFirst({
|
|
where: { id: input.entryId, tenantId: input.tenantId, vaultId: input.vaultId, archivedAt: null },
|
|
select: { id: true, usernameCiphertext: true, passwordCiphertext: true, notesCiphertext: true },
|
|
});
|
|
if (!entry) {
|
|
throw new CatalogError(foundationErrors.notFound, { entryId: input.entryId });
|
|
}
|
|
const ciphertext = input.field === "username" ? entry.usernameCiphertext : input.field === "password" ? entry.passwordCiphertext : entry.notesCiphertext;
|
|
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.userId,
|
|
action: auditAction,
|
|
targetType: "EmployeePasswordSafeEntry",
|
|
targetId: input.entryId,
|
|
confidential: true,
|
|
metadata: { vaultId: input.vaultId, field: input.field },
|
|
});
|
|
|
|
if (ciphertext == null) {
|
|
return null;
|
|
}
|
|
return crypto.decrypt(input.tenantId, ciphertext);
|
|
});
|
|
}
|
|
|
|
export async function revealEntryValue(
|
|
input: { tenantId: string; vaultId: string; entryId: string; userId: string; field: PasswordSafeSecretField },
|
|
crypto: HrFieldCrypto = getHrFieldCrypto()
|
|
): Promise<string | null> {
|
|
return readEntryValue(input, "REVEAL", crypto);
|
|
}
|
|
|
|
/** Copy is a distinct command so its own permission re-check and audit event
|
|
* cannot be replaced by a client-only clipboard operation. */
|
|
export async function copyEntryValue(
|
|
input: { tenantId: string; vaultId: string; entryId: string; userId: string; field: PasswordSafeSecretField },
|
|
crypto: HrFieldCrypto = getHrFieldCrypto()
|
|
): Promise<string | null> {
|
|
return readEntryValue(input, "COPY", crypto);
|
|
}
|
|
|
|
// --- DSGVO export ----------------------------------------------------------
|
|
|
|
export type ExportedEntry = { title: string; tags: string[]; url: string | null; username: string | null; password: string | null; notes: string | null };
|
|
|
|
/**
|
|
* Decrypt a whole vault for an authorised user (read access) — DSGVO export.
|
|
* Audited as a confidential bulk reveal.
|
|
*/
|
|
export async function exportVault(
|
|
input: { tenantId: string; vaultId: string; userId: string; hasExportPermission: boolean },
|
|
crypto: HrFieldCrypto = getHrFieldCrypto()
|
|
): Promise<{ vaultName: string; entries: ExportedEntry[] }> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
if (!input.hasExportPermission) throw new CatalogError(foundationErrors.notFound, { permission: "hr.passwordsafe.export" });
|
|
const { vault } = await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "read");
|
|
const entries = await tx.employeePasswordSafeEntry.findMany({
|
|
where: { tenantId: input.tenantId, vaultId: input.vaultId, archivedAt: null },
|
|
orderBy: { title: "asc" },
|
|
});
|
|
await auditPasswordSafe({
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.userId,
|
|
action: "EXPORT",
|
|
targetType: "EmployeePasswordSafeVault",
|
|
targetId: input.vaultId,
|
|
confidential: true,
|
|
regulatoryScope: ["gdpr"],
|
|
metadata: { entryCount: entries.length },
|
|
});
|
|
const exported: ExportedEntry[] = await Promise.all(
|
|
entries.map(async (entry) => ({
|
|
title: entry.title,
|
|
tags: entry.tags,
|
|
url: entry.url,
|
|
username: entry.usernameCiphertext ? await crypto.decrypt(input.tenantId, entry.usernameCiphertext) : null,
|
|
password: entry.passwordCiphertext ? await crypto.decrypt(input.tenantId, entry.passwordCiphertext) : null,
|
|
notes: entry.notesCiphertext ? await crypto.decrypt(input.tenantId, entry.notesCiphertext) : null,
|
|
}))
|
|
);
|
|
return { vaultName: vault.name, entries: exported };
|
|
});
|
|
}
|
|
|
|
export type PasswordSafeActivityRow = {
|
|
id: string;
|
|
createdAt: Date;
|
|
action: string;
|
|
actorLabel: string;
|
|
outcome: string;
|
|
};
|
|
|
|
/** Redacted audit timeline. No metadata is returned because even permitted
|
|
* metadata such as entry ids is unnecessary for the user-facing activity tab. */
|
|
export async function listPasswordSafeActivity(input: {
|
|
tenantId: string;
|
|
userId: string;
|
|
vaultId?: string;
|
|
isTenantPasswordSafeAdmin?: boolean;
|
|
}): Promise<PasswordSafeActivityRow[]> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
if (input.vaultId) {
|
|
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "read");
|
|
} else if (!input.isTenantPasswordSafeAdmin) {
|
|
throw new CatalogError(foundationErrors.notFound);
|
|
}
|
|
const rows = await tx.auditEvent.findMany({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
action: { startsWith: PASSWORD_SAFE_AUDIT_PREFIX },
|
|
...(input.vaultId
|
|
? {
|
|
OR: [
|
|
{ targetType: "EmployeePasswordSafeVault", targetId: input.vaultId },
|
|
{ metadata: { path: ["vaultId"], equals: input.vaultId } },
|
|
],
|
|
}
|
|
: {}),
|
|
},
|
|
select: { id: true, createdAt: true, action: true, actorId: true, outcome: true },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 100,
|
|
});
|
|
const actorIds = [...new Set(rows.map(({ actorId }) => actorId).filter((actorId): actorId is string => Boolean(actorId)))];
|
|
const actors = actorIds.length
|
|
? await tx.tenantUser.findMany({
|
|
where: { tenantId: input.tenantId, userId: { in: actorIds } },
|
|
select: { userId: true, user: { select: { firstName: true, lastName: true, email: true } } },
|
|
})
|
|
: [];
|
|
const actorById = new Map(
|
|
actors.map(({ userId, user }) => [userId, `${user.firstName} ${user.lastName}`.trim() || user.email])
|
|
);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
createdAt: row.createdAt,
|
|
action: row.action,
|
|
actorLabel: row.actorId ? actorById.get(row.actorId) ?? "System" : "System",
|
|
outcome: row.outcome,
|
|
}));
|
|
});
|
|
}
|