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