fix(crewing): mask Aadhaar/PAN document numbers server-side
The crew profile page passed SeafarerDocument.number to the client unmasked for all roles and all doc types, exposing full Aadhaar/PAN identity numbers to MPO / Manager / Site staff — contradicting the field's PII annotation and §6 / Roles-and-Permissions §3 (Aadhaar/PAN are gated to Accounts/SuperUser, same as the bank account number). - crew-pii.ts: add documentNumberValue(number, docType, role) — masks AADHAAR / PAN for non-privileged roles via the existing canViewFullBankEpf gate + maskTail; non-identity docs (passport, CDC, STCW…) pass through; preserves the string|null contract. - crew/[id]/page.tsx: mask the number server-side before it crosses to the client. - Tests: unit cases for the helper; an integration test that invokes the server component and asserts the documents prop is masked for MANAGER/SITE_STAFF/MPO and full for ACCOUNTS/SUPERUSER. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
53fbdb5c53
commit
06ff587024
4 changed files with 132 additions and 4 deletions
|
|
@ -2,7 +2,7 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { canViewSalary, bankEpfValue } from "@/lib/crew-pii";
|
||||
import { canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { CrewProfile } from "./crew-profile";
|
||||
import type { Metadata } from "next";
|
||||
|
|
@ -68,7 +68,7 @@ export default async function CrewProfilePage({ params }: { params: Promise<{ id
|
|||
documents={c.documents.map((d) => ({
|
||||
id: d.id,
|
||||
docType: d.docType,
|
||||
number: d.number,
|
||||
number: documentNumberValue(d.number, d.docType, role),
|
||||
issueDate: d.issueDate?.toISOString() ?? null,
|
||||
expiryDate: d.expiryDate?.toISOString() ?? null,
|
||||
verificationStatus: d.verificationStatus,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Role } from "@prisma/client";
|
||||
import type { Role, SeafarerDocType } from "@prisma/client";
|
||||
|
||||
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
|
||||
// Bank account / EPF identity numbers are full only for Accounts (and SuperUser);
|
||||
|
|
@ -8,6 +8,11 @@ export function canViewFullBankEpf(role: Role): boolean {
|
|||
return role === "ACCOUNTS" || role === "SUPERUSER";
|
||||
}
|
||||
|
||||
// Identity documents whose number is itself restricted PII (Aadhaar/PAN), gated
|
||||
// like bank/EPF (§6, Roles-and-Permissions §3). Other seafarer documents
|
||||
// (passport, CDC, STCW, COC, medical…) are not number-restricted.
|
||||
const RESTRICTED_DOC_TYPES = new Set<SeafarerDocType>(["AADHAAR", "PAN"]);
|
||||
|
||||
export function canViewSalary(role: Role): boolean {
|
||||
// Office roles see salary; site staff see status only (§6, R7).
|
||||
return role !== "SITE_STAFF";
|
||||
|
|
@ -26,3 +31,18 @@ export function bankEpfValue(value: string | null | undefined, role: Role): stri
|
|||
if (!value) return "—";
|
||||
return canViewFullBankEpf(role) ? value : maskTail(value);
|
||||
}
|
||||
|
||||
// A seafarer document number, masked for non-privileged roles when the document
|
||||
// type is itself restricted PII (Aadhaar/PAN). Non-restricted documents pass
|
||||
// through unchanged. Preserves the `string | null` contract the profile expects.
|
||||
export function documentNumberValue(
|
||||
value: string | null | undefined,
|
||||
docType: SeafarerDocType,
|
||||
role: Role
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
if (RESTRICTED_DOC_TYPES.has(docType) && !canViewFullBankEpf(role)) {
|
||||
return maskTail(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
87
App/tests/integration/crew-pii-page.test.ts
Normal file
87
App/tests/integration/crew-pii-page.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Integration test for the server-side PII masking on the crew profile page.
|
||||
* Identity-document numbers (Aadhaar/PAN) must be masked BEFORE they cross to the
|
||||
* client component — full only for Accounts/SuperUser (Crewing-Implementation-Spec
|
||||
* §6 / Roles-and-Permissions §3). We invoke the server component and inspect the
|
||||
* props it hands to <CrewProfile>, so a regression that passes raw numbers to the
|
||||
* client is caught here.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
|
||||
// The integration runner compiles the page's JSX to classic React.createElement
|
||||
// without injecting React; provide it so invoking the server component works.
|
||||
(globalThis as unknown as { React: typeof React }).React = React;
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() }));
|
||||
// The client component is irrelevant to this test — we read element.props directly.
|
||||
vi.mock("@/app/(portal)/crewing/crew/[id]/crew-profile", () => ({ CrewProfile: () => null }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import CrewProfilePage from "@/app/(portal)/crewing/crew/[id]/page";
|
||||
import { makeSession } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
const AADHAAR = "123456789012";
|
||||
const PAN = "ABCDE1234F";
|
||||
|
||||
let crewId: string;
|
||||
const as = (role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(`u-${role}`, role));
|
||||
|
||||
// Pull the documents prop the page would pass to the client component.
|
||||
async function docsFor(role: Role) {
|
||||
as(role);
|
||||
const element = (await CrewProfilePage({ params: Promise.resolve({ id: crewId }) })) as {
|
||||
props: { documents: Array<{ docType: string; number: string | null }> };
|
||||
};
|
||||
return element.props.documents;
|
||||
}
|
||||
const numberFor = (docs: Array<{ docType: string; number: string | null }>, docType: string) =>
|
||||
docs.find((d) => d.docType === docType)?.number ?? null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const c = await db.crewMember.create({
|
||||
data: { name: "PII Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-PII${Date.now() % 100000}` },
|
||||
});
|
||||
crewId = c.id;
|
||||
await db.seafarerDocument.createMany({
|
||||
data: [
|
||||
{ crewMemberId: c.id, docType: "AADHAAR", number: AADHAAR },
|
||||
{ crewMemberId: c.id, docType: "PAN", number: PAN },
|
||||
{ crewMemberId: c.id, docType: "PASSPORT", number: "P1234567" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
afterAll(async () => {
|
||||
await db.seafarerDocument.deleteMany({ where: { crewMemberId: crewId } });
|
||||
await db.crewMember.deleteMany({ where: { id: crewId } });
|
||||
});
|
||||
|
||||
describe("crew profile — identity-document masking (server-side)", () => {
|
||||
it("masks Aadhaar/PAN for a MANAGER", async () => {
|
||||
const docs = await docsFor("MANAGER");
|
||||
expect(numberFor(docs, "AADHAAR")).toBe("•••• 9012");
|
||||
expect(numberFor(docs, "PAN")).toBe("•••• 234F");
|
||||
// Non-identity documents are not restricted.
|
||||
expect(numberFor(docs, "PASSPORT")).toBe("P1234567");
|
||||
});
|
||||
|
||||
it("masks Aadhaar/PAN for SITE_STAFF and the MPO too", async () => {
|
||||
expect(numberFor(await docsFor("SITE_STAFF"), "AADHAAR")).toBe("•••• 9012");
|
||||
expect(numberFor(await docsFor("MANNING"), "PAN")).toBe("•••• 234F");
|
||||
});
|
||||
|
||||
it("shows Aadhaar/PAN in full to ACCOUNTS and SUPERUSER", async () => {
|
||||
const acc = await docsFor("ACCOUNTS");
|
||||
expect(numberFor(acc, "AADHAAR")).toBe(AADHAAR);
|
||||
expect(numberFor(acc, "PAN")).toBe(PAN);
|
||||
expect(numberFor(await docsFor("SUPERUSER"), "AADHAAR")).toBe(AADHAAR);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { maskTail, canViewFullBankEpf, canViewSalary, bankEpfValue } from "@/lib/crew-pii";
|
||||
import { maskTail, canViewFullBankEpf, canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii";
|
||||
|
||||
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
|
||||
describe("crew PII masking", () => {
|
||||
|
|
@ -43,4 +43,25 @@ describe("crew PII masking", () => {
|
|||
expect(bankEpfValue(null, "ACCOUNTS")).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentNumberValue", () => {
|
||||
it("masks Aadhaar/PAN numbers for non-privileged roles", () => {
|
||||
expect(documentNumberValue("123456789012", "AADHAAR", "MANAGER")).toBe("•••• 9012");
|
||||
expect(documentNumberValue("123456789012", "AADHAAR", "MANNING")).toBe("•••• 9012");
|
||||
expect(documentNumberValue("ABCDE1234F", "PAN", "SITE_STAFF")).toBe("•••• 234F");
|
||||
});
|
||||
it("shows Aadhaar/PAN in full to Accounts and SuperUser", () => {
|
||||
expect(documentNumberValue("123456789012", "AADHAAR", "ACCOUNTS")).toBe("123456789012");
|
||||
expect(documentNumberValue("ABCDE1234F", "PAN", "SUPERUSER")).toBe("ABCDE1234F");
|
||||
});
|
||||
it("does not restrict non-identity documents for any role", () => {
|
||||
expect(documentNumberValue("P1234567", "PASSPORT", "SITE_STAFF")).toBe("P1234567");
|
||||
expect(documentNumberValue("CDC-99", "CDC", "MANNING")).toBe("CDC-99");
|
||||
expect(documentNumberValue("STCW-1", "STCW", "MANAGER")).toBe("STCW-1");
|
||||
});
|
||||
it("returns null for an empty number regardless of type/role", () => {
|
||||
expect(documentNumberValue(null, "AADHAAR", "ACCOUNTS")).toBeNull();
|
||||
expect(documentNumberValue("", "PASSPORT", "MANAGER")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue