Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/tenant-isolation-report.ts
T

312 lines
12 KiB
TypeScript

#!/usr/bin/env tsx
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const REPORT_PATH = "docs/security/tenant-isolation-report.md";
export const S04_SUMMARY_PATH = "docs/security/tenant-isolation-s04-summary.md";
export const REQUIRED_ISOLATION_TEST_PATHS = [
"apps/web/app/test/isolation/admin-routes.test.ts",
"apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
] as const;
export const ISOLATION_TEST_ROOTS = ["apps/web/app/test/isolation", "apps/api/src/isolation"] as const;
type EvidenceSource = {
path: string;
content: string;
};
export type IsolationTestEvidence = {
path: string;
testCount: number;
boundaries: string[];
denyPathEvidence: string[];
};
export type TenantIsolationEvidence = {
generatedFrom: string[];
tests: IsolationTestEvidence[];
s04: {
path: string;
verificationStatus: string;
summary: string;
patterns: string[];
};
};
export type CheckTenantIsolationReportResult = {
ok: boolean;
reportPath: string;
failures: string[];
};
type CliOptions = {
rootDir: string;
check: boolean;
};
const TEST_CASE_PATTERN = /\b(?:it|test)\(\s*(["'`])([\s\S]*?)\1\s*,/g;
const TENANT_ID_PATTERN = /\b(?:tenant|row|relationship|api[_-]?key|log|user|session|token|bearer)[_-](?:session[_-])?(?:iso[_-])?[a-z0-9_-]+\b/gi;
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi;
const TOKEN_ASSIGNMENT_PATTERN = /\b(?:token|apiKey|api_key|secret|password|bearer)\s*[:=]\s*["'`][^"'`]+["'`]/gi;
const LONG_SECRET_PATTERN = /\b[A-Za-z0-9_-]{24,}\b/g;
const SOURCE_SNIPPET_PATTERN = /[`{};]/g;
const BOUNDARY_RULES: readonly { pattern: RegExp; label: string }[] = [
{ pattern: /admin permission|admin auth|adminOnly|verifyUserHasPermission/i, label: "admin permission gate" },
{ pattern: /resolver tenant|tenant, user, and entity|tenantId|userId/i, label: "tenant and user scoped resolver" },
{ pattern: /relationship resolver|parent and child|authorized rows/i, label: "relationship parent/child authorization" },
{ pattern: /row logs|API-key log|setApiKeyLogStatus|status log/i, label: "API-key status logging boundary" },
{ pattern: /reportUsage|usage/i, label: "tenant-scoped usage reporting" },
];
const DENY_RULES: readonly { pattern: RegExp; label: string }[] = [
{ pattern: /permission is denied|auth is denied|denied/i, label: "denied credentials stop downstream access" },
{ pattern: /returns 403|status: 403|, 403/i, label: "403 response for unauthorized tenant access" },
{ pattern: /not\.toHaveBeenCalled|skips|not called/i, label: "denied path skips data mutation or lookup" },
{ pattern: /foreign|tenant B|cross-tenant/i, label: "foreign tenant resource denied" },
];
function normalizeRelativePath(relativePath: string): string {
return relativePath.split(path.sep).join("/");
}
function uniqueSorted(values: Iterable<string>): string[] {
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
function readRequiredFile(rootDir: string, relativePath: string): EvidenceSource {
const normalizedPath = normalizeRelativePath(relativePath);
const absolutePath = path.join(rootDir, normalizedPath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`missing expected isolation evidence file: ${normalizedPath}`);
}
return { path: normalizedPath, content: fs.readFileSync(absolutePath, "utf8") };
}
function walkTestFiles(rootDir: string, relativeRoot: string): string[] {
const absoluteRoot = path.join(rootDir, relativeRoot);
if (!fs.existsSync(absoluteRoot)) return [];
const discovered: string[] = [];
const walk = (absoluteDir: string) => {
for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) {
const absolutePath = path.join(absoluteDir, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
} else if (entry.isFile() && /\.test\.[cm]?[tj]sx?$/.test(entry.name)) {
discovered.push(normalizeRelativePath(path.relative(rootDir, absolutePath)));
}
}
};
walk(absoluteRoot);
return discovered;
}
export function discoverIsolationTestPaths(rootDir = process.cwd()): string[] {
return uniqueSorted([
...REQUIRED_ISOLATION_TEST_PATHS,
...ISOLATION_TEST_ROOTS.flatMap((testRoot) => walkTestFiles(rootDir, testRoot)),
]);
}
function collectLabels(content: string, rules: readonly { pattern: RegExp; label: string }[]): string[] {
return uniqueSorted(rules.filter((rule) => rule.pattern.test(content)).map((rule) => rule.label));
}
function extractTestNames(content: string): string[] {
return [...content.matchAll(TEST_CASE_PATTERN)].map((match) => match[2].replace(/\s+/g, " ").trim()).filter(Boolean);
}
function parseS04Summary(source: EvidenceSource): TenantIsolationEvidence["s04"] {
const verificationStatus = source.content.match(/^verification_result:\s*(.+)$/m)?.[1]?.trim() ?? "unknown";
const headingSummary = source.content.match(/^\*\*(.+?)\*\*/m)?.[1]?.trim() ?? "S04 summary available.";
const patternBlock = source.content.match(/^patterns_established:\n([\s\S]*?)(?:\n[a-z_]+:|\n---)/m)?.[1] ?? "";
const patterns = uniqueSorted([...patternBlock.matchAll(/^\s*-\s+(.+)$/gm)].map((match) => sanitizeSummaryText(match[1])));
return {
path: source.path,
verificationStatus: sanitizeSummaryText(verificationStatus),
summary: sanitizeSummaryText(headingSummary),
patterns,
};
}
function sanitizeSummaryText(value: string): string {
return value
.replace(EMAIL_PATTERN, "[redacted email]")
.replace(TOKEN_ASSIGNMENT_PATTERN, "[redacted credential]")
.replace(UUID_PATTERN, "[redacted id]")
.replace(TENANT_ID_PATTERN, (match) => categoricalLabelFor(match))
.replace(LONG_SECRET_PATTERN, "[redacted token]")
.replace(SOURCE_SNIPPET_PATTERN, "")
.replace(/\s+/g, " ")
.trim();
}
function categoricalLabelFor(value: string): string {
const lower = value.toLowerCase();
if (lower.includes("tenant") && /(?:_a|tenanta|tenant-a| a\b)/i.test(value)) return "tenant A";
if (lower.includes("tenant") && /(?:_b|tenantb|tenant-b| b\b)/i.test(value)) return "tenant B";
if (lower.includes("admin")) return "admin";
if (lower.includes("relationship")) return "relationship record";
if (lower.includes("row")) return "row record";
if (lower.includes("api")) return "API credential";
if (lower.includes("user")) return "user session";
return "[redacted id]";
}
function summarizeIsolationTest(source: EvidenceSource): IsolationTestEvidence {
const testNames = extractTestNames(source.content).map(sanitizeSummaryText);
return {
path: source.path,
testCount: testNames.length,
boundaries: collectLabels(source.content, BOUNDARY_RULES),
denyPathEvidence: uniqueSorted([...testNames.filter((name) => /denied|403|foreign|skips/i.test(name)), ...collectLabels(source.content, DENY_RULES)]),
};
}
export function collectTenantIsolationEvidence(rootDir = process.cwd()): TenantIsolationEvidence {
const tests = discoverIsolationTestPaths(rootDir).map((testPath) => summarizeIsolationTest(readRequiredFile(rootDir, testPath))).sort((a, b) =>
a.path.localeCompare(b.path),
);
const s04Source = readRequiredFile(rootDir, S04_SUMMARY_PATH);
const generatedFrom = uniqueSorted([...tests.map((test) => test.path), s04Source.path]);
return { generatedFrom, tests, s04: parseS04Summary(s04Source) };
}
function renderList(values: string[]): string {
if (values.length === 0) return "- none observed";
return values.map((value) => `- ${value}`).join("\n");
}
export function renderTenantIsolationReport(evidence: TenantIsolationEvidence): string {
const testRows = evidence.tests.map((test) => {
return `| ${test.path} | ${test.testCount} | ${test.boundaries.join("; ") || "none observed"} | ${test.denyPathEvidence.join("; ") || "none observed"} |`;
});
return [
"# Tenant Isolation Report",
"",
"<!-- Generated by scripts/tenant-isolation-report.ts. Do not edit by hand. -->",
"",
"## Scope",
"",
"This report summarizes committed Tenant Isolation tenant-isolation tests and S04 evidence using categorical labels only. Fixture identifiers, credentials, row IDs, relationship IDs, tenant IDs, emails, tokens, and source snippets are intentionally redacted from this document.",
"",
"## Source Evidence",
"",
renderList(evidence.generatedFrom),
"",
"## Boundary Matrix",
"",
"| Source | Test count | Checked access boundaries | Deny-path evidence |",
"| --- | ---: | --- | --- |",
...testRows,
"",
"## Prior Slice Evidence",
"",
`- Source: ${evidence.s04.path}`,
`- Verification status: ${evidence.s04.verificationStatus}`,
`- Summary: ${evidence.s04.summary}`,
"- Established patterns:",
...evidence.s04.patterns.map((pattern) => ` - ${pattern}`),
"",
"## Deny-Path Coverage",
"",
"The boundary matrix records denial outcomes for unauthorized credentials, foreign tenant resources, skipped downstream mutations/lookups, and 403 responses. These entries are derived from test names and assertion patterns rather than concrete fixture values.",
"",
"## Redaction Rules",
"",
"The renderer reports tenant A, tenant B, admin, user session, API credential, row record, and relationship record categories instead of concrete fixture values.",
"",
"## Verification Commands",
"",
"```sh",
"pnpm exec eslint scripts/tenant-isolation-report.ts scripts/tenant-isolation-report.test.ts --max-warnings=0",
"pnpm exec vitest run scripts/tenant-isolation-report.test.ts",
"pnpm exec tsx scripts/tenant-isolation-report.ts --check",
"pnpm exec tsx scripts/docs-link-check.ts",
"```",
"",
"## Redaction Contract",
"",
"The renderer reports tenant A, tenant B, admin, user session, API credential, row record, and relationship record categories instead of concrete fixture values.",
"",
].join("\n");
}
export function writeTenantIsolationReport(rootDir = process.cwd()): string {
const reportPath = path.join(rootDir, REPORT_PATH);
const report = renderTenantIsolationReport(collectTenantIsolationEvidence(rootDir));
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, report, "utf8");
return reportPath;
}
export function checkTenantIsolationReport(rootDir = process.cwd()): CheckTenantIsolationReportResult {
const reportPath = path.join(rootDir, REPORT_PATH);
const expected = renderTenantIsolationReport(collectTenantIsolationEvidence(rootDir));
if (!fs.existsSync(reportPath)) {
return { ok: false, reportPath, failures: [`missing generated report: ${REPORT_PATH}`] };
}
const actual = fs.readFileSync(reportPath, "utf8");
if (actual !== expected) {
return { ok: false, reportPath, failures: [`stale generated report: ${REPORT_PATH}`] };
}
return { ok: true, reportPath, failures: [] };
}
function parseCliOptions(args: string[]): CliOptions {
const options: CliOptions = { rootDir: process.cwd(), check: false };
for (const arg of args) {
if (arg === "--check") {
options.check = true;
} else if (arg.startsWith("--root=")) {
options.rootDir = path.resolve(arg.slice("--root=".length));
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
function formatCliResult(status: "PASS" | "FAIL", reportPath: string, details: string[]): string {
const relativeReportPath = normalizeRelativePath(path.relative(process.cwd(), reportPath));
return [`${status} tenant-isolation-report ${relativeReportPath}`, ...details].join("\n") + "\n";
}
export function runTenantIsolationReportCli(args = process.argv.slice(2)): number {
try {
const options = parseCliOptions(args);
if (options.check) {
const result = checkTenantIsolationReport(options.rootDir);
const output = formatCliResult(result.ok ? "PASS" : "FAIL", result.reportPath, result.failures.map((failure) => `FAIL ${failure}`));
(result.ok ? process.stdout : process.stderr).write(output);
return result.ok ? 0 : 1;
}
const reportPath = writeTenantIsolationReport(options.rootDir);
process.stdout.write(formatCliResult("PASS", reportPath, ["wrote generated report"]));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : "unknown tenant isolation report failure";
process.stderr.write(`FAIL tenant-isolation-report ${message}\n`);
return 1;
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exitCode = runTenantIsolationReportCli();
}