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:
Hardik 2026-06-22 23:29:11 +05:30
parent 53fbdb5c53
commit 06ff587024
4 changed files with 132 additions and 4 deletions

View file

@ -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,

View file

@ -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;
}

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

View file

@ -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();
});
});
});