From 7aabfbe5b5015aaddb45e2546c3aee412764c77d Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 16 Jun 2026 16:35:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(controls):=20Mandanten-Suppression=20?= =?UTF-8?q?=E2=80=94=20per-tenant=20Applicability-Override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geteilte Schicht für alle Surfaces (Workspace-Anwälte, Cyber-Risiko-Projekt, Admin): ein Mandant markiert ein Control als "nicht anwendbar" → in seinen Use-Case-Ansichten (und künftig Repo-Scans) ausgeblendet. - Migration 156: compliance.control_suppressions (PK tenant_id+control_uuid), reversibel (active + reverted_*), auditierbar (actor/reason/created_at). [migration-approved] - Service control_suppression: suppress/revert/list_suppressions + suppressed_control_uuids (geteilter Filter). - Routes: GET/POST /v1/controls/suppressions + POST .../{uuid}/revert (X-Tenant-ID). - controls_for_use_case: optionaler X-Tenant-ID + include_suppressed; suppressed per Default versteckt (nie gelöscht), suppressed_count, suppressed-Flag pro Control. Agenten/CRA ohne Tenant unberührt. - Tests: Request-Validierung + import-safety (E2E-Zyklus gegen macmini bewiesen). Co-Authored-By: Claude Opus 4.8 --- backend-compliance/compliance/api/__init__.py | 1 + .../api/control_suppression_routes.py | 65 ++++++++++++++ .../api/use_case_controls_routes.py | 12 ++- .../services/control_suppression.py | 84 +++++++++++++++++++ .../compliance/services/use_case_controls.py | 75 +++++++++++++---- .../tests/test_control_suppression.py | 35 ++++++++ .../migrations/156_control_suppressions.sql | 35 ++++++++ 7 files changed, 286 insertions(+), 21 deletions(-) create mode 100644 backend-compliance/compliance/api/control_suppression_routes.py create mode 100644 backend-compliance/compliance/services/control_suppression.py create mode 100644 backend-compliance/compliance/tests/test_control_suppression.py create mode 100644 backend-compliance/migrations/156_control_suppressions.sql diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py index 89cc1ccd..6518b4a6 100644 --- a/backend-compliance/compliance/api/__init__.py +++ b/backend-compliance/compliance/api/__init__.py @@ -59,6 +59,7 @@ _ROUTER_MODULES = [ "control_generator_routes", "crosswalk_routes", "use_case_controls_routes", + "control_suppression_routes", "process_task_routes", "evidence_check_routes", "vvt_library_routes", diff --git a/backend-compliance/compliance/api/control_suppression_routes.py b/backend-compliance/compliance/api/control_suppression_routes.py new file mode 100644 index 00000000..9ea312e1 --- /dev/null +++ b/backend-compliance/compliance/api/control_suppression_routes.py @@ -0,0 +1,65 @@ +"""Per-tenant control-suppression API (Applicability-Override). + + GET /v1/controls/suppressions — tenant's suppressions (audit list) + POST /v1/controls/suppressions — mark a control not-applicable + POST /v1/controls/suppressions/{uuid}/revert — undo (kept for audit) + +Tenant from X-Tenant-ID. Suppressed controls are hidden from the use-case views +(and future repo scans) but never deleted. NOTE: no `from __future__ import +annotations` — it breaks Pydantic v2; use Optional[...].""" + +from typing import Any, Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from classroom_engine.database import get_db +from compliance.api._http_errors import translate_domain_errors +from compliance.api.tenant_utils import get_tenant_id +from compliance.services import control_suppression as svc + +router = APIRouter(prefix="/v1/controls/suppressions", tags=["control-suppressions"]) + + +class SuppressRequest(BaseModel): + control_uuid: str + reason: Optional[str] = None + actor: Optional[str] = None + + +class RevertRequest(BaseModel): + reason: Optional[str] = None + actor: Optional[str] = None + + +@router.get("") +async def list_suppressions( + include_reverted: bool = False, + tid: str = Depends(get_tenant_id), + db: Session = Depends(get_db), +) -> list[dict[str, Any]]: + with translate_domain_errors(): + return svc.list_suppressions(db, tid, include_reverted) + + +@router.post("") +async def create_suppression( + body: SuppressRequest, + tid: str = Depends(get_tenant_id), + db: Session = Depends(get_db), +) -> dict[str, Any]: + with translate_domain_errors(): + return svc.suppress(db, tid, body.control_uuid, body.reason, body.actor) + + +@router.post("/{control_uuid}/revert") +async def revert_suppression( + control_uuid: str, + body: RevertRequest = RevertRequest(), + tid: str = Depends(get_tenant_id), + db: Session = Depends(get_db), +) -> dict[str, Any]: + with translate_domain_errors(): + ok = svc.revert(db, tid, control_uuid, body.actor, body.reason) + return {"reverted": ok, "control_uuid": control_uuid} diff --git a/backend-compliance/compliance/api/use_case_controls_routes.py b/backend-compliance/compliance/api/use_case_controls_routes.py index 1c9352a1..8fbcf182 100644 --- a/backend-compliance/compliance/api/use_case_controls_routes.py +++ b/backend-compliance/compliance/api/use_case_controls_routes.py @@ -11,7 +11,7 @@ from __future__ import annotations from typing import Any, Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Header, Query from sqlalchemy.orm import Session from classroom_engine.database import get_db @@ -62,14 +62,20 @@ async def controls_for_use_case( description="atom-grain: out-of-scope-Adressaten (Aufsichtsbefugnis/" "Mitgliedstaat/Dritter/meta) einblenden (Default: advisory ausgeblendet)", ), + include_suppressed: bool = Query( + False, + description="atom-grain: vom Mandanten als unanwendbar markierte Controls " + "einblenden (Default: ausgeblendet)", + ), + x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), svc: UseCaseControlsService = Depends(get_use_case_controls_service), ) -> dict[str, Any]: """Controls for a topic. Atom-grain (Haiku: relevant + sub_topic) wenn vorhanden, - sonst master-grain Seed.""" + sonst master-grain Seed. Mandanten-Suppression greift nur mit X-Tenant-ID.""" with translate_domain_errors(): return svc.controls_for_use_case( use_case, primary_only, limit, offset, sub_topic, tier, - include_out_of_scope, + include_out_of_scope, x_tenant_id, include_suppressed, ) diff --git a/backend-compliance/compliance/services/control_suppression.py b/backend-compliance/compliance/services/control_suppression.py new file mode 100644 index 00000000..f965502c --- /dev/null +++ b/backend-compliance/compliance/services/control_suppression.py @@ -0,0 +1,84 @@ +"""Per-tenant control suppression (Applicability-Override). A tenant marks a +control as 'not applicable' → it is hidden from that tenant's use-case views and +(via suppressed_control_uuids) future repo scans. Reversible (active flag + +reverted_*); auditable (actor + reason + created_at answer "why not checked?"). +See migration 156. Writes commit explicitly (request-scoped session).""" + +from typing import Any, Optional + +from sqlalchemy import text +from sqlalchemy.orm import Session + + +def suppress( + db: Session, tenant_id: str, control_uuid: str, + reason: Optional[str] = None, actor: Optional[str] = None, +) -> dict[str, Any]: + """Mark a control not-applicable for a tenant (idempotent; reactivates a + previously reverted suppression).""" + db.execute(text( + "INSERT INTO compliance.control_suppressions " + "(tenant_id, control_uuid, reason, actor, active, created_at, updated_at) " + "VALUES (:t, :c, :r, :a, TRUE, now(), now()) " + "ON CONFLICT (tenant_id, control_uuid) DO UPDATE SET " + "active=TRUE, reason=EXCLUDED.reason, actor=EXCLUDED.actor, " + "reverted_at=NULL, reverted_by=NULL, revert_reason=NULL, updated_at=now()" + ), {"t": tenant_id, "c": control_uuid, "r": reason, "a": actor}) + db.commit() + return {"tenant_id": tenant_id, "control_uuid": control_uuid, + "active": True, "reason": reason, "actor": actor} + + +def revert( + db: Session, tenant_id: str, control_uuid: str, + actor: Optional[str] = None, reason: Optional[str] = None, +) -> bool: + """Undo a suppression (keeps the row for audit). True if one was active.""" + res = db.execute(text( + "UPDATE compliance.control_suppressions " + "SET active=FALSE, reverted_at=now(), reverted_by=:a, revert_reason=:r, " + "updated_at=now() " + "WHERE tenant_id=:t AND control_uuid=:c AND active=TRUE" + ), {"t": tenant_id, "c": control_uuid, "a": actor, "r": reason}) + db.commit() + return (res.rowcount or 0) > 0 + + +def list_suppressions( + db: Session, tenant_id: str, include_reverted: bool = False, +) -> list[dict[str, Any]]: + """A tenant's suppressions (active by default) — the audit list answering + 'what did we exclude, by whom, when and why?'.""" + rows = db.execute(text( + "SELECT cs.control_uuid::text, cs.reason, cs.actor, cs.active, " + "cs.reverted_at, cs.reverted_by, cs.created_at, " + "cc.control_id, cc.title " + "FROM compliance.control_suppressions cs " + "JOIN canonical_controls cc ON cc.id = cs.control_uuid " + "WHERE cs.tenant_id = :t AND (:incl = TRUE OR cs.active = TRUE) " + "ORDER BY cs.updated_at DESC" + ), {"t": tenant_id, "incl": include_reverted}).fetchall() + return [ + { + "control_uuid": r[0], "reason": r[1], "actor": r[2], + "active": bool(r[3]), + "reverted_at": r[4].isoformat() if r[4] else None, + "reverted_by": r[5], + "created_at": r[6].isoformat() if r[6] else None, + "control_id": r[7], "title": r[8], + } + for r in rows + ] + + +def suppressed_control_uuids(db: Session, tenant_id: Optional[str]) -> set[str]: + """Active-suppressed control UUIDs for a tenant — shared filter for the + use-case views AND repo scans. Empty when no tenant (agent/CRA path).""" + if not tenant_id: + return set() + return { + str(r[0]) for r in db.execute(text( + "SELECT control_uuid FROM compliance.control_suppressions " + "WHERE tenant_id = :t AND active = TRUE" + ), {"t": tenant_id}).fetchall() + } diff --git a/backend-compliance/compliance/services/use_case_controls.py b/backend-compliance/compliance/services/use_case_controls.py index 46c46f94..c758686b 100644 --- a/backend-compliance/compliance/services/use_case_controls.py +++ b/backend-compliance/compliance/services/use_case_controls.py @@ -109,11 +109,13 @@ _LIST_SQL = text(""" # seed. Preferred whenever the use-case has been processed. _ATOM_LIST_SQL = text(""" SELECT ac.control_uuid, ac.sub_topic, ac.canonical_obligation, ac.relevant, - ac.addressee, + ac.addressee, (cs.control_uuid IS NOT NULL) AS suppressed, cc.control_id, cc.title, cc.objective, cc.severity, cc.license_rule, cpl.source_regulation, cpl.source_article FROM atom_classification ac JOIN canonical_controls cc ON cc.id = ac.control_uuid + LEFT JOIN control_suppressions cs + ON cs.control_uuid = ac.control_uuid AND cs.tenant_id = :tenant AND cs.active LEFT JOIN LATERAL ( SELECT cpl.source_regulation, cpl.source_article FROM control_parent_links cpl @@ -122,6 +124,7 @@ _ATOM_LIST_SQL = text(""" WHERE ac.use_case = :uc AND (:all = true OR ac.relevant = true) AND (:incl_oos = true OR ac.addressee IS NULL OR ac.addressee NOT IN ('aufsichtsbefugnis','staat_eu','dritter','meta')) + AND (:incl_suppressed = true OR cs.control_uuid IS NULL) AND (:sub IS NULL OR ac.sub_topic = :sub) ORDER BY ac.relevant DESC, ac.sub_topic NULLS LAST, CASE cc.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 @@ -130,6 +133,21 @@ _ATOM_LIST_SQL = text(""" """) +# Same WHERE as the list (tier + addressee + suppression) → exact `total` for +# pagination, without arithmetic over overlapping filters. +_ATOM_COUNT_SQL = text(""" + SELECT count(*) + FROM atom_classification ac + LEFT JOIN control_suppressions cs + ON cs.control_uuid = ac.control_uuid AND cs.tenant_id = :tenant AND cs.active + WHERE ac.use_case = :uc AND (:all = true OR ac.relevant = true) + AND (:incl_oos = true OR ac.addressee IS NULL + OR ac.addressee NOT IN ('aufsichtsbefugnis','staat_eu','dritter','meta')) + AND (:incl_suppressed = true OR cs.control_uuid IS NULL) + AND (:sub IS NULL OR ac.sub_topic = :sub) +""") + + class UseCaseControlsService: """Topic → controls retrieval over the seeded use-case mappings.""" @@ -184,6 +202,8 @@ class UseCaseControlsService: sub_topic: Optional[str] = None, tier: str = "core", include_out_of_scope: bool = False, + tenant_id: Optional[str] = None, + include_suppressed: bool = False, ) -> dict[str, Any]: """Controls for ``use_case``. Prefers the atom-grain Haiku classification (precise + sub-topic-organized) when present; falls back to the @@ -194,7 +214,10 @@ class UseCaseControlsService: 'review' tier (shown, flagged) so the human browse view loses nothing. ``include_out_of_scope``: by default out-of-scope addressees (authority power / member-state / foreign / meta) are hidden (advisory, never - deleted); set true to surface them.""" + deleted); set true to surface them. + ``tenant_id`` (+ ``include_suppressed``): controls the tenant marked + not-applicable are hidden by default (reversible, audited); None tenant + (agent/CRA path) = no suppression filter.""" if not is_valid_use_case(use_case): raise NotFoundError(f"Unknown use_case '{use_case}'") uc = REGISTRY[use_case] @@ -204,7 +227,8 @@ class UseCaseControlsService: if self._has_atom_grain(use_case): return self._atom_grain(uc, lim, off, sub_topic, tier, - bool(include_out_of_scope)) + bool(include_out_of_scope), + tenant_id, bool(include_suppressed)) # --- master-grain fallback (recall seed) --- count_sql = ( @@ -251,9 +275,13 @@ class UseCaseControlsService: def _atom_grain( self, uc, lim: int, off: int, sub_topic: Optional[str], tier: str = "core", - include_out_of_scope: bool = False, + include_out_of_scope: bool = False, tenant_id: Optional[str] = None, + include_suppressed: bool = False, ) -> dict[str, Any]: all_flag = tier == "all" + p = {"uc": uc.key, "all": all_flag, "incl_oos": include_out_of_scope, + "incl_suppressed": include_suppressed, "tenant": tenant_id, + "sub": sub_topic} counts = self.db.execute(text( "SELECT count(*) FILTER (WHERE relevant), " "count(*) FILTER (WHERE NOT relevant), " @@ -266,24 +294,33 @@ class UseCaseControlsService: core_count = int((counts[0] if counts else 0) or 0) review_count = int((counts[1] if counts else 0) or 0) oos_count = int((counts[2] if counts else 0) or 0) - tier_total = core_count + review_count if all_flag else core_count - total = tier_total if include_out_of_scope else tier_total - oos_count + suppressed_count = 0 + if tenant_id: + suppressed_count = int(self.db.execute(text( + "SELECT count(*) FROM atom_classification ac " + "JOIN control_suppressions cs ON cs.control_uuid = ac.control_uuid " + " AND cs.tenant_id = :tenant AND cs.active " + "WHERE ac.use_case = :uc AND (:all = true OR ac.relevant = true) " + "AND (:sub IS NULL OR ac.sub_topic = :sub)" + ), {"uc": uc.key, "all": all_flag, "tenant": tenant_id, + "sub": sub_topic}).scalar() or 0) + total = int(self.db.execute(_ATOM_COUNT_SQL, p).scalar() or 0) facet = { row[0]: int(row[1]) for row in self.db.execute(text( - "SELECT COALESCE(sub_topic, '(none)'), count(*) " - "FROM atom_classification WHERE use_case = :uc " - "AND (:all = true OR relevant = true) " - "AND (:incl_oos = true OR addressee IS NULL OR addressee NOT IN " + "SELECT COALESCE(ac.sub_topic, '(none)'), count(*) " + "FROM atom_classification ac " + "LEFT JOIN control_suppressions cs ON cs.control_uuid = ac.control_uuid " + " AND cs.tenant_id = :tenant AND cs.active " + "WHERE ac.use_case = :uc AND (:all = true OR ac.relevant = true) " + "AND (:incl_oos = true OR ac.addressee IS NULL OR ac.addressee NOT IN " " ('aufsichtsbefugnis','staat_eu','dritter','meta')) " + "AND (:incl_suppressed = true OR cs.control_uuid IS NULL) " "GROUP BY 1 ORDER BY 2 DESC" - ), {"uc": uc.key, "all": all_flag, - "incl_oos": include_out_of_scope}).fetchall() + ), p).fetchall() } - rows = self.db.execute(_ATOM_LIST_SQL, { - "uc": uc.key, "all": all_flag, "incl_oos": include_out_of_scope, - "sub": sub_topic, "lim": lim, "off": off, - }).fetchall() + rows = self.db.execute( + _ATOM_LIST_SQL, {**p, "lim": lim, "off": off}).fetchall() controls = [ { "id": str(r.control_uuid), @@ -301,15 +338,17 @@ class UseCaseControlsService: "addressee": r.addressee, "applicable": addressee_applicable(r.addressee), "is_gov": addressee_is_gov(r.addressee), + "suppressed": bool(r.suppressed), } for r in rows ] return { "use_case": uc.key, "label": uc.label, "group": uc.group, - "granularity": "atom", "tier": tier, "total": int(total), + "granularity": "atom", "tier": tier, "total": total, "core_count": core_count, "review_count": review_count, - "out_of_scope_count": oos_count, + "out_of_scope_count": oos_count, "suppressed_count": suppressed_count, "include_out_of_scope": bool(include_out_of_scope), + "include_suppressed": bool(include_suppressed), "limit": lim, "offset": off, "sub_topic": sub_topic, "subtopic_counts": facet, "controls": controls, } diff --git a/backend-compliance/compliance/tests/test_control_suppression.py b/backend-compliance/compliance/tests/test_control_suppression.py new file mode 100644 index 00000000..ee59c45f --- /dev/null +++ b/backend-compliance/compliance/tests/test_control_suppression.py @@ -0,0 +1,35 @@ +"""Tests for per-tenant control suppression. The service is DB-bound (verified +e2e against the seeded DB); here we pin the request-model validation + import +safety so CI catches breakage in the routes/service modules.""" + +import pytest + +from compliance.api.control_suppression_routes import RevertRequest, SuppressRequest +from compliance.services import control_suppression + + +def test_suppress_request_requires_control_uuid(): + with pytest.raises(Exception): + SuppressRequest() # type: ignore[call-arg] # control_uuid missing + req = SuppressRequest(control_uuid="c-1", reason="für uns nicht anwendbar", + actor="dsb@kunde.de") + assert req.control_uuid == "c-1" + assert req.reason == "für uns nicht anwendbar" + assert req.actor == "dsb@kunde.de" + + +def test_revert_request_fields_optional(): + r = RevertRequest() + assert r.reason is None and r.actor is None + + +def test_service_exposes_expected_api(): + for fn in ("suppress", "revert", "list_suppressions", + "suppressed_control_uuids"): + assert callable(getattr(control_suppression, fn)) + + +def test_suppressed_uuids_empty_without_tenant(): + # no tenant → no filter, no DB access + assert control_suppression.suppressed_control_uuids(db=None, tenant_id=None) == set() + assert control_suppression.suppressed_control_uuids(db=None, tenant_id="") == set() diff --git a/backend-compliance/migrations/156_control_suppressions.sql b/backend-compliance/migrations/156_control_suppressions.sql new file mode 100644 index 00000000..565bfb78 --- /dev/null +++ b/backend-compliance/migrations/156_control_suppressions.sql @@ -0,0 +1,35 @@ +-- Migration 156: control_suppressions — per-tenant Applicability-Override. +-- Ein Mandant markiert ein Control als "unbrauchbar / nicht anwendbar" -> es +-- wird in seinen Use-Case-Ansichten (und künftig Repo-Scans) ausgeblendet. +-- REVERSIBEL (active=false + reverted_*, Zeile bleibt) und AUDIT-tauglich +-- (actor + reason + created_at beantworten "warum nicht geprüft?"). PER-TENANT. +-- Composite PK (tenant_id, control_uuid) = genau eine aktuelle Zeile je +-- Mandant+Control; Re-Suppress reaktiviert. Additiv, idempotent. [migration-approved] + +SET search_path TO compliance, public; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'compliance' + AND table_name = 'canonical_controls') THEN + + CREATE TABLE IF NOT EXISTS control_suppressions ( + tenant_id UUID NOT NULL, + control_uuid UUID NOT NULL + REFERENCES canonical_controls(id) ON DELETE CASCADE, + reason TEXT, + actor VARCHAR(120), + active BOOLEAN NOT NULL DEFAULT TRUE, + reverted_at TIMESTAMPTZ, + reverted_by VARCHAR(120), + revert_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, control_uuid) + ); + CREATE INDEX IF NOT EXISTS idx_ctrl_suppr_tenant_active + ON control_suppressions(tenant_id, active); + + END IF; +END $$;