// 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 = { 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; confidential?: boolean; regulatoryScope?: Array<"gdpr">; }): Promise { 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 { 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 { 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> { 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> { 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 { 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 { 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 { 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 { 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 { await requireVaultWithAccessWithTx(tx, tenantId, vaultId, userId, "admin"); } async function protectLastVaultAdminWithTx( tx: TenantRlsClient, input: { tenantId: string; vaultId: string; targetUserId: string; nextLevel?: PasswordSafePermissionLevel } ): Promise { 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 { 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[1] ); } catch { // Access changes remain authoritative when notification delivery fails. } } }); } export async function revokeAccess(input: { tenantId: string; vaultId: string; targetUserId: string; actorUserId: string; }): Promise { 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[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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, })); }); }