feat(controls): Mandanten-Suppression — per-tenant Applicability-Override

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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-16 16:35:38 +02:00
parent 400eba592e
commit 7aabfbe5b5
7 changed files with 286 additions and 21 deletions
@@ -59,6 +59,7 @@ _ROUTER_MODULES = [
"control_generator_routes", "control_generator_routes",
"crosswalk_routes", "crosswalk_routes",
"use_case_controls_routes", "use_case_controls_routes",
"control_suppression_routes",
"process_task_routes", "process_task_routes",
"evidence_check_routes", "evidence_check_routes",
"vvt_library_routes", "vvt_library_routes",
@@ -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}
@@ -11,7 +11,7 @@ from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from classroom_engine.database import get_db 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/" description="atom-grain: out-of-scope-Adressaten (Aufsichtsbefugnis/"
"Mitgliedstaat/Dritter/meta) einblenden (Default: advisory ausgeblendet)", "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), limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
svc: UseCaseControlsService = Depends(get_use_case_controls_service), svc: UseCaseControlsService = Depends(get_use_case_controls_service),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Controls for a topic. Atom-grain (Haiku: relevant + sub_topic) wenn vorhanden, """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(): with translate_domain_errors():
return svc.controls_for_use_case( return svc.controls_for_use_case(
use_case, primary_only, limit, offset, sub_topic, tier, use_case, primary_only, limit, offset, sub_topic, tier,
include_out_of_scope, include_out_of_scope, x_tenant_id, include_suppressed,
) )
@@ -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()
}
@@ -109,11 +109,13 @@ _LIST_SQL = text("""
# seed. Preferred whenever the use-case has been processed. # seed. Preferred whenever the use-case has been processed.
_ATOM_LIST_SQL = text(""" _ATOM_LIST_SQL = text("""
SELECT ac.control_uuid, ac.sub_topic, ac.canonical_obligation, ac.relevant, 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, cc.control_id, cc.title, cc.objective, cc.severity, cc.license_rule,
cpl.source_regulation, cpl.source_article cpl.source_regulation, cpl.source_article
FROM atom_classification ac FROM atom_classification ac
JOIN canonical_controls cc ON cc.id = ac.control_uuid 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 ( LEFT JOIN LATERAL (
SELECT cpl.source_regulation, cpl.source_article SELECT cpl.source_regulation, cpl.source_article
FROM control_parent_links cpl 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) WHERE ac.use_case = :uc AND (:all = true OR ac.relevant = true)
AND (:incl_oos = true OR ac.addressee IS NULL AND (:incl_oos = true OR ac.addressee IS NULL
OR ac.addressee NOT IN ('aufsichtsbefugnis','staat_eu','dritter','meta')) 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) AND (:sub IS NULL OR ac.sub_topic = :sub)
ORDER BY ac.relevant DESC, ac.sub_topic NULLS LAST, ORDER BY ac.relevant DESC, ac.sub_topic NULLS LAST,
CASE cc.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 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: class UseCaseControlsService:
"""Topic → controls retrieval over the seeded use-case mappings.""" """Topic → controls retrieval over the seeded use-case mappings."""
@@ -184,6 +202,8 @@ class UseCaseControlsService:
sub_topic: Optional[str] = None, sub_topic: Optional[str] = None,
tier: str = "core", 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]: ) -> dict[str, Any]:
"""Controls for ``use_case``. Prefers the atom-grain Haiku classification """Controls for ``use_case``. Prefers the atom-grain Haiku classification
(precise + sub-topic-organized) when present; falls back to the (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. 'review' tier (shown, flagged) so the human browse view loses nothing.
``include_out_of_scope``: by default out-of-scope addressees (authority ``include_out_of_scope``: by default out-of-scope addressees (authority
power / member-state / foreign / meta) are hidden (advisory, never 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): if not is_valid_use_case(use_case):
raise NotFoundError(f"Unknown use_case '{use_case}'") raise NotFoundError(f"Unknown use_case '{use_case}'")
uc = REGISTRY[use_case] uc = REGISTRY[use_case]
@@ -204,7 +227,8 @@ class UseCaseControlsService:
if self._has_atom_grain(use_case): if self._has_atom_grain(use_case):
return self._atom_grain(uc, lim, off, sub_topic, tier, 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) --- # --- master-grain fallback (recall seed) ---
count_sql = ( count_sql = (
@@ -251,9 +275,13 @@ class UseCaseControlsService:
def _atom_grain( def _atom_grain(
self, uc, lim: int, off: int, sub_topic: Optional[str], tier: str = "core", 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]: ) -> dict[str, Any]:
all_flag = tier == "all" 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( counts = self.db.execute(text(
"SELECT count(*) FILTER (WHERE relevant), " "SELECT count(*) FILTER (WHERE relevant), "
"count(*) FILTER (WHERE NOT relevant), " "count(*) FILTER (WHERE NOT relevant), "
@@ -266,24 +294,33 @@ class UseCaseControlsService:
core_count = int((counts[0] if counts else 0) or 0) core_count = int((counts[0] if counts else 0) or 0)
review_count = int((counts[1] 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) oos_count = int((counts[2] if counts else 0) or 0)
tier_total = core_count + review_count if all_flag else core_count suppressed_count = 0
total = tier_total if include_out_of_scope else tier_total - oos_count 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 = { facet = {
row[0]: int(row[1]) row[0]: int(row[1])
for row in self.db.execute(text( for row in self.db.execute(text(
"SELECT COALESCE(sub_topic, '(none)'), count(*) " "SELECT COALESCE(ac.sub_topic, '(none)'), count(*) "
"FROM atom_classification WHERE use_case = :uc " "FROM atom_classification ac "
"AND (:all = true OR relevant = true) " "LEFT JOIN control_suppressions cs ON cs.control_uuid = ac.control_uuid "
"AND (:incl_oos = true OR addressee IS NULL OR addressee NOT IN " " 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')) " " ('aufsichtsbefugnis','staat_eu','dritter','meta')) "
"AND (:incl_suppressed = true OR cs.control_uuid IS NULL) "
"GROUP BY 1 ORDER BY 2 DESC" "GROUP BY 1 ORDER BY 2 DESC"
), {"uc": uc.key, "all": all_flag, ), p).fetchall()
"incl_oos": include_out_of_scope}).fetchall()
} }
rows = self.db.execute(_ATOM_LIST_SQL, { rows = self.db.execute(
"uc": uc.key, "all": all_flag, "incl_oos": include_out_of_scope, _ATOM_LIST_SQL, {**p, "lim": lim, "off": off}).fetchall()
"sub": sub_topic, "lim": lim, "off": off,
}).fetchall()
controls = [ controls = [
{ {
"id": str(r.control_uuid), "id": str(r.control_uuid),
@@ -301,15 +338,17 @@ class UseCaseControlsService:
"addressee": r.addressee, "addressee": r.addressee,
"applicable": addressee_applicable(r.addressee), "applicable": addressee_applicable(r.addressee),
"is_gov": addressee_is_gov(r.addressee), "is_gov": addressee_is_gov(r.addressee),
"suppressed": bool(r.suppressed),
} }
for r in rows for r in rows
] ]
return { return {
"use_case": uc.key, "label": uc.label, "group": uc.group, "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, "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_out_of_scope": bool(include_out_of_scope),
"include_suppressed": bool(include_suppressed),
"limit": lim, "offset": off, "limit": lim, "offset": off,
"sub_topic": sub_topic, "subtopic_counts": facet, "controls": controls, "sub_topic": sub_topic, "subtopic_counts": facet, "controls": controls,
} }
@@ -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()
@@ -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 $$;