From e536247c20c81fb8de7fb4884309c14ff99528b9 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 19 May 2026 13:03:54 +0200 Subject: [PATCH] feat(quaidal): backend API + frontend tab for BSI QUAIDAL data-quality controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the 195 Clean-Room QUAIDAL controls (from breakpilot-core migration 011) into the compliance SaaS UI. Backend: - GET /api/v1/quaidal/stats - counts by kind + source provenance - GET /api/v1/quaidal/controls - list, optional kind= filter - GET /api/v1/quaidal/controls/{id} - single derived control - GET /api/v1/quaidal/criteria - 10 QKB criteria - GET /api/v1/quaidal/criteria/{id} - QKB with QB/MA/QM tree Frontend: - /sdk/quality: new "Trainingsdaten-Qualität (BSI QUAIDAL)" tab with 10 QKB cards and a drill-down modal showing the full QB→MA→QM tree plus original BSI source link and license note. - /sdk/ai-act: Art. 10 tile on each high-risk/unacceptable result, linking to /sdk/quality?category=data_quality. Pattern matches existing IACE module DIN-reference handling: own wording, source section + URL preserved for due diligence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/quaidal/controls/[derived_id]/route.ts | 27 ++ .../app/api/sdk/v1/quaidal/controls/route.ts | 25 ++ .../v1/quaidal/criteria/[section_id]/route.ts | 27 ++ .../app/api/sdk/v1/quaidal/criteria/route.ts | 23 ++ .../app/api/sdk/v1/quaidal/stats/route.ts | 23 ++ .../app/sdk/ai-act/_components/Art10Tile.tsx | 45 ++++ admin-compliance/app/sdk/ai-act/page.tsx | 2 + .../_components/QuaidalCriterionDetail.tsx | 152 +++++++++++ .../_components/TrainingDataQualityTab.tsx | 109 ++++++++ .../app/sdk/quality/_hooks/useQuaidalData.ts | 86 ++++++ admin-compliance/app/sdk/quality/page.tsx | 72 ++++-- .../compliance/api/quaidal_routes.py | 244 ++++++++++++++++++ backend-compliance/main.py | 2 + 13 files changed, 821 insertions(+), 16 deletions(-) create mode 100644 admin-compliance/app/api/sdk/v1/quaidal/controls/[derived_id]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/quaidal/controls/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/quaidal/criteria/[section_id]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/quaidal/criteria/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/quaidal/stats/route.ts create mode 100644 admin-compliance/app/sdk/ai-act/_components/Art10Tile.tsx create mode 100644 admin-compliance/app/sdk/quality/_components/QuaidalCriterionDetail.tsx create mode 100644 admin-compliance/app/sdk/quality/_components/TrainingDataQualityTab.tsx create mode 100644 admin-compliance/app/sdk/quality/_hooks/useQuaidalData.ts create mode 100644 backend-compliance/compliance/api/quaidal_routes.py diff --git a/admin-compliance/app/api/sdk/v1/quaidal/controls/[derived_id]/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/controls/[derived_id]/route.ts new file mode 100644 index 00000000..bc05124e --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/quaidal/controls/[derived_id]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +function tenantHeader(request: NextRequest): string { + return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001' +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ derived_id: string }> } +) { + const { derived_id } = await params + try { + const resp = await fetch( + `${BACKEND_URL}/api/v1/quaidal/controls/${encodeURIComponent(derived_id)}`, + { headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' } + ) + const body = await resp.text() + return new NextResponse(body, { + status: resp.status, + headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' }, + }) + } catch (err) { + return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 }) + } +} diff --git a/admin-compliance/app/api/sdk/v1/quaidal/controls/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/controls/route.ts new file mode 100644 index 00000000..1ec0557a --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/quaidal/controls/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +function tenantHeader(request: NextRequest): string { + return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001' +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const qs = searchParams.toString() + try { + const resp = await fetch( + `${BACKEND_URL}/api/v1/quaidal/controls${qs ? `?${qs}` : ''}`, + { headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' } + ) + const body = await resp.text() + return new NextResponse(body, { + status: resp.status, + headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' }, + }) + } catch (err) { + return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 }) + } +} diff --git a/admin-compliance/app/api/sdk/v1/quaidal/criteria/[section_id]/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/criteria/[section_id]/route.ts new file mode 100644 index 00000000..7b6bfd9e --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/quaidal/criteria/[section_id]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +function tenantHeader(request: NextRequest): string { + return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001' +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ section_id: string }> } +) { + const { section_id } = await params + try { + const resp = await fetch( + `${BACKEND_URL}/api/v1/quaidal/criteria/${encodeURIComponent(section_id)}`, + { headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' } + ) + const body = await resp.text() + return new NextResponse(body, { + status: resp.status, + headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' }, + }) + } catch (err) { + return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 }) + } +} diff --git a/admin-compliance/app/api/sdk/v1/quaidal/criteria/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/criteria/route.ts new file mode 100644 index 00000000..015b1206 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/quaidal/criteria/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +function tenantHeader(request: NextRequest): string { + return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001' +} + +export async function GET(request: NextRequest) { + try { + const resp = await fetch(`${BACKEND_URL}/api/v1/quaidal/criteria`, { + headers: { 'X-Tenant-ID': tenantHeader(request) }, + cache: 'no-store', + }) + const body = await resp.text() + return new NextResponse(body, { + status: resp.status, + headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' }, + }) + } catch (err) { + return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 }) + } +} diff --git a/admin-compliance/app/api/sdk/v1/quaidal/stats/route.ts b/admin-compliance/app/api/sdk/v1/quaidal/stats/route.ts new file mode 100644 index 00000000..f79c2d28 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/quaidal/stats/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +function tenantHeader(request: NextRequest): string { + return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001' +} + +export async function GET(request: NextRequest) { + try { + const resp = await fetch(`${BACKEND_URL}/api/v1/quaidal/stats`, { + headers: { 'X-Tenant-ID': tenantHeader(request) }, + cache: 'no-store', + }) + const body = await resp.text() + return new NextResponse(body, { + status: resp.status, + headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' }, + }) + } catch (err) { + return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 }) + } +} diff --git a/admin-compliance/app/sdk/ai-act/_components/Art10Tile.tsx b/admin-compliance/app/sdk/ai-act/_components/Art10Tile.tsx new file mode 100644 index 00000000..d02610b1 --- /dev/null +++ b/admin-compliance/app/sdk/ai-act/_components/Art10Tile.tsx @@ -0,0 +1,45 @@ +'use client' + +import Link from 'next/link' + +interface Props { + /** Risk classification of the AI system. Tile is only rendered for high_risk / unacceptable. */ + riskLevel: string +} + +/** + * Renders a tile pointing to the BSI QUAIDAL-based data-quality control tab. + * AI Act Article 10 obligations (training-data quality) apply only to high-risk + * systems, so the tile is skipped for limited / minimal / not-applicable classes. + */ +export function Art10Tile({ riskLevel }: Props) { + if (riskLevel !== 'high_risk' && riskLevel !== 'unacceptable') return null + + return ( + +
+
+ + + +
+
+
+ Art. 10 Datenqualität (Hochrisiko-KI) +
+
+ BSI QUAIDAL Controls: 10 Kriterien, 15 Bausteine, 30 Maßnahmen, 140 Metriken. + Klicken zum Öffnen des Trainingsdaten-Qualität-Moduls. +
+
+ + + +
+ + ) +} diff --git a/admin-compliance/app/sdk/ai-act/page.tsx b/admin-compliance/app/sdk/ai-act/page.tsx index 96522f97..5d65075e 100644 --- a/admin-compliance/app/sdk/ai-act/page.tsx +++ b/admin-compliance/app/sdk/ai-act/page.tsx @@ -9,6 +9,7 @@ import { RiskPyramid } from './_components/RiskPyramid' import { AddSystemForm } from './_components/AddSystemForm' import { AISystemCard } from './_components/AISystemCard' import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard' +import { Art10Tile } from './_components/Art10Tile' type TabId = 'overview' | 'decision-tree' | 'results' @@ -136,6 +137,7 @@ function SavedResultsTab() { Löschen + ))} diff --git a/admin-compliance/app/sdk/quality/_components/QuaidalCriterionDetail.tsx b/admin-compliance/app/sdk/quality/_components/QuaidalCriterionDetail.tsx new file mode 100644 index 00000000..3cf6d34e --- /dev/null +++ b/admin-compliance/app/sdk/quality/_components/QuaidalCriterionDetail.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useEffect, useState } from 'react' +import { fetchCriterionTree, type QuaidalControl, type QuaidalCriterionTree } from '../_hooks/useQuaidalData' + +interface Props { + sectionId: string + onClose: () => void +} + +function ControlBlock({ ctrl, badgeColor }: { ctrl: QuaidalControl; badgeColor: string }) { + return ( +
+
+

{ctrl.canonical_name}

+ {ctrl.source.section} +
+

{ctrl.description}

+ {ctrl.source.url && ( + + BSI-Quelle ansehen ({ctrl.source.framework}) + + )} +
+ ) +} + +export function QuaidalCriterionDetail({ sectionId, onClose }: Props) { + const [tree, setTree] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let active = true + setLoading(true) + fetchCriterionTree(sectionId).then(t => { + if (active) { + setTree(t) + setLoading(false) + } + }) + return () => { active = false } + }, [sectionId]) + + return ( +
+
+
+
+
QUAIDAL Kriterium
+

+ {tree?.criterion.canonical_name || sectionId} +

+
+ +
+ +
+ {loading &&
Lade...
} + + {tree && ( + <> +
+

+ Anforderung (eigene Formulierung) +

+
+

{tree.criterion.description}

+
+
+ Regulierung: {tree.criterion.regulation_anchor || '—'} + Quelle: {tree.criterion.source.framework} {tree.criterion.source.section} + {tree.criterion.source.url && ( + + Originalquelle + + )} +
+
+ + {tree.criterion.external_refs.length > 0 && ( +
+

+ Externe Referenzen (nicht ingestiert, nur Verweis) +

+
+ {tree.criterion.external_refs.map((ref, i) => ( + + {ref.framework}{ref.citation ? ` — ${ref.citation}` : ''} + + ))} +
+
+ )} + + {tree.building_blocks.length > 0 && ( +
+

+ Bausteine ({tree.building_blocks.length}) +

+
+ {tree.building_blocks.map(qb => ( + + ))} +
+
+ )} + + {tree.measures.length > 0 && ( +
+

+ Maßnahmen ({tree.measures.length}) +

+
+ {tree.measures.map(m => ( + + ))} +
+
+ )} + + {tree.metrics.length > 0 && ( +
+

+ Metriken & Methoden ({tree.metrics.length}) +

+
+ {tree.metrics.map(qm => ( + + ))} +
+
+ )} + + )} +
+ +
+ Eigene Clean-Room-Ableitung von BSI QUAIDAL. Quellverweis und Lizenz-Note pro Eintrag. +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/quality/_components/TrainingDataQualityTab.tsx b/admin-compliance/app/sdk/quality/_components/TrainingDataQualityTab.tsx new file mode 100644 index 00000000..e2431a7a --- /dev/null +++ b/admin-compliance/app/sdk/quality/_components/TrainingDataQualityTab.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useState } from 'react' +import { useQuaidalData, type QuaidalControl } from '../_hooks/useQuaidalData' +import { QuaidalCriterionDetail } from './QuaidalCriterionDetail' + +function CriterionCard({ ctrl, onOpen }: { ctrl: QuaidalControl; onOpen: () => void }) { + return ( + + ) +} + +export function TrainingDataQualityTab() { + const { criteria, stats, loading, error } = useQuaidalData() + const [openSection, setOpenSection] = useState(null) + + if (loading) { + return
Lade QUAIDAL-Katalog...
+ } + if (error) { + return ( +
+ QUAIDAL-Daten konnten nicht geladen werden: {error} +
+ ) + } + + return ( +
+
+

Trainingsdaten-Qualität nach BSI QUAIDAL

+

+ Operative Umsetzung von EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI) auf Basis des + BSI-Katalogs QUAIDAL. Alle Controls sind eigenständig formuliert (Clean-Room) und verweisen + auf die jeweilige QUAIDAL-Sektion. +

+ {stats && ( +
+
+
Qualitätskriterien
+
{stats.counts_by_kind.criterion ?? 0}
+
+
+
Bausteine
+
{stats.counts_by_kind.building_block ?? 0}
+
+
+
Maßnahmen
+
{stats.counts_by_kind.measure ?? 0}
+
+
+
Metriken & Methoden
+
{stats.counts_by_kind.metric ?? 0}
+
+
+ )} +
+ +
+

10 Qualitätskriterien

+ {criteria.length === 0 ? ( +
+ Keine Kriterien gefunden. Bitte Backend-Ingest prüfen. +
+ ) : ( +
+ {criteria.map(c => ( + setOpenSection(c.source.section)} + /> + ))} +
+ )} +
+ + {stats?.license_note && ( +
{stats.license_note}
+ )} + + {openSection && ( + setOpenSection(null)} + /> + )} +
+ ) +} diff --git a/admin-compliance/app/sdk/quality/_hooks/useQuaidalData.ts b/admin-compliance/app/sdk/quality/_hooks/useQuaidalData.ts new file mode 100644 index 00000000..47f8c6d4 --- /dev/null +++ b/admin-compliance/app/sdk/quality/_hooks/useQuaidalData.ts @@ -0,0 +1,86 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' + +export interface QuaidalExternalRef { + framework: string + citation: string | null +} + +export interface QuaidalSource { + framework: string + section: string + url: string | null + commit_sha: string | null + title_original: string | null + license_note: string | null +} + +export interface QuaidalControl { + derived_id: string + kind: 'criterion' | 'building_block' | 'measure' | 'metric' + canonical_name: string + description: string + regulation_anchor: string | null + related_quaidal_ids: string[] + external_refs: QuaidalExternalRef[] + source: QuaidalSource + plagiarism_score: number | null +} + +export interface QuaidalStats { + counts_by_kind: Record + source_framework: string + source_commit_sha: string | null + license_note: string | null +} + +export interface QuaidalCriterionTree { + criterion: QuaidalControl + building_blocks: QuaidalControl[] + measures: QuaidalControl[] + metrics: QuaidalControl[] +} + +const API_BASE = '/api/sdk/v1/quaidal' + +export function useQuaidalData() { + const [criteria, setCriteria] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadAll = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [criteriaRes, statsRes] = await Promise.all([ + fetch(`${API_BASE}/criteria`, { cache: 'no-store' }), + fetch(`${API_BASE}/stats`, { cache: 'no-store' }), + ]) + if (criteriaRes.ok) { + const data = (await criteriaRes.json()) as QuaidalControl[] + setCriteria(Array.isArray(data) ? data : []) + } else { + setError(`Criteria endpoint returned ${criteriaRes.status}`) + } + if (statsRes.ok) { + setStats(await statsRes.json()) + } + } catch (err) { + setError(String(err)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { loadAll() }, [loadAll]) + + return { criteria, stats, loading, error, reload: loadAll } +} + +export async function fetchCriterionTree(sectionId: string): Promise { + const res = await fetch(`${API_BASE}/criteria/${encodeURIComponent(sectionId)}`, { cache: 'no-store' }) + if (!res.ok) return null + return (await res.json()) as QuaidalCriterionTree +} diff --git a/admin-compliance/app/sdk/quality/page.tsx b/admin-compliance/app/sdk/quality/page.tsx index e6776b66..abda4c59 100644 --- a/admin-compliance/app/sdk/quality/page.tsx +++ b/admin-compliance/app/sdk/quality/page.tsx @@ -1,15 +1,23 @@ 'use client' import { useState, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' import { useSDK } from '@/lib/sdk' import { useQualityData } from './_hooks/useQualityData' import { MetricCard, type QualityMetric } from './_components/MetricCard' import { TestRow } from './_components/TestRow' import { MetricModal } from './_components/MetricModal' import { TestModal } from './_components/TestModal' +import { TrainingDataQualityTab } from './_components/TrainingDataQualityTab' + +type TabId = 'model_quality' | 'data_quality' export default function QualityPage() { const { state } = useSDK() + const searchParams = useSearchParams() + const initialTab: TabId = searchParams?.get('category') === 'data_quality' ? 'data_quality' : 'model_quality' + const [tab, setTab] = useState(initialTab) + const { metrics, tests, @@ -41,24 +49,54 @@ export default function QualityPage() {

AI Quality Dashboard

Ueberwachen Sie die Qualitaet und Fairness Ihrer KI-Systeme

-
- - -
+ {tab === 'model_quality' && ( +
+ + +
+ )} +
+ +
+ + {tab === 'data_quality' && } + {tab === 'model_quality' && ( + <>
Durchschnittlicher Score
@@ -141,6 +179,8 @@ export default function QualityPage() {
+ + )} {showMetricModal && ( DerivedControl: + return DerivedControl( + derived_id=row.derived_id, + kind=row.kind, + canonical_name=row.canonical_name, + description=row.description, + regulation_anchor=row.regulation_anchor, + related_quaidal_ids=row.related_quaidal_ids or [], + external_refs=[ExternalRef(**r) for r in (row.external_refs or [])], + source=SourceProvenance( + framework=row.source_framework, + section=row.source_section, + url=row.source_url, + commit_sha=row.source_commit_sha, + title_original=row.source_title_original, + license_note=row.source_license_note, + ), + plagiarism_score=float(row.plagiarism_score_at_generation) if row.plagiarism_score_at_generation is not None else None, + ) + + +_SELECT_COLUMNS = """ + derived_id, kind, canonical_name, description, regulation_anchor, + related_quaidal_ids, external_refs, + source_framework, source_section, source_url, source_commit_sha, + source_title_original, source_license_note, + plagiarism_score_at_generation +""" + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/stats", response_model=StatsResponse) +def get_stats() -> StatsResponse: + """Counts by kind + the QUAIDAL source provenance (single source today).""" + with SessionLocal() as db: + counts = db.execute(text( + "SELECT kind, COUNT(*) AS n FROM compliance.derived_controls " + "WHERE source_framework = :fw GROUP BY kind" + ), {"fw": "BSI QUAIDAL"}).all() + meta = db.execute(text( + "SELECT source_commit_sha, source_license_note FROM compliance.derived_controls " + "WHERE source_framework = :fw LIMIT 1" + ), {"fw": "BSI QUAIDAL"}).first() + return StatsResponse( + counts_by_kind={r.kind: r.n for r in counts}, + source_framework="BSI QUAIDAL", + source_commit_sha=meta.source_commit_sha if meta else None, + license_note=meta.source_license_note if meta else None, + ) + + +@router.get("/controls", response_model=ControlsListResponse) +def list_controls( + kind: Optional[str] = Query(None, description="criterion | building_block | measure | metric"), + limit: int = Query(500, ge=1, le=2000), + offset: int = Query(0, ge=0), +) -> ControlsListResponse: + """List QUAIDAL-derived controls, optionally filtered by kind.""" + where = ["source_framework = :fw"] + params: dict = {"fw": "BSI QUAIDAL", "limit": limit, "offset": offset} + if kind: + where.append("kind = :kind") + params["kind"] = kind + + sql = ( + f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls " + f"WHERE {' AND '.join(where)} " + "ORDER BY source_section LIMIT :limit OFFSET :offset" + ) + count_sql = f"SELECT COUNT(*) FROM compliance.derived_controls WHERE {' AND '.join(where)}" + + with SessionLocal() as db: + rows = db.execute(text(sql), params).all() + total = db.execute(text(count_sql), {k: v for k, v in params.items() if k not in ("limit", "offset")}).scalar() or 0 + return ControlsListResponse(total=int(total), controls=[_row_to_control(r) for r in rows]) + + +@router.get("/controls/{derived_id}", response_model=DerivedControl) +def get_control(derived_id: str) -> DerivedControl: + with SessionLocal() as db: + row = db.execute(text( + f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls WHERE derived_id = :id" + ), {"id": derived_id}).first() + if not row: + raise HTTPException(status_code=404, detail=f"Control {derived_id} not found") + return _row_to_control(row) + + +@router.get("/criteria", response_model=list[DerivedControl]) +def list_criteria() -> list[DerivedControl]: + """Returns the 10 QKB criteria. Use /criteria/{section_id} for the full child tree.""" + with SessionLocal() as db: + rows = db.execute(text( + f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls " + "WHERE source_framework = :fw AND kind = 'criterion' ORDER BY source_section" + ), {"fw": "BSI QUAIDAL"}).all() + return [_row_to_control(r) for r in rows] + + +@router.get("/criteria/{section_id}", response_model=CriterionWithChildren) +def get_criterion_tree(section_id: str) -> CriterionWithChildren: + """Single QKB with the building blocks it references and the measures/metrics those reference. + + `section_id` is the canonical QUAIDAL ID, e.g. `QKB-01`. + """ + section_id_upper = section_id.upper() + with SessionLocal() as db: + criterion_row = db.execute(text( + f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls " + "WHERE source_framework = :fw AND source_section = :sid AND kind = 'criterion'" + ), {"fw": "BSI QUAIDAL", "sid": section_id_upper}).first() + if not criterion_row: + raise HTTPException(status_code=404, detail=f"Criterion {section_id_upper} not found") + + building_block_ids = criterion_row.related_quaidal_ids or [] + building_blocks = [] + if building_block_ids: + qb_rows = db.execute(text( + f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls " + "WHERE source_framework = :fw AND kind = 'building_block' " + "AND source_section = ANY(:ids) ORDER BY source_section" + ), {"fw": "BSI QUAIDAL", "ids": building_block_ids}).all() + building_blocks = [_row_to_control(r) for r in qb_rows] + + # Collect measure IDs from each building block, then fetch them + measure_ids: list[str] = [] + for qb in building_blocks: + measure_ids.extend(mid for mid in qb.related_quaidal_ids if mid.startswith("MA-")) + measures = [] + if measure_ids: + ma_rows = db.execute(text( + f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls " + "WHERE source_framework = :fw AND kind = 'measure' " + "AND source_section = ANY(:ids) ORDER BY source_section" + ), {"fw": "BSI QUAIDAL", "ids": list(set(measure_ids))}).all() + measures = [_row_to_control(r) for r in ma_rows] + + # Collect metric IDs from each measure + metric_ids: list[str] = [] + for ma in measures: + metric_ids.extend(mid for mid in ma.related_quaidal_ids if mid.startswith("QM-")) + metrics = [] + if metric_ids: + qm_rows = db.execute(text( + f"SELECT {_SELECT_COLUMNS} FROM compliance.derived_controls " + "WHERE source_framework = :fw AND kind = 'metric' " + "AND source_section = ANY(:ids) ORDER BY source_section" + ), {"fw": "BSI QUAIDAL", "ids": list(set(metric_ids))}).all() + metrics = [_row_to_control(r) for r in qm_rows] + + return CriterionWithChildren( + criterion=_row_to_control(criterion_row), + building_blocks=building_blocks, + measures=measures, + metrics=metrics, + ) diff --git a/backend-compliance/main.py b/backend-compliance/main.py index 0130c83f..03e859cb 100644 --- a/backend-compliance/main.py +++ b/backend-compliance/main.py @@ -55,6 +55,7 @@ from compliance.api.saving_scan_routes import router as saving_scan_router from compliance.api.agent_migration_routes import router as agent_migration_router from compliance.api.vendor_assessment_routes import router as vendor_assessment_router from compliance.api.cra_routes import router as cra_router +from compliance.api.quaidal_routes import router as quaidal_router # Middleware from middleware import ( @@ -168,6 +169,7 @@ app.include_router(vendor_assessment_router, prefix="/api") # CRA (Cyber Resilience Act) Compliance app.include_router(cra_router, prefix="/api") +app.include_router(quaidal_router, prefix="/api") if __name__ == "__main__":