diff --git a/admin-compliance/app/api/sdk/v1/master-controls/route.ts b/admin-compliance/app/api/sdk/v1/master-controls/route.ts index 4dd70f5f..009e14a9 100644 --- a/admin-compliance/app/api/sdk/v1/master-controls/route.ts +++ b/admin-compliance/app/api/sdk/v1/master-controls/route.ts @@ -10,6 +10,19 @@ const dbUrl = process.env.COMPLIANCE_DATABASE_URL || const pool = new Pool({ connectionString: dbUrl }) +// handleMeta returns global (filter-independent) counts incl. a ~2s member-join +// facet. It is refetched on every filter change, so cache it briefly. +let metaCache: { at: number; data: unknown } | null = null +const META_TTL_MS = 120_000 + +type MCListRow = { + id: string; control_id: string; title: string; objective: string + severity: string; category: string; total_controls: number + phases_covered: string[] | null; created_at: string + verification_method: string | null; use_cases: string[] | null + primary_regulation: string | null +} + /** * MC API that returns data in the same format as the canonical controls * endpoint. This allows the MC page to reuse ControlListView components. @@ -43,17 +56,14 @@ export async function GET(request: NextRequest) { } } -async function handleControls(params: URLSearchParams) { - const search = params.get('search') || '' - const limit = Math.min(parseInt(params.get('limit') || '50'), 200) - const offset = parseInt(params.get('offset') || '0') - const sort = params.get('sort') || 'control_id' - const order = params.get('order') === 'desc' ? 'DESC' : 'ASC' - +// Shared WHERE builder so list + count stay in lock-step (incl. the +// use_case / verification_method / source_regulation mapping filters). +function buildControlsWhere(params: URLSearchParams): { where: string; args: unknown[]; idx: number } { let where = "WHERE 1=1" const args: unknown[] = [] let idx = 1 + const search = params.get('search') || '' if (search) { where += ` AND mc.canonical_name ILIKE $${idx}` args.push(`%${search}%`) @@ -61,11 +71,9 @@ async function handleControls(params: URLSearchParams) { } const severity = params.get('severity') || '' - if (severity) { - if (severity === 'high') { where += ` AND mc.total_controls > 100` } - else if (severity === 'medium') { where += ` AND mc.total_controls BETWEEN 20 AND 100` } - else if (severity === 'low') { where += ` AND mc.total_controls < 20` } - } + if (severity === 'high') { where += ` AND mc.total_controls > 100` } + else if (severity === 'medium') { where += ` AND mc.total_controls BETWEEN 20 AND 100` } + else if (severity === 'low') { where += ` AND mc.total_controls < 20` } const domain = params.get('domain') || '' if (domain) { @@ -74,6 +82,67 @@ async function handleControls(params: URLSearchParams) { idx++ } + const useCase = params.get('use_case') || '' + const primaryOnly = params.get('primary') === '1' + if (useCase) { + where += ` AND EXISTS (SELECT 1 FROM compliance.mc_use_case_mappings m + WHERE m.master_control_uuid = mc.id AND m.use_case = $${idx}${primaryOnly ? ' AND m.is_primary' : ''})` + args.push(useCase) + idx++ + } + + const verification = params.get('verification_method') || '' + if (verification === '__none__') { + where += ` AND NOT EXISTS (SELECT 1 FROM compliance.mc_verification v + WHERE v.master_control_uuid = mc.id)` + } else if (verification) { + where += ` AND EXISTS (SELECT 1 FROM compliance.mc_verification v + WHERE v.master_control_uuid = mc.id AND v.verification_method = $${idx})` + args.push(verification) + idx++ + } + + const regulation = params.get('source_regulation') || '' + if (regulation) { + where += ` AND EXISTS (SELECT 1 FROM compliance.mc_regulations r + WHERE r.master_control_uuid = mc.id AND r.source_regulation = $${idx})` + args.push(regulation) + idx++ + } + + const mapped = params.get('mapped') || '' + if (mapped === 'mapped') { + where += ` AND EXISTS (SELECT 1 FROM compliance.mc_use_case_mappings m + WHERE m.master_control_uuid = mc.id)` + } else if (mapped === 'unmapped') { + where += ` AND NOT EXISTS (SELECT 1 FROM compliance.mc_use_case_mappings m + WHERE m.master_control_uuid = mc.id)` + } + + // Member-based filter: an MC matches if ANY of its atomic members has the + // category. Only category/severity/release_state are populated on the + // deduplicated members; evidence_type, target_audience and source_citation + // are 100% NULL there, so those canonical filters cannot apply to MCs + // without an upstream backfill (wiring them would just return 0). + const category = params.get('category') || '' + if (category) { + where += ` AND EXISTS (SELECT 1 FROM compliance.master_control_members mcm + JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid + WHERE mcm.master_control_uuid = mc.id AND cc.category = $${idx})` + args.push(category); idx++ + } + + return { where, args, idx } +} + +async function handleControls(params: URLSearchParams) { + const limit = Math.min(parseInt(params.get('limit') || '50'), 200) + const offset = parseInt(params.get('offset') || '0') + const sort = params.get('sort') || 'control_id' + const order = params.get('order') === 'desc' ? 'DESC' : 'ASC' + + const { where, args, idx } = buildControlsWhere(params) + const sortCol = sort === 'control_id' ? 'mc.master_control_id' : sort === 'created_at' ? 'mc.created_at' : sort === 'source' ? 'mc.canonical_name' : 'mc.master_control_id' @@ -90,7 +159,14 @@ async function handleControls(params: URLSearchParams) { mc.total_controls, mc.phases_covered, mc.id, - mc.created_at + mc.created_at, + (SELECT v.verification_method FROM compliance.mc_verification v + WHERE v.master_control_uuid = mc.id) as verification_method, + (SELECT array_agg(m.use_case ORDER BY m.is_primary DESC, m.use_case) + FROM compliance.mc_use_case_mappings m + WHERE m.master_control_uuid = mc.id) as use_cases, + (SELECT r.source_regulation FROM compliance.mc_regulations r + WHERE r.master_control_uuid = mc.id AND r.is_primary LIMIT 1) as primary_regulation FROM compliance.master_controls mc ${where} ORDER BY ${sortCol} ${order} @@ -98,7 +174,7 @@ async function handleControls(params: URLSearchParams) { `, args) // Map to canonical control format - const controls = res.rows.map(r => ({ + const controls = res.rows.map((r: MCListRow) => ({ id: r.id, control_id: r.control_id, title: r.title, @@ -106,10 +182,11 @@ async function handleControls(params: URLSearchParams) { severity: r.severity, category: r.category, release_state: 'active', - source_citation: null, - verification_method: null, + source_citation: r.primary_regulation ? { source: r.primary_regulation } : null, + verification_method: r.verification_method, evidence_type: null, target_audience: [], + use_cases: r.use_cases || [], requirements: [], test_procedure: [], evidence: [], @@ -126,22 +203,17 @@ async function handleControls(params: URLSearchParams) { } async function handleCount(params: URLSearchParams) { - const search = params.get('search') || '' - let where = "WHERE 1=1" - const args: unknown[] = [] - - if (search) { - where += ` AND mc.canonical_name ILIKE $1` - args.push(`%${search}%`) - } - + const { where, args } = buildControlsWhere(params) const res = await pool.query( `SELECT count(*) FROM compliance.master_controls mc ${where}`, args ) return NextResponse.json({ total: parseInt(res.rows[0].count) }) } -async function handleMeta(params: URLSearchParams) { +async function handleMeta(_params: URLSearchParams) { + if (metaCache && Date.now() - metaCache.at < META_TTL_MS) { + return NextResponse.json(metaCache.data) + } const res = await pool.query(` SELECT count(*) as total, count(CASE WHEN total_controls > 100 THEN 1 END) as high_count, @@ -158,21 +230,59 @@ async function handleMeta(params: URLSearchParams) { GROUP BY 1 ORDER BY 2 DESC LIMIT 30 `) - return NextResponse.json({ - total: parseInt(r.total), + // Mapping distribution + coverage + member-derived category facet. Only + // category is populated on the deduplicated members (evidence_type / + // target_audience are NULL there), so it is the one canonical facet we surface. + const [ucRes, vRes, regRes, mappedRes, catRes] = await Promise.all([ + pool.query(`SELECT use_case, count(DISTINCT master_control_uuid) c + FROM compliance.mc_use_case_mappings GROUP BY 1 ORDER BY 2 DESC`), + pool.query(`SELECT verification_method, count(*) c + FROM compliance.mc_verification GROUP BY 1 ORDER BY 2 DESC`), + pool.query(`SELECT source_regulation, count(DISTINCT master_control_uuid) c + FROM compliance.mc_regulations GROUP BY 1 ORDER BY 2 DESC LIMIT 200`), + pool.query(`SELECT count(DISTINCT master_control_uuid) c + FROM compliance.mc_use_case_mappings`), + pool.query(`SELECT cc.category v, count(DISTINCT mcm.master_control_uuid) c + FROM compliance.master_control_members mcm + JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid + WHERE cc.category IS NOT NULL GROUP BY 1 ORDER BY 2 DESC`), + ]) + const facet = (rows: Array<{ v: string; c: string }>) => + Object.fromEntries(rows.filter(x => x.v).map(x => [x.v, parseInt(x.c)])) + + const total = parseInt(r.total) + const mappedTotal = parseInt(mappedRes.rows[0].c) + + const payload = { + total, severity_counts: { high: parseInt(r.high_count), medium: parseInt(r.medium_count), low: parseInt(r.low_count), }, - domains: domainRes.rows.map(d => ({ domain: d.domain, count: parseInt(d.count) })), + domains: domainRes.rows.map((d: { domain: string; count: string }) => + ({ domain: d.domain, count: parseInt(d.count) })), sources: [], no_source_count: 0, - release_state_counts: { active: parseInt(r.total) }, - verification_method_counts: {}, - category_counts: {}, + release_state_counts: { active: total }, + verification_method_counts: Object.fromEntries( + vRes.rows.map((x: { verification_method: string; c: string }) => + [x.verification_method, parseInt(x.c)])), + category_counts: facet(catRes.rows), evidence_type_counts: {}, - }) + use_case_counts: Object.fromEntries( + ucRes.rows + .filter((x: { use_case: string | null }) => x.use_case) + .map((x: { use_case: string; c: string }) => [x.use_case, parseInt(x.c)])), + regulations: regRes.rows + .filter((x: { source_regulation: string | null }) => x.source_regulation) + .map((x: { source_regulation: string; c: string }) => + ({ source_regulation: x.source_regulation, count: parseInt(x.c) })), + mapped_total: mappedTotal, + unmapped_count: total - mappedTotal, + } + metaCache = { at: Date.now(), data: payload } + return NextResponse.json(payload) } async function handleDetail(params: URLSearchParams) { @@ -201,6 +311,23 @@ async function handleDetail(params: URLSearchParams) { LIMIT 100 `, [mc.id]) + // Use-case / verification / regulation mapping (the "regulation→code" lineage) + const mapRes = await pool.query(` + SELECT + (SELECT json_agg(json_build_object('use_case', m.use_case, 'is_primary', m.is_primary) + ORDER BY m.is_primary DESC, m.use_case) + FROM compliance.mc_use_case_mappings m WHERE m.master_control_uuid = $1) as use_cases, + (SELECT v.verification_method FROM compliance.mc_verification v + WHERE v.master_control_uuid = $1) as verification_method, + (SELECT json_agg(json_build_object('source_regulation', r.source_regulation, + 'is_primary', r.is_primary, 'member_count', r.member_count) + ORDER BY r.is_primary DESC, r.member_count DESC) + FROM compliance.mc_regulations r WHERE r.master_control_uuid = $1) as regulations + `, [mc.id]) + const mapping = mapRes.rows[0] || {} + const regs = mapping.regulations || [] + const primaryReg = regs.find((x: { is_primary: boolean }) => x.is_primary) || regs[0] + return NextResponse.json({ id: mc.id, control_id: mc.control_id, @@ -220,7 +347,10 @@ async function handleDetail(params: URLSearchParams) { evidence: [], open_anchors: [], target_audience: [], - source_citation: null, + verification_method: mapping.verification_method || null, + use_cases: mapping.use_cases || [], + regulations: regs, + source_citation: primaryReg ? { source: primaryReg.source_regulation } : null, scope: { platforms: [], components: [], data_classes: [] }, risk_score: null, implementation_effort: null, diff --git a/admin-compliance/app/sdk/control-library/__tests__/mcMappingLabels.test.ts b/admin-compliance/app/sdk/control-library/__tests__/mcMappingLabels.test.ts new file mode 100644 index 00000000..6d29274a --- /dev/null +++ b/admin-compliance/app/sdk/control-library/__tests__/mcMappingLabels.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { + USE_CASE_LABELS, MC_VERIFICATION_LABELS, useCaseLabel, mcVerificationLabel, +} from '../components/mcMappingLabels' + +describe('useCaseLabel', () => { + it('maps known use-case keys to German labels', () => { + expect(useCaseLabel('impressum')).toBe('Impressum') + expect(useCaseLabel('cookie_banner')).toBe('Cookie-Banner') + expect(useCaseLabel('code_security')).toBe('Code Security') + expect(useCaseLabel('dse')).toBe('Datenschutzerklärung') + }) + + it('humanizes an unknown key instead of showing the raw slug', () => { + expect(useCaseLabel('brand_new_thing')).toBe('Brand New Thing') + }) +}) + +describe('mcVerificationLabel', () => { + it('maps the master-control verification methods', () => { + expect(mcVerificationLabel('source_code')).toBe('Source Code') + expect(mcVerificationLabel('it_process')).toBe('IT-Prozess') + expect(mcVerificationLabel('network')).toBe('Netzwerk/Infra') + expect(mcVerificationLabel('document')).toBe('Dokument') + }) + + it('humanizes an unknown method', () => { + expect(mcVerificationLabel('telepathy')).toBe('Telepathy') + }) +}) + +describe('label coverage', () => { + it('labels the security/code use cases (>=50% code+process focus)', () => { + for (const k of ['code_security', 'network_security', 'cra', 'isms', 'tisax']) { + expect(USE_CASE_LABELS[k]).toBeTruthy() + } + }) + + it('covers every master-control verification method', () => { + for (const m of ['document', 'source_code', 'network', 'it_process', 'hybrid', 'manual']) { + expect(MC_VERIFICATION_LABELS[m]).toBeTruthy() + } + }) +}) diff --git a/admin-compliance/app/sdk/control-library/components/ControlListView.tsx b/admin-compliance/app/sdk/control-library/components/ControlListView.tsx index 719ecb96..90dce7b7 100644 --- a/admin-compliance/app/sdk/control-library/components/ControlListView.tsx +++ b/admin-compliance/app/sdk/control-library/components/ControlListView.tsx @@ -12,6 +12,7 @@ import { VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS, } from './helpers' import { ControlsMeta } from './useControlLibraryState' +import { useCaseLabel, mcVerificationLabel } from './mcMappingLabels' import { GeneratorModal } from './GeneratorModal' interface ControlListViewProps { @@ -34,6 +35,10 @@ interface ControlListViewProps { domainFilter: string stateFilter: string verificationFilter: string + useCaseFilter: string + primaryOnly: boolean + regulationFilter: string + mappedFilter: string categoryFilter: string evidenceTypeFilter: string audienceFilter: string @@ -46,6 +51,10 @@ interface ControlListViewProps { setDomainFilter: (v: string) => void setStateFilter: (v: string) => void setVerificationFilter: (v: string) => void + setUseCaseFilter: (v: string) => void + setPrimaryOnly: (v: boolean) => void + setRegulationFilter: (v: string) => void + setMappedFilter: (v: string) => void setCategoryFilter: (v: string) => void setEvidenceTypeFilter: (v: string) => void setAudienceFilter: (v: string) => void @@ -71,10 +80,12 @@ export function ControlListView({ reviewCount, bulkProcessing, showStats, processedStats, showGenerator, currentPage, totalPages, sortBy, searchQuery, severityFilter, domainFilter, stateFilter, - verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, + verificationFilter, useCaseFilter, primaryOnly, regulationFilter, mappedFilter, + categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, setSearchQuery, setSeverityFilter, setDomainFilter, setStateFilter, - setVerificationFilter, setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter, + setVerificationFilter, setUseCaseFilter, setPrimaryOnly, setRegulationFilter, setMappedFilter, + setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter, setSourceFilter, setTypeFilter, setHideDuplicates, setSortBy, setShowStats, setShowGenerator, setCurrentPage, onSelectControl, onCreateMode, onEnterReview, onBulkReject, onRefresh, onLoadStats, onFullReload, @@ -176,18 +187,60 @@ export function ControlListView({ className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" /> Duplikate ausblenden + {meta?.use_case_counts && ( + + )} + {meta?.use_case_counts && useCaseFilter && ( + + )} + {meta?.regulations && meta.regulations.length > 0 && ( + + )} + {meta?.mapped_total != null && ( + + )}