Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrPasswordSafeService.server.ts
T
2026-07-08 16:15:37 +02:00

429 lines
19 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 (/admin/hr/password-safe, 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.
//
// Until the AD-8 envelope ticket lands, the crypto seam's production binding
// throws HrCryptoNotProvisionedError (M002 Klartext) — so creating an entry with
// secrets or revealing one surfaces "encryption module not provisioned" rather
// than storing plaintext. All NON-crypto paths (vault/entry/access structure,
// access checks, never-list-values, audit, listing) ship and are testable by
// injecting a working crypto provider.
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
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";
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];
}
// --- 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): Promise<VaultRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.employeePasswordSafeVault.findMany({
where: { tenantId, archivedAt: null, access: { some: { tenantId, userId } } },
orderBy: { name: "asc" },
}),
);
return rows as VaultRow[];
}
/** 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 }): Promise<Array<VaultRow & { entryCount: number; accessCount: number }>> {
return withTenantRLS(tenantId, async (tx) => {
const vaults = await tx.employeePasswordSafeVault.findMany({
where: { tenantId, ...(opts?.includeArchived ? {} : { archivedAt: null }) },
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 } }),
]);
return { ...(vault as VaultRow), entryCount, accessCount };
}),
);
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 },
});
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 });
}
}
await tx.employeePasswordSafeVault.updateMany({ where: { id: input.vaultId, tenantId: input.tenantId }, data: { archivedAt: new Date() } });
});
}
// --- Access management -----------------------------------------------------
export async function grantAccess(input: {
tenantId: string;
vaultId: string;
targetUserId: string;
permissionLevel: PasswordSafePermissionLevel;
actorUserId: string;
isTenantPasswordSafeAdmin: boolean;
}): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
if (!input.isTenantPasswordSafeAdmin) {
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.actorUserId, "admin");
}
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 },
});
});
}
export async function revokeAccess(input: { tenantId: string; vaultId: string; targetUserId: string; actorUserId: string; isTenantPasswordSafeAdmin: boolean }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
if (!input.isTenantPasswordSafeAdmin) {
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.actorUserId, "admin");
}
await tx.employeePasswordSafeAccess.deleteMany({ where: { tenantId: input.tenantId, vaultId: input.vaultId, userId: input.targetUserId } });
});
}
export type VaultAccessRow = { userId: string; permissionLevel: PasswordSafePermissionLevel; grantedByUserId: string };
export async function listVaultAccess(input: { tenantId: string; vaultId: string; userId: string; isTenantPasswordSafeAdmin: boolean }): Promise<VaultAccessRow[]> {
return withTenantRLS(input.tenantId, async (tx) => {
if (!input.isTenantPasswordSafeAdmin) {
await requireVaultWithAccessWithTx(tx, input.tenantId, input.vaultId, input.userId, "admin");
}
const rows = await tx.employeePasswordSafeAccess.findMany({
where: { tenantId: input.tenantId, vaultId: input.vaultId },
select: { userId: true, permissionLevel: true, grantedByUserId: true },
orderBy: { createdAt: "asc" },
});
return rows as VaultAccessRow[];
});
}
// --- 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 }): 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: 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 },
});
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) : {};
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,
},
});
});
}
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");
await tx.employeePasswordSafeEntry.updateMany({ where: { id: input.entryId, tenantId: input.tenantId, vaultId: input.vaultId }, data: { archivedAt: new Date() } });
});
}
// --- 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).
*/
export async function revealEntryValue(
input: { tenantId: string; vaultId: string; entryId: string; userId: string; field: PasswordSafeSecretField },
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 createAuditEvent({
tenantId: input.tenantId,
actorId: input.userId,
actorType: "user",
action: "HR_PASSWORD_SAFE_REVEAL",
targetType: "EmployeePasswordSafeEntry",
targetId: input.entryId,
sourceModule: HR_AUDIT_MODULE,
dataSensitivity: "confidential",
outcome: "success",
metadata: { vaultId: input.vaultId, field: input.field },
});
if (ciphertext == null) {
return null;
}
return crypto.decrypt(input.tenantId, ciphertext);
});
}
// --- 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 },
crypto: HrFieldCrypto = getHrFieldCrypto(),
): Promise<{ vaultName: string; entries: ExportedEntry[] }> {
return withTenantRLS(input.tenantId, async (tx) => {
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 createAuditEvent({
tenantId: input.tenantId,
actorId: input.userId,
actorType: "user",
action: "HR_PASSWORD_SAFE_EXPORT",
targetType: "EmployeePasswordSafeVault",
targetId: input.vaultId,
sourceModule: HR_AUDIT_MODULE,
dataSensitivity: "confidential",
regulatoryScope: ["gdpr"],
outcome: "success",
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 };
});
}