Files
Unraid-AppOps-Gateway-Public/backend/app/services/safety_center_service.py
T

851 lines
34 KiB
Python

from __future__ import annotations
from collections import Counter
from datetime import UTC, datetime
import hashlib
import json
import logging
import os
from threading import Lock, Thread
from time import perf_counter
from typing import Any
from sqlalchemy import func
from sqlmodel import Session, select
from ..core.redaction import redact_message
from ..core.time import utc_now
from ..db.models import App, AppSetting, AuditEvent, Connector, DiscoveryRun, JobRun, Policy, Secret
from . import ai_remediation_planner, ai_security_reviewer
from .audit_logger import log_event
logger = logging.getLogger(__name__)
DEFAULT_CACHE_TTL_SECONDS = 60
CACHE_KEY = 'safety-dashboard-v1'
CACHE_SCHEMA_VERSION = 2
CACHE_SETTING_KEY = 'safety_center:dashboard_cache'
DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS = 900
_dashboard_cache: dict[str, Any] | None = None
_skip_persistent_cache_once = False
_background_refresh_lock = Lock()
_background_refresh_running = False
SECTION_DEFINITIONS = [
('setup-coverage', 'Setup & Coverage', '/setup', {'coverage', 'connector_config', 'setup'}),
('external-exposure', 'External Exposure', '/network', {'exposure'}),
('authentication-coverage', 'Authentication Coverage', '/apps/authentik', {'auth'}),
('secret-hygiene', 'Secret Hygiene', '/secrets', {'secrets'}),
('policy-posture', 'Policy Posture', '/policies', {'policies'}),
('inventory-app-review', 'Inventory & App Review', '/inventory', {'inventory', 'connectors'}),
('media-pipeline', 'Media Pipeline', '/media-pipeline', {'media'}),
('unraid-host-safety', 'Unraid & Host Safety', '/unraid', {'unraid'}),
('audit-activity', 'Audit & Activity', '/audit', {'audit'}),
]
SEVERITY_PENALTY = {'critical': 20, 'high': 12, 'medium': 6, 'low': 2, 'info': 0}
CATEGORY_MAP = {
'auth_coverage': 'auth',
'coverage': 'setup',
'connector_config': 'connectors',
}
SOURCE_LABELS = {
'inventory': 'Inventory',
'portainer': 'Portainer',
'unraid': 'Unraid',
'npm': 'NPM',
'authentik': 'Authentik',
'secret_storage': 'Secret Storage',
'policies': 'Policies',
'audit': 'Audit',
'media_pipeline': 'Media Pipeline',
}
def _now() -> str:
return datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
def _parse_utc(value: str) -> datetime:
return datetime.fromisoformat(value.replace('Z', '+00:00'))
def _ttl_seconds() -> int:
raw = os.getenv('SAFETY_CACHE_TTL_SECONDS', str(DEFAULT_CACHE_TTL_SECONDS))
try:
return max(1, int(raw))
except (TypeError, ValueError):
return DEFAULT_CACHE_TTL_SECONDS
def _persistent_cache_max_stale_seconds() -> int:
raw = os.getenv('SAFETY_PERSISTENT_CACHE_MAX_STALE_SECONDS', str(DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS))
try:
return max(0, int(raw))
except (TypeError, ValueError):
return DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS
def invalidate_cache() -> None:
global _dashboard_cache, _skip_persistent_cache_once
_dashboard_cache = None
_skip_persistent_cache_once = True
def _redact(value: Any) -> Any:
if isinstance(value, str):
return redact_message(value)
if isinstance(value, list):
return [_redact(item) for item in value]
if isinstance(value, dict):
return {
str(key): _redact(item)
for key, item in value.items()
if 'secret' not in str(key).lower() and 'token' not in str(key).lower()
}
return value
def _state_key(kind: str, item_id: str) -> str:
return f'safety_center:{kind}:{item_id}:status'
def _get_state(session: Session, kind: str, item_id: str, default: str = 'open') -> str:
row = session.get(AppSetting, _state_key(kind, item_id))
if not row:
return default
return str(row.value_json.get('status') or default)
def _set_state(session: Session, kind: str, item_id: str, status: str) -> dict[str, Any]:
row = session.get(AppSetting, _state_key(kind, item_id))
if not row:
row = AppSetting(key=_state_key(kind, item_id), is_secret=False)
row.value_json = {'status': status, 'updatedAt': _now()}
row.updated_at = utc_now()
session.add(row)
session.commit()
log_event(
session,
source='manual',
identity='safety_center',
action_id=f'safety.{kind}.{status}',
target=item_id,
target_type=kind,
risk='low',
policy_decision='allowed',
reason=f'Safety Center {kind} marked {status}.',
result='success',
redacted_metadata={'kind': kind, 'id': item_id, 'status': status},
)
invalidate_cache()
return {'success': True, 'id': item_id, 'status': status}
def _category(category: str) -> str:
return CATEGORY_MAP.get(category, category)
def _action_type(raw: str | None) -> str:
allowed = {
'open_page',
'configure_connector',
'configure_secret',
'run_review',
'create_remediation_plan',
'run_readonly_check',
'review_policy',
}
if raw == 'run_readonly_check':
return raw
if raw in {'policy_draft', 'review_policy'}:
return 'review_policy'
if raw == 'configure_secret':
return raw
if raw == 'configure_connector':
return raw
return 'open_page' if raw in {None, 'link'} else (raw if raw in allowed else 'open_page')
def _suggested_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for action in actions:
label = str(action.get('label') or action.get('type') or 'Open details')
href = action.get('href') or action.get('link')
result.append({
'label': _redact(label),
'actionType': _action_type(action.get('type')),
'href': href or '/safety',
'policyDecision': action.get('policyDecision'),
'executable': False,
})
return result
def _findings(session: Session, review: Any) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
for finding in review.findings:
source_status = _get_state(session, 'finding', finding.id, finding.status)
category = _category(finding.category)
findings.append({
'id': finding.id,
'title': _redact(finding.title),
'description': _redact(finding.description),
'severity': finding.severity,
'category': category,
'affectedResources': _redact(finding.affectedResources),
'evidence': _redact([item.model_dump() for item in finding.evidence]),
'suggestedActions': _suggested_actions([item.model_dump() for item in finding.suggestedActions]),
'source': 'AI Security Reviewer',
'status': source_status,
})
return findings
def _coverage(session: Session, summary: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str], list[str], dict[str, Any]]:
breakdown = summary.get('scoreBreakdown') if isinstance(summary.get('scoreBreakdown'), dict) else {}
sources = breakdown.get('coverageSources') if isinstance(breakdown.get('coverageSources'), dict) else {}
if not sources:
sources = {
'inventory': {'status': 'connected'},
'portainer': {'status': _connector_status(session, 'portainer')},
'unraid': {'status': _connector_status(session, 'unraid')},
'npm': {'status': _connector_status(session, 'nginx_proxy_manager')},
'authentik': {'status': _connector_status(session, 'authentik')},
'secret_storage': {'status': 'connected'},
'policies': {'status': 'connected'},
'audit': {'status': 'partial'},
'media_pipeline': {'status': 'partial'},
}
normalized = {
key: {'label': SOURCE_LABELS.get(key, key), 'status': str(value.get('status', 'unknown')), 'effectOnConfidence': value.get('effectOnConfidence', 'medium')}
for key, value in sources.items()
if isinstance(value, dict)
}
connected = [key for key, value in normalized.items() if value['status'] == 'connected']
missing = [key for key, value in normalized.items() if value['status'] in {'missing_config', 'unavailable', 'unsupported', 'error', 'unknown'}]
completeness_weight = sum(_coverage_weight(value['status']) for value in normalized.values())
completeness = {'percentage': round((completeness_weight / max(len(normalized), 1)) * 100), 'connectedSources': connected, 'missingSources': missing}
return normalized, connected, missing, completeness
def _connector_status(session: Session, connector_id: str) -> str:
connector = session.get(Connector, connector_id)
return str(connector.status if connector else 'unknown')
def _coverage_weight(status: str) -> float:
if status == 'connected':
return 1.0
if status == 'partial':
return 0.5
return 0.0
def _counts(findings: list[dict[str, Any]]) -> Counter:
return Counter(finding['severity'] for finding in findings if finding['status'] == 'open')
def _has_real_high_or_critical(findings: list[dict[str, Any]]) -> bool:
proven_high_risk_categories = {'auth', 'auth_coverage', 'exposure', 'policies', 'secrets', 'unraid'}
return any(
finding['status'] == 'open'
and finding['severity'] in {'high', 'critical'}
and finding['category'] in proven_high_risk_categories
for finding in findings
)
def _status(score: int, counts: Counter, missing: list[str], findings: list[dict[str, Any]]) -> str:
if counts['critical'] > 0:
return 'critical'
if missing:
return 'warning' if counts['high'] > 0 and _has_real_high_or_critical(findings) else 'partial'
if score < 85 or counts['medium'] or counts['high']:
return 'warning'
return 'healthy'
def _section_status(score: int, counts: Counter, missing_only: bool) -> str:
if counts['critical'] > 0:
return 'critical'
if missing_only and counts['high'] == 0:
return 'partial'
if score < 85 or counts['medium'] or counts['high']:
return 'warning'
return 'healthy'
def _section_for_category(category: str) -> str:
for section_id, _, _, categories in SECTION_DEFINITIONS:
if category in categories:
return section_id
return 'setup-coverage'
def _section_summary(section_id: str, section_findings: list[dict[str, Any]]) -> str:
if not section_findings:
return 'Geen open rule-based findings voor deze sectie.'
top = section_findings[0]
return str(top['title'])
def _sections(findings: list[dict[str, Any]], recommendations: list[dict[str, Any]], coverage: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for section_id, title, href, categories in SECTION_DEFINITIONS:
section_findings = [
finding
for finding in findings
if finding['status'] == 'open' and _section_for_category(finding['category']) == section_id
]
section_recommendations = [item for item in recommendations if item['sectionId'] == section_id and item['status'] == 'open']
counts = _counts(section_findings)
score = max(0, 100 - sum(SEVERITY_PENALTY.get(finding['severity'], 0) for finding in section_findings))
related_sources = _related_sources(section_id)
source_weight = sum(_coverage_weight(str(coverage.get(source, {}).get('status', 'unknown'))) for source in related_sources)
missing_only = bool(related_sources) and source_weight < len(related_sources) and not any(finding['severity'] in {'high', 'critical'} for finding in section_findings)
completeness = round((source_weight / max(len(related_sources), 1)) * 100) if related_sources else 100
result.append({
'id': section_id,
'title': title,
'status': _section_status(score, counts, missing_only),
'score': score,
'summary': _section_summary(section_id, section_findings),
'findingsCount': len(section_findings),
'recommendationsCount': len(section_recommendations),
'dataCompleteness': completeness,
'href': href,
'categories': sorted(categories),
})
return result
def _related_sources(section_id: str) -> list[str]:
return {
'setup-coverage': ['inventory', 'portainer', 'secret_storage', 'policies'],
'external-exposure': ['npm'],
'authentication-coverage': ['authentik', 'npm'],
'secret-hygiene': ['secret_storage'],
'policy-posture': ['policies'],
'inventory-app-review': ['inventory', 'portainer'],
'media-pipeline': ['media_pipeline'],
'unraid-host-safety': ['unraid', 'portainer'],
'audit-activity': ['audit'],
}.get(section_id, [])
def _base_recommendations(session: Session, findings: list[dict[str, Any]], coverage: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
recommendations: list[dict[str, Any]] = []
for source, detail in coverage.items():
if detail['status'] not in {'missing_config', 'unavailable', 'unsupported', 'error', 'unknown'}:
continue
if source in {'npm', 'authentik', 'unraid'}:
recommendations.append(_recommendation(
rec_id=f'safety-rec-configure-{source}',
title=f'Configureer {SOURCE_LABELS.get(source, source)} connector',
description='Vul de connectorconfiguratie en secret in en draai daarna een read-only test.',
severity='medium',
confidence='high',
source='Setup & Coverage',
affected=[source],
action_type='configure_connector',
href='/setup',
section_id='setup-coverage',
))
for finding in findings:
if finding['severity'] not in {'critical', 'high', 'medium'}:
continue
section_id = _section_for_category(finding['category'])
action = finding['suggestedActions'][0] if finding['suggestedActions'] else {}
recommendations.append(_recommendation(
rec_id=f'safety-rec-{finding["id"]}',
title=f'Review: {finding["title"]}',
description=finding['description'],
severity=finding['severity'],
confidence='high',
source=section_id,
affected=finding['affectedResources'],
action_type=action.get('actionType') or 'open_page',
href=action.get('href') or '/safety',
section_id=section_id,
source_finding_id=finding['id'],
policy_decision=action.get('policyDecision'),
))
return list({item['id']: item for item in recommendations}.values())
def _recommendation(
*,
rec_id: str,
title: str,
description: str,
severity: str,
confidence: str,
source: str,
affected: list[str],
action_type: str,
href: str,
section_id: str,
source_finding_id: str | None = None,
policy_decision: str | None = None,
) -> dict[str, Any]:
return {
'id': rec_id,
'title': _redact(title),
'description': _redact(description),
'severity': severity,
'confidence': confidence,
'source': source,
'affectedResources': _redact(affected),
'actionType': _action_type(action_type),
'href': href,
'policyDecision': policy_decision,
'executable': False,
'canCreateRemediationPlan': bool(source_finding_id),
'sourceFindingId': source_finding_id,
'sectionId': section_id,
'status': 'open',
}
def _add_local_findings(session: Session, findings: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = list(findings)
latest_run = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first()
if not latest_run:
result.append(_local_finding('safety-discovery-not-run', 'Container discovery is nog niet gedraaid', 'Discovery coverage ontbreekt; inventory review is daardoor partial.', 'medium', 'setup', ['discovery'], ['No discovery run found'], '/inventory'))
apps_need_review = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712
if apps_need_review:
href = f'/apps/{apps_need_review[0].id}' if len(apps_need_review) == 1 else '/inventory'
result.append(_local_finding('safety-apps-need-review', 'Apps wachten op review', f'{len(apps_need_review)} app(s) vragen operator review voordat bredere automation veilig is.', 'medium', 'inventory', [app.id for app in apps_need_review[:6]], ['needs_review=true'], href))
jobs = session.exec(select(JobRun).where(JobRun.status.in_(['queued', 'running']))).all()
if len(jobs) >= 5:
result.append(_local_finding('safety-many-open-jobs', 'Veel open jobs actief', f'{len(jobs)} jobs staan queued/running.', 'low', 'audit', ['jobs'], ['open jobs >= 5'], '/jobs'))
return list({item['id']: item for item in result}.values())
def _local_finding(finding_id: str, title: str, description: str, severity: str, category: str, affected: list[str], evidence: list[str], href: str) -> dict[str, Any]:
return {
'id': finding_id,
'title': title,
'description': description,
'severity': severity,
'category': category,
'affectedResources': affected,
'evidence': [{'source': 'safety_center', 'label': title, 'value': evidence, 'redacted': False}],
'suggestedActions': [{'label': 'Open details', 'actionType': 'open_page', 'href': href, 'policyDecision': 'allowed', 'executable': False}],
'source': 'Safety Center',
'status': 'open',
}
def _source_signature(session: Session) -> str:
from .app_registry import list_active_apps
from .inventory_discovery import latest_discovered_by_container
policies = [
{
'id': item.id,
'action': item.action,
'target': item.target,
'voice': item.voice,
'manual': item.manual,
'risk': item.risk,
}
for item in session.exec(select(Policy)).all()
]
connectors = [
{
'id': item.id,
'status': item.status,
'lastTest': item.last_test,
'secretRef': item.secret_ref,
'capabilities': item.capabilities,
}
for item in session.exec(select(Connector)).all()
]
apps = [
{
'id': item.id,
'status': item.status,
'connectorStatus': item.connector_status,
'containerStatus': item.container_status,
'needsReview': item.needs_review,
'discoveryStatus': item.discovery_status,
'protected': item.protected,
}
for item in list_active_apps(session)
]
settings = [
{
'key': item.key,
'updatedAt': item.updated_at.isoformat() if item.updated_at else None,
'isSecret': item.is_secret,
'status': item.value_json.get('status') if isinstance(item.value_json, dict) else None,
}
for item in session.exec(select(AppSetting)).all()
if item.key != CACHE_SETTING_KEY
and not item.key.startswith('ai_security:review_cache')
and item.key not in {'media_pipeline:summary_cache', 'network_exposure:cache'}
and (not item.is_secret or item.key.startswith('safety_center:'))
]
secret_rows = [
{
'id': item.id,
'name': item.name,
'updatedAt': item.updated_at.isoformat() if item.updated_at else None,
'usedBy': item.used_by_json,
}
for item in session.exec(select(Secret)).all()
]
discovered = [
{
'containerId': item.container_id,
'name': item.name,
'image': item.image,
'state': item.state,
'status': item.status,
'importStatus': item.import_status,
'matchedAppId': item.matched_app_id,
}
for item in latest_discovered_by_container(session).values()
]
job_count = session.exec(select(func.count(JobRun.id))).one()
latest_job_started = session.exec(select(func.max(JobRun.started_at))).one()
non_safety_audit = AuditEvent.action_id.not_like('safety.%')
audit_count = session.exec(select(func.count(AuditEvent.id)).where(non_safety_audit)).one()
latest_audit_timestamp = session.exec(select(func.max(AuditEvent.timestamp)).where(non_safety_audit)).one()
signature_payload = {
'cacheSchemaVersion': CACHE_SCHEMA_VERSION,
'policies': sorted(policies, key=lambda item: item['id']),
'connectors': sorted(connectors, key=lambda item: item['id']),
'apps': sorted(apps, key=lambda item: item['id']),
'settings': sorted(settings, key=lambda item: item['key']),
'secrets': sorted(secret_rows, key=lambda item: item['id']),
'discovered': sorted(discovered, key=lambda item: item['containerId']),
'counts': {
'jobs': job_count,
'latestJobStarted': latest_job_started.isoformat() if latest_job_started else None,
'audit': audit_count,
'latestAuditTimestamp': latest_audit_timestamp.isoformat() if latest_audit_timestamp else None,
},
}
serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':'))
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
def _payload(session: Session) -> dict[str, Any]:
review = ai_security_reviewer.generate_review(session)
security_summary = review.summary.model_dump()
coverage, connected, missing, completeness = _coverage(session, security_summary)
findings = _add_local_findings(session, _findings(session, review))
counts = _counts(findings)
score = max(0, min(100, 100 - sum(SEVERITY_PENALTY.get(finding['severity'], 0) for finding in findings if finding['status'] == 'open')))
recommendations = _base_recommendations(session, findings, coverage)
sections = _sections(findings, recommendations, coverage)
status = _status(score, counts, missing, findings)
summary = {
'status': status,
'score': score,
'generatedAt': _now(),
'dataCompleteness': completeness,
'coverageStatus': coverage,
'criticalCount': counts['critical'],
'highCount': counts['high'],
'mediumCount': counts['medium'],
'warningCount': counts['low'] + counts['medium'] + counts['high'],
'recommendationCount': len([item for item in recommendations if item['status'] == 'open']),
'connectedSources': connected,
'missingSources': missing,
'sections': sections,
}
score_breakdown = {
'baseScore': 100,
'finalScore': score,
'penalties': [
{
'source': finding['source'],
'reason': finding['title'],
'severity': finding['severity'],
'points': SEVERITY_PENALTY.get(finding['severity'], 0),
'findingId': finding['id'],
}
for finding in findings
if finding['status'] == 'open' and SEVERITY_PENALTY.get(finding['severity'], 0) > 0
],
'bonuses': [],
'missingCoverageImpact': 'Missing coverage lowers confidence and data completeness; it is not treated as critical without evidence.',
'criticalEvidence': [finding['id'] for finding in findings if finding['status'] == 'open' and finding['severity'] == 'critical'],
'explanation': f'Startscore 100 minus open finding penalties. Current status is {status}.',
'missingSources': missing,
'dataCompleteness': completeness,
'coverageStatus': coverage,
}
return {'summary': _redact(summary), 'sections': _redact(sections), 'findings': _redact(findings), 'recommendations': _redact(recommendations), 'scoreBreakdown': _redact(score_breakdown)}
def _coverage_sources(payload: dict[str, Any]) -> list[dict[str, Any]]:
coverage = payload.get('summary', {}).get('coverageStatus', {})
if not isinstance(coverage, dict):
return []
return [
{
'id': source,
'label': detail.get('label') or SOURCE_LABELS.get(source, source),
'status': detail.get('status', 'unknown'),
'effectOnConfidence': detail.get('effectOnConfidence'),
}
for source, detail in sorted(coverage.items())
if isinstance(detail, dict)
]
def _with_cache_metadata(payload: dict[str, Any], *, hit: bool, source: str, generated_at: str, expires_at: str, ttl_seconds: int, generation_duration_ms: int, stale_reason: str | None = None) -> dict[str, Any]:
dashboard = dict(payload)
dashboard['generatedAt'] = generated_at
dashboard['coverageSources'] = _coverage_sources(payload)
cache_meta: dict[str, Any] = {
'hit': hit,
'ttlSeconds': ttl_seconds,
'expiresAt': expires_at,
'source': source,
'generationDurationMs': generation_duration_ms,
}
if stale_reason:
cache_meta['staleReason'] = _redact(stale_reason)
dashboard['cache'] = cache_meta
return _redact(dashboard)
def _persistent_cache(session: Session, signature: str) -> dict[str, Any] | None:
row = session.get(AppSetting, CACHE_SETTING_KEY)
if not row or not isinstance(row.value_json, dict):
return None
value = row.value_json
if value.get('schemaVersion') != CACHE_SCHEMA_VERSION:
return None
if value.get('signature') != signature:
return None
payload = value.get('payload')
if not isinstance(payload, dict):
return None
return {
'key': CACHE_KEY,
'signature': signature,
'payload': payload,
'generatedAt': value.get('generatedAt'),
'expiresAt': value.get('expiresAt'),
'generationDurationMs': value.get('generationDurationMs', 0),
}
def _store_persistent_cache(session: Session, cache: dict[str, Any]) -> None:
row = session.get(AppSetting, CACHE_SETTING_KEY)
if not row:
row = AppSetting(key=CACHE_SETTING_KEY, is_secret=False)
row.value_json = {
'schemaVersion': CACHE_SCHEMA_VERSION,
'signature': cache.get('signature'),
'payload': _redact(cache.get('payload')),
'generatedAt': cache.get('generatedAt'),
'expiresAt': cache.get('expiresAt'),
'generationDurationMs': cache.get('generationDurationMs', 0),
}
row.updated_at = utc_now()
session.add(row)
session.commit()
def _schedule_background_refresh() -> bool:
global _background_refresh_running
with _background_refresh_lock:
if _background_refresh_running:
return False
_background_refresh_running = True
def refresh_worker() -> None:
global _background_refresh_running
try:
from ..db.session import engine
with Session(engine) as refresh_session:
get_dashboard(refresh_session, force_refresh=True)
except Exception: # noqa: BLE001 - stale safety data remains available on refresh failure.
logger.exception('Background Safety dashboard refresh failed')
finally:
with _background_refresh_lock:
_background_refresh_running = False
Thread(target=refresh_worker, name='appops-safety-refresh', daemon=True).start()
return True
def get_dashboard(session: Session, force_refresh: bool = False) -> dict[str, Any]:
global _dashboard_cache, _skip_persistent_cache_once
ttl_seconds = _ttl_seconds()
now_dt = datetime.now(UTC)
signature = _source_signature(session)
if _dashboard_cache and not force_refresh and _dashboard_cache.get('signature') == signature:
expires_at = _parse_utc(str(_dashboard_cache['expiresAt']))
if now_dt < expires_at:
return _with_cache_metadata(
_dashboard_cache['payload'],
hit=True,
source='cache',
generated_at=str(_dashboard_cache['generatedAt']),
expires_at=str(_dashboard_cache['expiresAt']),
ttl_seconds=ttl_seconds,
generation_duration_ms=int(_dashboard_cache.get('generationDurationMs', 0)),
)
skip_persistent = _skip_persistent_cache_once
_skip_persistent_cache_once = False
persisted = None if force_refresh or skip_persistent else _persistent_cache(session, signature)
if persisted:
expires_at = _parse_utc(str(persisted['expiresAt']))
max_stale_seconds = _persistent_cache_max_stale_seconds()
stale_until = expires_at.timestamp() + max_stale_seconds
if now_dt < expires_at:
_dashboard_cache = persisted
return _with_cache_metadata(
persisted['payload'],
hit=True,
source='cache',
generated_at=str(persisted['generatedAt']),
expires_at=str(persisted['expiresAt']),
ttl_seconds=ttl_seconds,
generation_duration_ms=int(persisted.get('generationDurationMs', 0)),
)
if max_stale_seconds > 0 and now_dt.timestamp() < stale_until:
_dashboard_cache = persisted
_schedule_background_refresh()
return _with_cache_metadata(
persisted['payload'],
hit=True,
source='stale-while-revalidate',
generated_at=str(persisted['generatedAt']),
expires_at=str(persisted['expiresAt']),
ttl_seconds=ttl_seconds,
generation_duration_ms=int(persisted.get('generationDurationMs', 0)),
)
started = perf_counter()
try:
payload = _payload(session)
except Exception as exc:
if _dashboard_cache:
return _with_cache_metadata(
_dashboard_cache['payload'],
hit=True,
source='stale',
generated_at=str(_dashboard_cache['generatedAt']),
expires_at=str(_dashboard_cache['expiresAt']),
ttl_seconds=ttl_seconds,
generation_duration_ms=int(_dashboard_cache.get('generationDurationMs', 0)),
stale_reason=str(exc),
)
raise
duration_ms = int((perf_counter() - started) * 1000)
generated_at = str(payload['summary']['generatedAt'])
expires_at = (now_dt.replace(microsecond=0).timestamp() + ttl_seconds)
expires_at_iso = datetime.fromtimestamp(expires_at, UTC).isoformat().replace('+00:00', 'Z')
_dashboard_cache = {
'key': CACHE_KEY,
'signature': signature,
'payload': payload,
'generatedAt': generated_at,
'expiresAt': expires_at_iso,
'generationDurationMs': duration_ms,
}
_store_persistent_cache(session, _dashboard_cache)
logger.info('Safety dashboard generated in %sms with score=%s status=%s', duration_ms, payload['summary'].get('score'), payload['summary'].get('status'))
return _with_cache_metadata(
payload,
hit=False,
source='fresh',
generated_at=generated_at,
expires_at=expires_at_iso,
ttl_seconds=ttl_seconds,
generation_duration_ms=duration_ms,
)
def get_summary(session: Session) -> dict[str, Any]:
return get_dashboard(session)['summary']
def get_sections(session: Session) -> list[dict[str, Any]]:
return get_dashboard(session)['sections']
def get_findings(session: Session) -> list[dict[str, Any]]:
return get_dashboard(session)['findings']
def get_recommendations(session: Session) -> list[dict[str, Any]]:
return get_dashboard(session)['recommendations']
def get_score_breakdown(session: Session) -> dict[str, Any]:
return get_dashboard(session)['scoreBreakdown']
def refresh(session: Session) -> dict[str, Any]:
invalidate_cache()
dashboard = get_dashboard(session, force_refresh=True)
log_event(
session,
source='manual',
identity='safety_center',
action_id='safety.refresh',
target='safety-center',
target_type='safety',
risk='low',
policy_decision='allowed',
reason='Safety Center read-only refresh.',
result='success',
redacted_metadata={'score': dashboard['summary']['score'], 'status': dashboard['summary']['status'], 'findings': len(dashboard['findings']), 'cache': dashboard['cache']['source']},
)
return dashboard
def acknowledge_finding(session: Session, finding_id: str) -> dict[str, Any]:
return _set_state(session, 'finding', finding_id, 'acknowledged')
def dismiss_finding(session: Session, finding_id: str) -> dict[str, Any]:
return _set_state(session, 'finding', finding_id, 'dismissed')
def dismiss_recommendation(session: Session, recommendation_id: str) -> dict[str, Any]:
return _set_state(session, 'recommendation', recommendation_id, 'dismissed')
def create_remediation_plan(session: Session, recommendation_id: str) -> dict[str, Any]:
recommendation = next((item for item in get_recommendations(session) if item['id'] == recommendation_id), None)
if not recommendation:
return {'success': False, 'status': 'not_found', 'recommendationId': recommendation_id, 'executable': False, 'href': '/ai/remediation-planner'}
finding_id = recommendation.get('sourceFindingId')
plan_id = None
if finding_id:
plan = ai_remediation_planner.generate_from_finding(session, str(finding_id))
plan_id = plan.id
log_event(
session,
source='manual',
identity='safety_center',
action_id='safety.recommendation.create_remediation_plan',
target=recommendation_id,
target_type='safety_recommendation',
risk='low',
policy_decision='allowed',
reason='Safety Center linked a non-executing remediation plan.',
result='success',
redacted_metadata={'recommendationId': recommendation_id, 'planId': plan_id},
)
invalidate_cache()
href = f'/ai/remediation-planner?plan={plan_id}' if plan_id else '/ai/remediation-planner'
return {'success': True, 'status': 'plan_linked', 'recommendationId': recommendation_id, 'planId': plan_id, 'href': href, 'executable': False}