429 lines
19 KiB
TypeScript
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 };
|
|
});
|
|
}
|