Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrPasswordSafeService.server.ts
T

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,
}));
});
}