Use-Case-Mapping-Filter für Master Controls + Mapper-Präzisionsfix
CI / detect-changes (push) Successful in 14s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 7s
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m23s
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 34s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 14s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 7s
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m23s
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 34s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Phase 2: Live-Filter an /sdk/master-controls (Use Case, Quell-Regulierung, Verifikations-Methode, Coverage, Primärzweck-Toggle, category via Member-EXISTS). API mit EXISTS-Filtern + gecachten Meta-Counts in master-controls/route.ts. Phase A: neue UseCase telekommunikation + Fix der Impressum-Fehlrouten im Register (TKG/AT-TKG->telekommunikation, telemedien->dse, GewO->handelsrecht); echte Impressum-Quellen (TMG/Mediengesetz) bleiben impressum. Deterministischer Seed aus source_regulation; Tests grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,19 @@ const dbUrl = process.env.COMPLIANCE_DATABASE_URL ||
|
|||||||
|
|
||||||
const pool = new Pool({ connectionString: dbUrl })
|
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
|
* MC API that returns data in the same format as the canonical controls
|
||||||
* endpoint. This allows the MC page to reuse ControlListView components.
|
* 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) {
|
// Shared WHERE builder so list + count stay in lock-step (incl. the
|
||||||
const search = params.get('search') || ''
|
// use_case / verification_method / source_regulation mapping filters).
|
||||||
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
|
function buildControlsWhere(params: URLSearchParams): { where: string; args: unknown[]; idx: number } {
|
||||||
const offset = parseInt(params.get('offset') || '0')
|
|
||||||
const sort = params.get('sort') || 'control_id'
|
|
||||||
const order = params.get('order') === 'desc' ? 'DESC' : 'ASC'
|
|
||||||
|
|
||||||
let where = "WHERE 1=1"
|
let where = "WHERE 1=1"
|
||||||
const args: unknown[] = []
|
const args: unknown[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|
||||||
|
const search = params.get('search') || ''
|
||||||
if (search) {
|
if (search) {
|
||||||
where += ` AND mc.canonical_name ILIKE $${idx}`
|
where += ` AND mc.canonical_name ILIKE $${idx}`
|
||||||
args.push(`%${search}%`)
|
args.push(`%${search}%`)
|
||||||
@@ -61,11 +71,9 @@ async function handleControls(params: URLSearchParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const severity = params.get('severity') || ''
|
const severity = params.get('severity') || ''
|
||||||
if (severity) {
|
if (severity === 'high') { where += ` AND mc.total_controls > 100` }
|
||||||
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 === 'medium') { where += ` AND mc.total_controls BETWEEN 20 AND 100` }
|
else if (severity === 'low') { where += ` AND mc.total_controls < 20` }
|
||||||
else if (severity === 'low') { where += ` AND mc.total_controls < 20` }
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = params.get('domain') || ''
|
const domain = params.get('domain') || ''
|
||||||
if (domain) {
|
if (domain) {
|
||||||
@@ -74,6 +82,67 @@ async function handleControls(params: URLSearchParams) {
|
|||||||
idx++
|
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' :
|
const sortCol = sort === 'control_id' ? 'mc.master_control_id' :
|
||||||
sort === 'created_at' ? 'mc.created_at' :
|
sort === 'created_at' ? 'mc.created_at' :
|
||||||
sort === 'source' ? 'mc.canonical_name' : 'mc.master_control_id'
|
sort === 'source' ? 'mc.canonical_name' : 'mc.master_control_id'
|
||||||
@@ -90,7 +159,14 @@ async function handleControls(params: URLSearchParams) {
|
|||||||
mc.total_controls,
|
mc.total_controls,
|
||||||
mc.phases_covered,
|
mc.phases_covered,
|
||||||
mc.id,
|
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
|
FROM compliance.master_controls mc
|
||||||
${where}
|
${where}
|
||||||
ORDER BY ${sortCol} ${order}
|
ORDER BY ${sortCol} ${order}
|
||||||
@@ -98,7 +174,7 @@ async function handleControls(params: URLSearchParams) {
|
|||||||
`, args)
|
`, args)
|
||||||
|
|
||||||
// Map to canonical control format
|
// Map to canonical control format
|
||||||
const controls = res.rows.map(r => ({
|
const controls = res.rows.map((r: MCListRow) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
control_id: r.control_id,
|
control_id: r.control_id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
@@ -106,10 +182,11 @@ async function handleControls(params: URLSearchParams) {
|
|||||||
severity: r.severity,
|
severity: r.severity,
|
||||||
category: r.category,
|
category: r.category,
|
||||||
release_state: 'active',
|
release_state: 'active',
|
||||||
source_citation: null,
|
source_citation: r.primary_regulation ? { source: r.primary_regulation } : null,
|
||||||
verification_method: null,
|
verification_method: r.verification_method,
|
||||||
evidence_type: null,
|
evidence_type: null,
|
||||||
target_audience: [],
|
target_audience: [],
|
||||||
|
use_cases: r.use_cases || [],
|
||||||
requirements: [],
|
requirements: [],
|
||||||
test_procedure: [],
|
test_procedure: [],
|
||||||
evidence: [],
|
evidence: [],
|
||||||
@@ -126,22 +203,17 @@ async function handleControls(params: URLSearchParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCount(params: URLSearchParams) {
|
async function handleCount(params: URLSearchParams) {
|
||||||
const search = params.get('search') || ''
|
const { where, args } = buildControlsWhere(params)
|
||||||
let where = "WHERE 1=1"
|
|
||||||
const args: unknown[] = []
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
where += ` AND mc.canonical_name ILIKE $1`
|
|
||||||
args.push(`%${search}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await pool.query(
|
const res = await pool.query(
|
||||||
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
|
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
|
||||||
)
|
)
|
||||||
return NextResponse.json({ total: parseInt(res.rows[0].count) })
|
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(`
|
const res = await pool.query(`
|
||||||
SELECT count(*) as total,
|
SELECT count(*) as total,
|
||||||
count(CASE WHEN total_controls > 100 THEN 1 END) as high_count,
|
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
|
GROUP BY 1 ORDER BY 2 DESC LIMIT 30
|
||||||
`)
|
`)
|
||||||
|
|
||||||
return NextResponse.json({
|
// Mapping distribution + coverage + member-derived category facet. Only
|
||||||
total: parseInt(r.total),
|
// 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: {
|
severity_counts: {
|
||||||
high: parseInt(r.high_count),
|
high: parseInt(r.high_count),
|
||||||
medium: parseInt(r.medium_count),
|
medium: parseInt(r.medium_count),
|
||||||
low: parseInt(r.low_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: [],
|
sources: [],
|
||||||
no_source_count: 0,
|
no_source_count: 0,
|
||||||
release_state_counts: { active: parseInt(r.total) },
|
release_state_counts: { active: total },
|
||||||
verification_method_counts: {},
|
verification_method_counts: Object.fromEntries(
|
||||||
category_counts: {},
|
vRes.rows.map((x: { verification_method: string; c: string }) =>
|
||||||
|
[x.verification_method, parseInt(x.c)])),
|
||||||
|
category_counts: facet(catRes.rows),
|
||||||
evidence_type_counts: {},
|
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) {
|
async function handleDetail(params: URLSearchParams) {
|
||||||
@@ -201,6 +311,23 @@ async function handleDetail(params: URLSearchParams) {
|
|||||||
LIMIT 100
|
LIMIT 100
|
||||||
`, [mc.id])
|
`, [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({
|
return NextResponse.json({
|
||||||
id: mc.id,
|
id: mc.id,
|
||||||
control_id: mc.control_id,
|
control_id: mc.control_id,
|
||||||
@@ -220,7 +347,10 @@ async function handleDetail(params: URLSearchParams) {
|
|||||||
evidence: [],
|
evidence: [],
|
||||||
open_anchors: [],
|
open_anchors: [],
|
||||||
target_audience: [],
|
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: [] },
|
scope: { platforms: [], components: [], data_classes: [] },
|
||||||
risk_score: null,
|
risk_score: null,
|
||||||
implementation_effort: null,
|
implementation_effort: null,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
import { ControlsMeta } from './useControlLibraryState'
|
import { ControlsMeta } from './useControlLibraryState'
|
||||||
|
import { useCaseLabel, mcVerificationLabel } from './mcMappingLabels'
|
||||||
import { GeneratorModal } from './GeneratorModal'
|
import { GeneratorModal } from './GeneratorModal'
|
||||||
|
|
||||||
interface ControlListViewProps {
|
interface ControlListViewProps {
|
||||||
@@ -34,6 +35,10 @@ interface ControlListViewProps {
|
|||||||
domainFilter: string
|
domainFilter: string
|
||||||
stateFilter: string
|
stateFilter: string
|
||||||
verificationFilter: string
|
verificationFilter: string
|
||||||
|
useCaseFilter: string
|
||||||
|
primaryOnly: boolean
|
||||||
|
regulationFilter: string
|
||||||
|
mappedFilter: string
|
||||||
categoryFilter: string
|
categoryFilter: string
|
||||||
evidenceTypeFilter: string
|
evidenceTypeFilter: string
|
||||||
audienceFilter: string
|
audienceFilter: string
|
||||||
@@ -46,6 +51,10 @@ interface ControlListViewProps {
|
|||||||
setDomainFilter: (v: string) => void
|
setDomainFilter: (v: string) => void
|
||||||
setStateFilter: (v: string) => void
|
setStateFilter: (v: string) => void
|
||||||
setVerificationFilter: (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
|
setCategoryFilter: (v: string) => void
|
||||||
setEvidenceTypeFilter: (v: string) => void
|
setEvidenceTypeFilter: (v: string) => void
|
||||||
setAudienceFilter: (v: string) => void
|
setAudienceFilter: (v: string) => void
|
||||||
@@ -71,10 +80,12 @@ export function ControlListView({
|
|||||||
reviewCount, bulkProcessing, showStats, processedStats,
|
reviewCount, bulkProcessing, showStats, processedStats,
|
||||||
showGenerator, currentPage, totalPages, sortBy,
|
showGenerator, currentPage, totalPages, sortBy,
|
||||||
searchQuery, severityFilter, domainFilter, stateFilter,
|
searchQuery, severityFilter, domainFilter, stateFilter,
|
||||||
verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter,
|
verificationFilter, useCaseFilter, primaryOnly, regulationFilter, mappedFilter,
|
||||||
|
categoryFilter, evidenceTypeFilter, audienceFilter,
|
||||||
sourceFilter, typeFilter, hideDuplicates,
|
sourceFilter, typeFilter, hideDuplicates,
|
||||||
setSearchQuery, setSeverityFilter, setDomainFilter, setStateFilter,
|
setSearchQuery, setSeverityFilter, setDomainFilter, setStateFilter,
|
||||||
setVerificationFilter, setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter,
|
setVerificationFilter, setUseCaseFilter, setPrimaryOnly, setRegulationFilter, setMappedFilter,
|
||||||
|
setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter,
|
||||||
setSourceFilter, setTypeFilter, setHideDuplicates, setSortBy,
|
setSourceFilter, setTypeFilter, setHideDuplicates, setSortBy,
|
||||||
setShowStats, setShowGenerator, setCurrentPage,
|
setShowStats, setShowGenerator, setCurrentPage,
|
||||||
onSelectControl, onCreateMode, onEnterReview, onBulkReject, onRefresh, onLoadStats, onFullReload,
|
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" />
|
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
Duplikate ausblenden
|
Duplikate ausblenden
|
||||||
</label>
|
</label>
|
||||||
|
{meta?.use_case_counts && (
|
||||||
|
<select value={useCaseFilter} onChange={e => setUseCaseFilter(e.target.value)}
|
||||||
|
className="text-sm border border-purple-300 bg-purple-50 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500 max-w-[260px]">
|
||||||
|
<option value="">Use Case (alle)</option>
|
||||||
|
{Object.entries(meta.use_case_counts).sort((a, b) => b[1] - a[1]).map(([k, c]) => (
|
||||||
|
<option key={k} value={k}>{useCaseLabel(k)} ({c})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{meta?.use_case_counts && useCaseFilter && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer whitespace-nowrap"
|
||||||
|
title="Nur Master Controls, deren Primärzweck dieser Use Case ist (blendet über-geclusterte Mehrfachzwecke aus)">
|
||||||
|
<input type="checkbox" checked={primaryOnly} onChange={e => setPrimaryOnly(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
nur Primärzweck
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{meta?.regulations && meta.regulations.length > 0 && (
|
||||||
|
<select value={regulationFilter} onChange={e => setRegulationFilter(e.target.value)}
|
||||||
|
className="text-sm border border-blue-300 bg-blue-50 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500 max-w-[260px]">
|
||||||
|
<option value="">Regulierung (alle)</option>
|
||||||
|
{meta.regulations.map(rg => (
|
||||||
|
<option key={rg.source_regulation} value={rg.source_regulation}>{rg.source_regulation} ({rg.count})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<select value={verificationFilter} onChange={e => setVerificationFilter(e.target.value)}
|
<select value={verificationFilter} onChange={e => setVerificationFilter(e.target.value)}
|
||||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||||
<option value="">Nachweis</option>
|
<option value="">Nachweis</option>
|
||||||
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
||||||
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
|
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
|
||||||
))}
|
))}
|
||||||
|
{Object.keys(meta?.verification_method_counts || {})
|
||||||
|
.filter(k => k !== '__none__' && !(k in VERIFICATION_METHODS))
|
||||||
|
.map(k => (
|
||||||
|
<option key={k} value={k}>{mcVerificationLabel(k)} ({meta!.verification_method_counts![k]})</option>
|
||||||
|
))}
|
||||||
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
|
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
|
||||||
</select>
|
</select>
|
||||||
|
{meta?.mapped_total != null && (
|
||||||
|
<select value={mappedFilter} onChange={e => setMappedFilter(e.target.value)}
|
||||||
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||||
|
<option value="">Coverage: alle</option>
|
||||||
|
<option value="mapped">Zugeordnet ({meta.mapped_total})</option>
|
||||||
|
<option value="unmapped">Offen ({meta.unmapped_count ?? 0})</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<select value={categoryFilter} onChange={e => setCategoryFilter(e.target.value)}
|
<select value={categoryFilter} onChange={e => setCategoryFilter(e.target.value)}
|
||||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||||
<option value="">Kategorie</option>
|
<option value="">Kategorie</option>
|
||||||
{CATEGORY_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>)}
|
{CATEGORY_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>)}
|
||||||
|
{Object.keys(meta?.category_counts || {})
|
||||||
|
.filter(k => k !== '__none__' && !CATEGORY_OPTIONS.some(c => c.value === k))
|
||||||
|
.map(k => <option key={k} value={k}>{k} ({meta!.category_counts![k]})</option>)}
|
||||||
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
|
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
|
||||||
</select>
|
</select>
|
||||||
<select value={evidenceTypeFilter} onChange={e => setEvidenceTypeFilter(e.target.value)}
|
<select value={evidenceTypeFilter} onChange={e => setEvidenceTypeFilter(e.target.value)}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Display labels for the master-control mapping dimensions (use case +
|
||||||
|
// verification method). Keys mirror the backend use_case_registry; an unknown
|
||||||
|
// key humanizes gracefully so a newly-seeded use case still renders.
|
||||||
|
|
||||||
|
export const USE_CASE_LABELS: Record<string, string> = {
|
||||||
|
impressum: 'Impressum',
|
||||||
|
telekommunikation: 'Telekommunikation (TKG)',
|
||||||
|
dse: 'Datenschutzerklärung',
|
||||||
|
agb: 'AGB',
|
||||||
|
cookie_banner: 'Cookie-Banner',
|
||||||
|
widerruf: 'Widerruf',
|
||||||
|
dsr: 'Betroffenenrechte (DSR)',
|
||||||
|
loeschkonzept: 'Löschkonzept',
|
||||||
|
avv: 'Auftragsverarbeitung (AVV)',
|
||||||
|
dsfa: 'DSFA',
|
||||||
|
code_security: 'Code Security',
|
||||||
|
network_security: 'Network Security',
|
||||||
|
cra: 'Cyber Resilience Act',
|
||||||
|
isms: 'ISMS',
|
||||||
|
tisax: 'TISAX',
|
||||||
|
kritis: 'KRITIS',
|
||||||
|
dora: 'DORA',
|
||||||
|
ai_act: 'AI Act',
|
||||||
|
mica: 'MiCA',
|
||||||
|
mdr: 'Medizinprodukte (MDR)',
|
||||||
|
maschinen: 'Maschinenverordnung',
|
||||||
|
batterie: 'Batterieverordnung',
|
||||||
|
ehds: 'EHDS',
|
||||||
|
produktsicherheit: 'Produktsicherheit',
|
||||||
|
dsa: 'Digital Services Act',
|
||||||
|
dma: 'Digital Markets Act',
|
||||||
|
data_governance: 'Data Governance Act',
|
||||||
|
zahlungsdienste: 'Zahlungsdienste (PSD2)',
|
||||||
|
geldwaesche: 'Geldwäsche (GwG)',
|
||||||
|
lieferkette: 'Lieferkettengesetz',
|
||||||
|
whistleblowing: 'Whistleblowing',
|
||||||
|
barrierefreiheit: 'Barrierefreiheit (BFSG)',
|
||||||
|
verbraucherschutz: 'Verbraucherschutz',
|
||||||
|
urheberrecht: 'Urheberrecht',
|
||||||
|
wettbewerbsrecht: 'Wettbewerbsrecht',
|
||||||
|
gleichbehandlung: 'Gleichbehandlung (AGG)',
|
||||||
|
steuerrecht: 'Steuerrecht',
|
||||||
|
handelsrecht: 'Handelsrecht',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MC_VERIFICATION_LABELS: Record<string, string> = {
|
||||||
|
document: 'Dokument',
|
||||||
|
source_code: 'Source Code',
|
||||||
|
network: 'Netzwerk/Infra',
|
||||||
|
it_process: 'IT-Prozess',
|
||||||
|
hybrid: 'Hybrid',
|
||||||
|
manual: 'Manuell',
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(key: string): string {
|
||||||
|
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCaseLabel(key: string): string {
|
||||||
|
return USE_CASE_LABELS[key] || humanize(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mcVerificationLabel(key: string): string {
|
||||||
|
return MC_VERIFICATION_LABELS[key] || humanize(key)
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ export interface ControlsMeta {
|
|||||||
category_counts?: Record<string, number>
|
category_counts?: Record<string, number>
|
||||||
evidence_type_counts?: Record<string, number>
|
evidence_type_counts?: Record<string, number>
|
||||||
release_state_counts?: Record<string, number>
|
release_state_counts?: Record<string, number>
|
||||||
|
// Master-control mapping dimensions (only returned by the MC endpoint)
|
||||||
|
use_case_counts?: Record<string, number>
|
||||||
|
regulations?: Array<{ source_regulation: string; count: number }>
|
||||||
|
mapped_total?: number
|
||||||
|
unmapped_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
@@ -35,6 +40,10 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
|||||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||||
const [stateFilter, setStateFilter] = useState<string>('')
|
const [stateFilter, setStateFilter] = useState<string>('')
|
||||||
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
||||||
|
const [useCaseFilter, setUseCaseFilter] = useState<string>('')
|
||||||
|
const [primaryOnly, setPrimaryOnly] = useState<boolean>(false)
|
||||||
|
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
||||||
|
const [mappedFilter, setMappedFilter] = useState<string>('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||||
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
||||||
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
||||||
@@ -88,6 +97,10 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
|||||||
if (domainFilter) p.set('domain', domainFilter)
|
if (domainFilter) p.set('domain', domainFilter)
|
||||||
if (stateFilter) p.set('release_state', stateFilter)
|
if (stateFilter) p.set('release_state', stateFilter)
|
||||||
if (verificationFilter) p.set('verification_method', verificationFilter)
|
if (verificationFilter) p.set('verification_method', verificationFilter)
|
||||||
|
if (useCaseFilter) p.set('use_case', useCaseFilter)
|
||||||
|
if (primaryOnly) p.set('primary', '1')
|
||||||
|
if (regulationFilter) p.set('source_regulation', regulationFilter)
|
||||||
|
if (mappedFilter) p.set('mapped', mappedFilter)
|
||||||
if (categoryFilter) p.set('category', categoryFilter)
|
if (categoryFilter) p.set('category', categoryFilter)
|
||||||
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
||||||
if (audienceFilter) p.set('target_audience', audienceFilter)
|
if (audienceFilter) p.set('target_audience', audienceFilter)
|
||||||
@@ -97,7 +110,7 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
|||||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||||
return p.toString()
|
return p.toString()
|
||||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
}, [severityFilter, domainFilter, stateFilter, verificationFilter, useCaseFilter, primaryOnly, regulationFilter, mappedFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
||||||
|
|
||||||
const loadFrameworks = useCallback(async () => {
|
const loadFrameworks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -156,7 +169,7 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
|||||||
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
||||||
useEffect(() => { loadMeta() }, [loadMeta])
|
useEffect(() => { loadMeta() }, [loadMeta])
|
||||||
useEffect(() => { loadControls() }, [loadControls])
|
useEffect(() => { loadControls() }, [loadControls])
|
||||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, useCaseFilter, primaryOnly, regulationFilter, mappedFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||||
|
|
||||||
@@ -212,6 +225,10 @@ export function useControlLibraryState(backendUrlOverride?: string) {
|
|||||||
domainFilter, setDomainFilter,
|
domainFilter, setDomainFilter,
|
||||||
stateFilter, setStateFilter,
|
stateFilter, setStateFilter,
|
||||||
verificationFilter, setVerificationFilter,
|
verificationFilter, setVerificationFilter,
|
||||||
|
useCaseFilter, setUseCaseFilter,
|
||||||
|
primaryOnly, setPrimaryOnly,
|
||||||
|
regulationFilter, setRegulationFilter,
|
||||||
|
mappedFilter, setMappedFilter,
|
||||||
categoryFilter, setCategoryFilter,
|
categoryFilter, setCategoryFilter,
|
||||||
evidenceTypeFilter, setEvidenceTypeFilter,
|
evidenceTypeFilter, setEvidenceTypeFilter,
|
||||||
audienceFilter, setAudienceFilter,
|
audienceFilter, setAudienceFilter,
|
||||||
|
|||||||
@@ -232,6 +232,10 @@ export default function ControlLibraryPage() {
|
|||||||
domainFilter={state.domainFilter}
|
domainFilter={state.domainFilter}
|
||||||
stateFilter={state.stateFilter}
|
stateFilter={state.stateFilter}
|
||||||
verificationFilter={state.verificationFilter}
|
verificationFilter={state.verificationFilter}
|
||||||
|
useCaseFilter={state.useCaseFilter}
|
||||||
|
primaryOnly={state.primaryOnly}
|
||||||
|
regulationFilter={state.regulationFilter}
|
||||||
|
mappedFilter={state.mappedFilter}
|
||||||
categoryFilter={state.categoryFilter}
|
categoryFilter={state.categoryFilter}
|
||||||
evidenceTypeFilter={state.evidenceTypeFilter}
|
evidenceTypeFilter={state.evidenceTypeFilter}
|
||||||
audienceFilter={state.audienceFilter}
|
audienceFilter={state.audienceFilter}
|
||||||
@@ -243,6 +247,10 @@ export default function ControlLibraryPage() {
|
|||||||
setDomainFilter={state.setDomainFilter}
|
setDomainFilter={state.setDomainFilter}
|
||||||
setStateFilter={state.setStateFilter}
|
setStateFilter={state.setStateFilter}
|
||||||
setVerificationFilter={state.setVerificationFilter}
|
setVerificationFilter={state.setVerificationFilter}
|
||||||
|
setUseCaseFilter={state.setUseCaseFilter}
|
||||||
|
setPrimaryOnly={state.setPrimaryOnly}
|
||||||
|
setRegulationFilter={state.setRegulationFilter}
|
||||||
|
setMappedFilter={state.setMappedFilter}
|
||||||
setCategoryFilter={state.setCategoryFilter}
|
setCategoryFilter={state.setCategoryFilter}
|
||||||
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
|
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
|
||||||
setAudienceFilter={state.setAudienceFilter}
|
setAudienceFilter={state.setAudienceFilter}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { ControlListView } from '../control-library/components/ControlListView'
|
import { ControlListView } from '../control-library/components/ControlListView'
|
||||||
import { useControlLibraryState } from '../control-library/components/useControlLibraryState'
|
import { useControlLibraryState } from '../control-library/components/useControlLibraryState'
|
||||||
|
import { useCaseLabel, mcVerificationLabel } from '../control-library/components/mcMappingLabels'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master Controls page — reuses the Control Library UI exactly,
|
* Master Controls page — reuses the Control Library UI exactly,
|
||||||
@@ -38,7 +39,7 @@ export default function MasterControlsPage() {
|
|||||||
if (state.mode === 'detail' && state.selectedControl) {
|
if (state.mode === 'detail' && state.selectedControl) {
|
||||||
return (
|
return (
|
||||||
<MCDetail
|
<MCDetail
|
||||||
mc={state.selectedControl}
|
mc={state.selectedControl as unknown as Record<string, unknown>}
|
||||||
onBack={() => { state.setMode('list'); state.setSelectedControl(null) }}
|
onBack={() => { state.setMode('list'); state.setSelectedControl(null) }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -65,6 +66,10 @@ export default function MasterControlsPage() {
|
|||||||
domainFilter={state.domainFilter}
|
domainFilter={state.domainFilter}
|
||||||
stateFilter={state.stateFilter}
|
stateFilter={state.stateFilter}
|
||||||
verificationFilter={state.verificationFilter}
|
verificationFilter={state.verificationFilter}
|
||||||
|
useCaseFilter={state.useCaseFilter}
|
||||||
|
primaryOnly={state.primaryOnly}
|
||||||
|
regulationFilter={state.regulationFilter}
|
||||||
|
mappedFilter={state.mappedFilter}
|
||||||
categoryFilter={state.categoryFilter}
|
categoryFilter={state.categoryFilter}
|
||||||
evidenceTypeFilter={state.evidenceTypeFilter}
|
evidenceTypeFilter={state.evidenceTypeFilter}
|
||||||
audienceFilter={state.audienceFilter}
|
audienceFilter={state.audienceFilter}
|
||||||
@@ -76,6 +81,10 @@ export default function MasterControlsPage() {
|
|||||||
setDomainFilter={state.setDomainFilter}
|
setDomainFilter={state.setDomainFilter}
|
||||||
setStateFilter={state.setStateFilter}
|
setStateFilter={state.setStateFilter}
|
||||||
setVerificationFilter={state.setVerificationFilter}
|
setVerificationFilter={state.setVerificationFilter}
|
||||||
|
setUseCaseFilter={state.setUseCaseFilter}
|
||||||
|
setPrimaryOnly={state.setPrimaryOnly}
|
||||||
|
setRegulationFilter={state.setRegulationFilter}
|
||||||
|
setMappedFilter={state.setMappedFilter}
|
||||||
setCategoryFilter={state.setCategoryFilter}
|
setCategoryFilter={state.setCategoryFilter}
|
||||||
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
|
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
|
||||||
setAudienceFilter={state.setAudienceFilter}
|
setAudienceFilter={state.setAudienceFilter}
|
||||||
@@ -116,8 +125,15 @@ const SEV = {
|
|||||||
low: 'bg-blue-100 text-blue-800',
|
low: 'bg-blue-100 text-blue-800',
|
||||||
} as Record<string, string>
|
} as Record<string, string>
|
||||||
|
|
||||||
|
interface MCMapping {
|
||||||
|
use_cases?: Array<{ use_case: string; is_primary: boolean }>
|
||||||
|
verification_method?: string | null
|
||||||
|
regulations?: Array<{ source_regulation: string; is_primary: boolean; member_count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => void }) {
|
function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => void }) {
|
||||||
const [members, setMembers] = useState<Member[]>([])
|
const [members, setMembers] = useState<Member[]>([])
|
||||||
|
const [mapping, setMapping] = useState<MCMapping>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [phaseFilter, setPhaseFilter] = useState('')
|
const [phaseFilter, setPhaseFilter] = useState('')
|
||||||
|
|
||||||
@@ -131,6 +147,10 @@ function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => v
|
|||||||
fetch(`/api/sdk/v1/master-controls?endpoint=control&id=${mcId}`)
|
fetch(`/api/sdk/v1/master-controls?endpoint=control&id=${mcId}`)
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
if (data) setMapping({
|
||||||
|
use_cases: data.use_cases, verification_method: data.verification_method,
|
||||||
|
regulations: data.regulations,
|
||||||
|
})
|
||||||
if (data?.members) setMembers(data.members)
|
if (data?.members) setMembers(data.members)
|
||||||
else if (data?.requirements) {
|
else if (data?.requirements) {
|
||||||
// Fallback: parse requirements strings
|
// Fallback: parse requirements strings
|
||||||
@@ -164,6 +184,33 @@ function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => v
|
|||||||
<h1 className="text-2xl font-bold text-gray-900">{mcName}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{mcName}</h1>
|
||||||
<p className="text-gray-500 mt-1">{mcId} — {totalControls} Atomic Controls</p>
|
<p className="text-gray-500 mt-1">{mcId} — {totalControls} Atomic Controls</p>
|
||||||
|
|
||||||
|
{/* Zuordnung: Use Cases + Verifikation + Quell-Regulierung */}
|
||||||
|
{(mapping.use_cases?.length || mapping.verification_method || mapping.regulations?.length) ? (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
{(mapping.use_cases || []).map(u => (
|
||||||
|
<span key={u.use_case}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-medium ${u.is_primary
|
||||||
|
? 'bg-purple-100 text-purple-800 border border-purple-300'
|
||||||
|
: 'bg-purple-50 text-purple-600'}`}
|
||||||
|
title={u.is_primary ? 'Primärzweck' : 'Mehrfachzweck'}>
|
||||||
|
{useCaseLabel(u.use_case)}{u.is_primary ? ' ★' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{mapping.verification_method && (
|
||||||
|
<span className="px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-800 border border-emerald-300">
|
||||||
|
Nachweis: {mcVerificationLabel(mapping.verification_method)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(mapping.regulations || []).slice(0, 4).map(r => (
|
||||||
|
<span key={r.source_regulation}
|
||||||
|
className="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700"
|
||||||
|
title={`${r.member_count} Member${r.is_primary ? ' · Primärquelle' : ''}`}>
|
||||||
|
{r.source_regulation}{r.is_primary ? ' ★' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Phase badges */}
|
{/* Phase badges */}
|
||||||
<div className="flex flex-wrap gap-2 mt-4">
|
<div className="flex flex-wrap gap-2 mt-4">
|
||||||
{uniquePhases.map(p => (
|
{uniquePhases.map(p => (
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ _USE_CASES: tuple[UseCase, ...] = (
|
|||||||
doc_types=("impressum",), scope_tokens=("impressum",),
|
doc_types=("impressum",), scope_tokens=("impressum",),
|
||||||
categories=("compliance",),
|
categories=("compliance",),
|
||||||
keyword_tokens=("impressum", "anbieterkennzeichnung")),
|
keyword_tokens=("impressum", "anbieterkennzeichnung")),
|
||||||
|
UseCase("telekommunikation", "Telekommunikation (TKG)", "product",
|
||||||
|
regulations=("TKG",),
|
||||||
|
verification_methods=("document", "it_process"),
|
||||||
|
categories=("compliance", "governance"),
|
||||||
|
keyword_tokens=("telekommunikation", "tkg")),
|
||||||
UseCase("dse", "Datenschutzerklärung", "document",
|
UseCase("dse", "Datenschutzerklärung", "document",
|
||||||
regulations=("DSGVO",),
|
regulations=("DSGVO",),
|
||||||
verification_methods=("document",),
|
verification_methods=("document",),
|
||||||
@@ -270,12 +275,18 @@ _REGULATION_RULES: tuple[tuple[str, str], ...] = (
|
|||||||
# Website / Telemedien / Recht (User-Domaene)
|
# Website / Telemedien / Recht (User-Domaene)
|
||||||
("tdddg", "cookie_banner"),
|
("tdddg", "cookie_banner"),
|
||||||
("eprivacy", "cookie_banner"),
|
("eprivacy", "cookie_banner"),
|
||||||
("telemedien", "impressum"),
|
# Telemediengesetz (das echte Impressumsrecht) VOR der "telemedien"-
|
||||||
("telekommunikationsgesetz", "impressum"),
|
# Leitlinien-Regel — longest-first, sonst faengt "telemedien" es ab.
|
||||||
("tkg", "impressum"),
|
("telemediengesetz", "impressum"),
|
||||||
|
# "DSK OH Telemedien" ist eine Datenschutz-Leitlinie, kein Impressumsrecht.
|
||||||
|
("telemedien", "dse"),
|
||||||
|
# TKG / AT-TKG sind Telekommunikationsrecht, NICHT Website-Impressum.
|
||||||
|
("telekommunikationsgesetz", "telekommunikation"),
|
||||||
|
("tkg", "telekommunikation"),
|
||||||
("tmg", "impressum"),
|
("tmg", "impressum"),
|
||||||
("mediengesetz", "impressum"),
|
("mediengesetz", "impressum"),
|
||||||
("gewerbeordnung", "impressum"),
|
# Gewerbeordnung ist Gewerbe-/Handelsrecht, kein Impressum.
|
||||||
|
("gewerbeordnung", "handelsrecht"),
|
||||||
("e-commerce", "agb"),
|
("e-commerce", "agb"),
|
||||||
("digitale-inhalte", "agb"),
|
("digitale-inhalte", "agb"),
|
||||||
("konsumentenschutz", "verbraucherschutz"),
|
("konsumentenschutz", "verbraucherschutz"),
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ async def run_seed(conn, limit: int = 0) -> dict:
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""INSERT INTO compliance.mc_use_case_sync_state
|
"""INSERT INTO compliance.mc_use_case_sync_state
|
||||||
(registry_hash, stage, total_mappings, mcs_classified)
|
(registry_hash, stage, total_mappings, mcs_classified)
|
||||||
VALUES ($1,'seed_source_regulation',$2,$3)""",
|
VALUES ($1,'seed_regulation',$2,$3)""",
|
||||||
reg.registry_hash(), total, n_mc)
|
reg.registry_hash(), total, n_mc)
|
||||||
return {"mcs_mapped": n_mc, "regulation_rows": n_reg,
|
return {"mcs_mapped": n_mc, "regulation_rows": n_reg,
|
||||||
"use_case_rows": n_uc, "verification_rows": n_v}
|
"use_case_rows": n_uc, "verification_rows": n_v}
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ def test_regulation_mapper_known():
|
|||||||
"Cyber Resilience Act (CRA)": "cra",
|
"Cyber Resilience Act (CRA)": "cra",
|
||||||
"DSGVO (EU) 2016/679": "dse",
|
"DSGVO (EU) 2016/679": "dse",
|
||||||
"EDPB Facial Recognition": "dse", # Leitlinie → Datenschutz
|
"EDPB Facial Recognition": "dse", # Leitlinie → Datenschutz
|
||||||
"TKG": "impressum",
|
"TKG": "telekommunikation", # Telekom-Recht, NICHT Impressum
|
||||||
|
"TMG": "impressum", # echtes Impressumsrecht
|
||||||
"TDDDG": "cookie_banner",
|
"TDDDG": "cookie_banner",
|
||||||
"Markets in Crypto-Assets (MiCA)": "mica",
|
"Markets in Crypto-Assets (MiCA)": "mica",
|
||||||
"BGB": "agb",
|
"BGB": "agb",
|
||||||
@@ -131,6 +132,19 @@ def test_regulation_mapper_known():
|
|||||||
assert reg.use_case_for_regulation(reg_str) == expected, reg_str
|
assert reg.use_case_for_regulation(reg_str) == expected, reg_str
|
||||||
|
|
||||||
|
|
||||||
|
def test_regulation_mapper_impressum_misroutes_fixed():
|
||||||
|
# Phase A: Telekom-/Datenschutz-/Gewerbe-Gesetze duerfen NICHT mehr als
|
||||||
|
# Impressum durchgehen (Korpus enthaelt kein echtes Impressumsrecht ausser
|
||||||
|
# TMG/MStV). Siehe Audit 2026-06-09.
|
||||||
|
assert reg.use_case_for_regulation("Telekommunikationsgesetz Oesterreich") \
|
||||||
|
== "telekommunikation"
|
||||||
|
assert reg.use_case_for_regulation("DSK OH Telemedien") == "dse"
|
||||||
|
assert reg.use_case_for_regulation("Gewerbeordnung (GewO)") == "handelsrecht"
|
||||||
|
# Die echten Impressum-Quellen bleiben Impressum:
|
||||||
|
assert reg.use_case_for_regulation("TMG") == "impressum"
|
||||||
|
assert reg.use_case_for_regulation("AT Mediengesetz") == "impressum"
|
||||||
|
|
||||||
|
|
||||||
def test_regulation_mapper_abgb_before_bgb():
|
def test_regulation_mapper_abgb_before_bgb():
|
||||||
# 'ABGB' enthaelt 'bgb' — die abgb-Regel MUSS zuerst greifen.
|
# 'ABGB' enthaelt 'bgb' — die abgb-Regel MUSS zuerst greifen.
|
||||||
assert reg.use_case_for_regulation("AT ABGB") == "handelsrecht"
|
assert reg.use_case_for_regulation("AT ABGB") == "handelsrecht"
|
||||||
|
|||||||
Reference in New Issue
Block a user