4a25ac1379
Check Cross-Plugin Imports / check (push) Has been cancelled
Verifikation: Alle 17 Audit-Findings gegen den Code geprueft — alle bestaetigt. Backend-Lifecycle-Fixes umgesetzt; 4 Frontend-Plugin-Architektur-Punkte als Phase Q in die Roadmap eingeplant. - P1 list_workspaces: Module + User-Counts gebuendelt laden (Editor-Overwrite-Bug) - P1 active-manifests: Tenant-Deaktivierung (tenant_plugin_activation) filtern - P1 uninstall: volle Service-Deactivation VOR registry.uninstall() - P1 ContractRegistry: DB-Aktivstatus-Guard (Restart-Edge-Case) + Re-Activate - P1/P2 Field-Definitions: voller Lifecycle (register/unregister) im Service - P1/P2 Contact-Felddefinitionen (39) ins ContactsPlugin-Manifest verschoben - P1 12 fehlende Permission-Keys registriert (AST-Scan: 0 fehlend) - P2 contact_folder -> ContactsPlugin; ENTITY_PLUGIN_OWNERS wird befuellt - P2 Entity-Permission-Fallback fail-closed statt contacts:read - P2 forgejo_error_reporter is_core=False; DMS is_core=True (ADR-020) - P2 Worker: Contacts-Trash-Cleanup ins Plugin (get_job_modules-Discovery) - P1/P2 DSGVO-Export delegiert an DSAR-Collector (kein Core->Contacts) - P2 False-green Tests korrigiert (or True, veraltete Route-Count-Assertion) Verifikation: tests/test_audit_architecture_fixes.py 17/17; Regressionen gruen (contacts_lifecycle, entity_registry, workspace_scopes, rbac, lifecycle_service); Combo-Order-Test 35/35; Cross-Plugin-Checker 497/0; compileall sauber; ruff auf 7-Error-Baseline. Doku: PROGRESS.md Audit-Section, PLATFORM_ROADMAP.md Phase Q (Q1-Q4), plugin-development-guide.md Lifecycle, permissions.md Katalog.
307 lines
12 KiB
Python
307 lines
12 KiB
Python
"""Central sensitive-data boundary — field redaction and data-exposure policy.
|
|
|
|
Provides a single source of truth for which entity fields are sensitive
|
|
and which downstream systems (LLM context, search, embeddings, RAG,
|
|
agent memory, export) are allowed to receive them.
|
|
|
|
This module does **not** replace ``permission_registry.py`` or the
|
|
``sensitivity`` field in ``manifest.py``; it respects their classifications
|
|
but adds a central enforcement layer at the points where data leaves the
|
|
system (logs, exports, index, embeddings, LLM context).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# SENSITIVE_FIELDS — fields that must NEVER appear in external outputs
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
SENSITIVE_FIELDS: dict[str, set[str]] = {
|
|
"contact": {
|
|
"password_hash",
|
|
"smtp_password",
|
|
"imap_password",
|
|
"api_key",
|
|
"oauth_token",
|
|
"session_token",
|
|
"encryption_key",
|
|
},
|
|
"user": {
|
|
"password_hash",
|
|
"api_key",
|
|
"session_token",
|
|
},
|
|
"mail_account": {
|
|
"smtp_password",
|
|
"imap_password",
|
|
"oauth_token",
|
|
},
|
|
"system_settings": {
|
|
"secret_key",
|
|
"encryption_key",
|
|
},
|
|
}
|
|
|
|
_REDACTED = "***REDACTED***"
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# DATA_EXPOSURE_POLICY — per entity+field, which systems may receive data
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
# Schema: {entity_type: {field_name: {system: bool}}}
|
|
# Systems: llm_context, search, embeddings, rag, agent_memory, export
|
|
#
|
|
# Default derivation from sensitivity levels:
|
|
# normal → all systems allowed
|
|
# sensitive → only export (with permission)
|
|
# critical → nothing allowed
|
|
#
|
|
# Fields listed in SENSITIVE_FIELDS are always blocked everywhere regardless
|
|
# of any policy entry.
|
|
|
|
_EXPOSURE_SYSTEMS = (
|
|
"llm_context",
|
|
"search",
|
|
"embeddings",
|
|
"rag",
|
|
"agent_memory",
|
|
"export",
|
|
)
|
|
|
|
_ALL_ALLOWED = {s: True for s in _EXPOSURE_SYSTEMS}
|
|
_ALL_BLOCKED = {s: False for s in _EXPOSURE_SYSTEMS}
|
|
_EXPORT_ONLY = {s: (s == "export") for s in _EXPOSURE_SYSTEMS}
|
|
|
|
# Explicit overrides for specific entity+field combinations.
|
|
# Fields not listed here derive their policy from the sensitivity level
|
|
# (see ``_derive_policy_from_sensitivity``).
|
|
DATA_EXPOSURE_POLICY: dict[str, dict[str, dict[str, bool]]] = {
|
|
"contact": {
|
|
# Sensitive financial fields — allowed in export with permission,
|
|
# blocked from LLM/embeddings/search.
|
|
"code": _EXPORT_ONLY,
|
|
"accounting_code": _EXPORT_ONLY,
|
|
"vendor_accounting_code": _EXPORT_ONLY,
|
|
"vat_code": _EXPORT_ONLY,
|
|
"fiscal_code": _EXPORT_ONLY,
|
|
"commerce_code": _EXPORT_ONLY,
|
|
"purchase_number": _EXPORT_ONLY,
|
|
"bic": _EXPORT_ONLY,
|
|
"mobilephone": _EXPORT_ONLY,
|
|
"notes": _EXPORT_ONLY,
|
|
"tags": _EXPORT_ONLY,
|
|
},
|
|
}
|
|
|
|
# Sensitivity level → default exposure policy
|
|
_SENSITIVITY_DEFAULTS: dict[str, dict[str, bool]] = {
|
|
"normal": _ALL_ALLOWED,
|
|
"sensitive": _EXPORT_ONLY,
|
|
"critical": _ALL_BLOCKED,
|
|
}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Sensitive-field helpers
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def get_sensitive_fields(entity_type: str) -> set[str]:
|
|
"""Return the set of sensitive field names for *entity_type*.
|
|
|
|
Returns an empty set if the entity type is not registered.
|
|
"""
|
|
return set(SENSITIVE_FIELDS.get(entity_type, set()))
|
|
|
|
|
|
def is_sensitive(entity_type: str, field_name: str) -> bool:
|
|
"""Check whether *field_name* is sensitive for *entity_type*."""
|
|
return field_name in SENSITIVE_FIELDS.get(entity_type, set())
|
|
|
|
|
|
def sanitize_dict(data: dict[str, Any], entity_type: str) -> dict[str, Any]:
|
|
"""Return a copy of *data* with sensitive fields replaced by ``***REDACTED***``.
|
|
|
|
Non-sensitive fields are preserved as-is. Nested dicts are processed
|
|
recursively. The original dict is not mutated.
|
|
"""
|
|
sensitive = SENSITIVE_FIELDS.get(entity_type, set())
|
|
result: dict[str, Any] = {}
|
|
for key, value in data.items():
|
|
if key in sensitive:
|
|
result[key] = _REDACTED
|
|
elif isinstance(value, dict):
|
|
result[key] = sanitize_dict(value, entity_type)
|
|
else:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def register_sensitive_fields(entity_type: str, fields: set[str]) -> None:
|
|
"""Register additional sensitive fields for *entity_type*.
|
|
|
|
Plugins call this at startup to declare their own sensitive fields.
|
|
Merges with any existing fields for the entity type.
|
|
"""
|
|
existing = SENSITIVE_FIELDS.setdefault(entity_type, set())
|
|
existing |= fields
|
|
logger.debug(
|
|
"Registered sensitive fields for %s: %s", entity_type, fields
|
|
)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Data-exposure policy helpers
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _derive_policy_from_sensitivity(
|
|
entity_type: str, field_name: str
|
|
) -> dict[str, bool]:
|
|
"""Derive a default exposure policy from the permission registry.
|
|
|
|
Falls back to ``_ALL_ALLOWED`` if no sensitivity information is
|
|
available for the field.
|
|
"""
|
|
# Check explicit policy first
|
|
entity_policy = DATA_EXPOSURE_POLICY.get(entity_type, {})
|
|
if field_name in entity_policy:
|
|
return dict(entity_policy[field_name])
|
|
|
|
# Try to get sensitivity from the permission registry (lazy import to
|
|
# avoid circular dependencies at module load time). Use the registry's
|
|
# combined view (core + plugin field definitions) — contact fields moved
|
|
# to the ContactsPlugin manifest (audit P1/P2), so CORE_FIELD_DEFINITIONS
|
|
# alone no longer covers them.
|
|
try:
|
|
from app.core.permission_registry import get_permission_registry
|
|
|
|
for fd in get_permission_registry().get_all_field_definitions():
|
|
if fd.get("module") == entity_type and fd.get("field") == field_name:
|
|
sensitivity = fd.get("sensitivity", "normal")
|
|
return dict(_SENSITIVITY_DEFAULTS.get(sensitivity, _ALL_ALLOWED))
|
|
except Exception:
|
|
pass
|
|
|
|
# Unknown field — default to all allowed (fail-open for normal data)
|
|
return dict(_ALL_ALLOWED)
|
|
|
|
|
|
def get_exposure_policy(entity_type: str, field_name: str) -> dict[str, bool]:
|
|
"""Return the exposure policy dict for *entity_type* / *field_name*.
|
|
|
|
The returned dict has keys: ``llm_context``, ``search``, ``embeddings``,
|
|
``rag``, ``agent_memory``, ``export`` — each mapped to a bool.
|
|
|
|
Fields listed in :data:`SENSITIVE_FIELDS` are always fully blocked.
|
|
"""
|
|
# Sensitive fields are always blocked everywhere
|
|
if is_sensitive(entity_type, field_name):
|
|
return dict(_ALL_BLOCKED)
|
|
|
|
return _derive_policy_from_sensitivity(entity_type, field_name)
|
|
|
|
|
|
def _filter_for_system(
|
|
data: dict[str, Any], entity_type: str, system: str
|
|
) -> dict[str, Any]:
|
|
"""Generic filter — remove fields whose policy disallows *system*."""
|
|
sensitive = SENSITIVE_FIELDS.get(entity_type, set())
|
|
result: dict[str, Any] = {}
|
|
for key, value in data.items():
|
|
# Always block sensitive fields
|
|
if key in sensitive:
|
|
continue
|
|
policy = get_exposure_policy(entity_type, key)
|
|
if policy.get(system, False):
|
|
if isinstance(value, dict):
|
|
result[key] = _filter_for_system(value, entity_type, system)
|
|
else:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def filter_for_llm_context(data: dict[str, Any], entity_type: str) -> dict[str, Any]:
|
|
"""Remove fields not allowed for LLM context."""
|
|
return _filter_for_system(data, entity_type, "llm_context")
|
|
|
|
|
|
def filter_for_search(data: dict[str, Any], entity_type: str) -> dict[str, Any]:
|
|
"""Remove fields not allowed for search / index."""
|
|
return _filter_for_system(data, entity_type, "search")
|
|
|
|
|
|
def filter_for_embeddings(data: dict[str, Any], entity_type: str) -> dict[str, Any]:
|
|
"""Remove fields not allowed for embedding generation."""
|
|
return _filter_for_system(data, entity_type, "embeddings")
|
|
|
|
|
|
def filter_for_export(data: dict[str, Any], entity_type: str) -> dict[str, Any]:
|
|
"""Remove fields not allowed for data export."""
|
|
return _filter_for_system(data, entity_type, "export")
|
|
|
|
|
|
def filter_for_rag(data: dict[str, Any], entity_type: str) -> dict[str, Any]:
|
|
"""Remove fields not allowed for RAG pipelines."""
|
|
return _filter_for_system(data, entity_type, "rag")
|
|
|
|
|
|
def filter_for_agent_memory(data: dict[str, Any], entity_type: str) -> dict[str, Any]:
|
|
"""Remove fields not allowed for agent memory persistence."""
|
|
return _filter_for_system(data, entity_type, "agent_memory")
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# AI-provider compliance helpers
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
# Data classes that providers may be approved to process
|
|
KNOWN_DATA_CLASSES = (
|
|
"public",
|
|
"internal",
|
|
"sensitive",
|
|
"critical",
|
|
)
|
|
|
|
|
|
def check_provider_compliance(
|
|
allowed_data_classes: list[str] | None,
|
|
data_class: str,
|
|
) -> bool:
|
|
"""Check whether a provider is allowed to process *data_class*.
|
|
|
|
Args:
|
|
allowed_data_classes: The provider's ``allowed_data_classes`` list.
|
|
``None`` or empty means no restriction (fail-open for backward
|
|
compatibility).
|
|
data_class: One of :data:`KNOWN_DATA_CLASSES`.
|
|
|
|
Returns:
|
|
``True`` if the provider is allowed or unconfigured.
|
|
"""
|
|
if not allowed_data_classes:
|
|
return True
|
|
return data_class in allowed_data_classes
|
|
|
|
|
|
def get_data_class_for_field(entity_type: str, field_name: str) -> str:
|
|
"""Determine the data class (public/internal/sensitive/critical) for a field.
|
|
|
|
Used by LLM client to check provider compliance before sending data.
|
|
"""
|
|
if is_sensitive(entity_type, field_name):
|
|
return "critical"
|
|
policy = get_exposure_policy(entity_type, field_name)
|
|
if not any(policy.values()):
|
|
return "critical"
|
|
if policy.get("export") and not policy.get("llm_context"):
|
|
return "sensitive"
|
|
if all(policy.values()):
|
|
return "public"
|
|
return "internal"
|