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:
@@ -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 $$;
|
||||||
Reference in New Issue
Block a user