Assessor_2.0/src/compliance/ui/compliance-work-facets.ts

127 lines
6.8 KiB
TypeScript

import { useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { useApp } from '../../app/AppProvider';
import { useEnginePreferences } from '../../app/engine-preferences';
import type { FindingRecord } from '../../domain/finding';
import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup';
import type { DynamicFacet } from '../../features/search-filter/dynamic-facets';
import type { RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } from './decision-revision';
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID] as const;
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
export interface ComplianceWorkFacetIndex {
facets: [DynamicFacet, DynamicFacet];
}
const indexes = new WeakMap<RegulatoryControlOverviewResponse, ComplianceWorkFacetIndex>();
/** One shared index per loaded response, so every Work consumer uses identical cache keys. */
export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResponse): ComplianceWorkFacetIndex {
const existing = indexes.get(response);
if (existing) return existing;
const byOccurrence = new Map<string, Set<string>>();
const bySetting = new Map<string, Set<string>>();
const add = (map: Map<string, Set<string>>, key: string, controlId: string) => map.set(key, (map.get(key) ?? new Set()).add(controlId));
for (const control of response.controls) {
for (const finding of control.findings) {
if (finding.sourceKind === 'scanner-finding') add(byOccurrence, finding.sourceResultId, control.controlId);
else if (finding.sourceRuleId) add(bySetting, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, control.controlId);
}
}
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
const controlsFor = (record: FindingRecord): string[] => {
if (record.findingOccurrenceId) return [...byOccurrence.get(record.findingOccurrenceId) ?? []];
const identity = gpoWorkSettingIdentity(record.id);
return identity ? [...bySetting.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
};
const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
const index: ComplianceWorkFacetIndex = {
facets: [{
id: ISO_CONTROL_FACET_ID,
label: 'ISO control',
source: 'derived',
kind: 'category',
description: 'Compliance Engine: findings and GPO settings with this accepted ISO/IEC 27002 control. It does not change results.',
values: controls.map(control => ({ value: control.controlId, label: `${control.annexAControlId}${control.title ? ` · ${control.title}` : ''}`, count: 0 })),
valuesFor: controlsFor
}, {
id: ISO_THEME_FACET_ID,
label: 'ISO theme',
source: 'derived',
kind: 'category',
description: 'Compliance Engine: findings and GPO settings with an accepted ISO/IEC 27002 control of this theme. It does not change results.',
// Facet values are matched in lower case; the label keeps the ISO spelling.
values: THEMES.filter(theme => response.controls.some(control => control.theme === theme))
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))]
}]
};
indexes.set(response, index);
return index;
}
/** Adds the ISO facets directly before the permanent final `Source: Scan` control. */
export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWorkFacetIndex | undefined): DynamicFacet[] {
if (!index) return facets;
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
return sourceScan < 0 ? [...facets, ...index.facets]
: [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)];
}
const assessorApi = new AssessorApiClient();
// One request per timeslot and decision revision, shared by every Work consumer.
const requests = new Map<string, Promise<RegulatoryControlOverviewResponse>>();
const ready = new Map<string, RegulatoryControlOverviewResponse>();
const lastReadyByScope = new Map<string, RegulatoryControlOverviewResponse>();
/** The ISO facet index for Work, or undefined while the engine is off or nothing is loaded yet. */
export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined {
const app = useApp();
const available = useEnginePreferences().compliance;
const revision = useRegulatoryDecisionRevision();
const assessmentIds = [...(app.activeAssessmentIds ?? [])].sort();
const scanRunIds = [...(app.activeScanRunIds ?? [])].sort();
const projectId = app.targetProjectId;
const scope = JSON.stringify([projectId, assessmentIds, scanRunIds]);
const key = `${scope}\u0000${revision}`;
const enabled = available && app.backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
const [, setLoaded] = useState(0);
useEffect(() => {
if (!enabled || !projectId || ready.has(key)) return;
let active = true;
let request = requests.get(key);
if (!request) {
request = assessorApi.getRegulatoryControlAssignments(projectId, { assessmentIds, scanRunIds });
requests.set(key, request);
request.then(response => {
ready.set(key, response);
lastReadyByScope.set(scope, response);
while (ready.size > 8) ready.delete(ready.keys().next().value!);
while (lastReadyByScope.size > 4) lastReadyByScope.delete(lastReadyByScope.keys().next().value!);
}, () => undefined).finally(() => requests.delete(key));
}
request.then(() => { if (active) setLoaded(value => value + 1); }, () => undefined);
return () => { active = false; };
}, [enabled, key]); // eslint-disable-line react-hooks/exhaustive-deps
if (!enabled) return undefined;
// While a new decision revision of the same timeslot loads, the last loaded state keeps an active filter valid.
const response = ready.get(key) ?? lastReadyByScope.get(scope);
return response ? complianceWorkFacetIndex(response) : undefined;
}
/** Test support. */
export function resetComplianceWorkFacetsForTests(): void {
requests.clear();
ready.clear();
lastReadyByScope.clear();
}