from __future__ import annotations import base64 from datetime import UTC, datetime import hashlib import json from threading import Lock from typing import Any from sqlalchemy import and_, or_ from sqlmodel import Session, select from ..core.redaction import redact_value from ..core.time import utc_now from ..db.models import AppSetting, AuditEvent, JobRun, NotificationEvent from .action_registry import ACTIONS, ACTION_RISK from .approval_service import create_approval, get_approval from .audit_logger import log_event from .policy_engine import evaluate_policy _IDEMPOTENCY_LOCK = Lock() _IDEMPOTENCY_PREFIX = "n8n:idempotency:" _IDEMPOTENCY_MAX_RECORDS = 5_000 _HEARTBEAT_KEY = "n8n:workflow_heartbeat" def _redact(value: Any) -> Any: return redact_value(value) def _encode_cursor(timestamp: datetime, event_id: str) -> str: raw = json.dumps({"at": timestamp.astimezone(UTC).isoformat(), "id": event_id}, separators=(",", ":")) return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=") def _decode_cursor(value: str | None) -> tuple[datetime, str]: if not value: return datetime.fromtimestamp(0, UTC), "" try: padded = value + "=" * (-len(value) % 4) payload = json.loads(base64.urlsafe_b64decode(padded).decode()) timestamp = datetime.fromisoformat(str(payload["at"]).replace("Z", "+00:00")) return timestamp.astimezone(UTC), str(payload["id"]) except (ValueError, KeyError, TypeError, json.JSONDecodeError) as exc: raise ValueError("invalid_cursor") from exc def _as_utc(value: datetime) -> datetime: return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) def event_feed(session: Session, *, cursor: str | None, limit: int) -> dict[str, Any]: after_at, after_id = _decode_cursor(cursor) after_db = after_at.replace(tzinfo=None) events: list[dict[str, Any]] = [] audit_query = ( select(AuditEvent) .where(or_( AuditEvent.timestamp > after_db, and_(AuditEvent.timestamp == after_db, AuditEvent.id > after_id), )) .order_by(AuditEvent.timestamp, AuditEvent.id) .limit(limit + 1) ) for row in session.exec(audit_query).all(): events.append(_redact({ "id": row.id, "occurredAt": _as_utc(row.timestamp), "type": "audit.action", "severity": row.risk, "source": row.source, "target": row.target, "payload": { "actionId": row.action_id, "policyDecision": row.policy_decision, "result": row.result, "reason": row.reason, "metadata": row.redacted_metadata or {}, }, })) notification_query = ( select(NotificationEvent) .where(or_( NotificationEvent.created_at > after_db, and_(NotificationEvent.created_at == after_db, NotificationEvent.id > after_id), )) .order_by(NotificationEvent.created_at, NotificationEvent.id) .limit(limit + 1) ) for row in session.exec(notification_query).all(): events.append(_redact({ "id": row.id, "occurredAt": _as_utc(row.created_at), "type": "notification.created", "severity": row.severity, "source": row.source, "target": row.target, "payload": { "title": row.title, "message": row.message, "category": row.category, "status": row.status, }, })) events.sort(key=lambda item: (item["occurredAt"], item["id"])) page = events[:limit] for item in page: item["occurredAt"] = _as_utc(item["occurredAt"]).isoformat() next_cursor = cursor if page: last = page[-1] next_cursor = _encode_cursor(datetime.fromisoformat(last["occurredAt"]), last["id"]) return {"items": page, "nextCursor": next_cursor, "hasMore": len(events) > len(page)} def record_heartbeat(session: Session, payload: dict[str, Any]) -> dict[str, Any]: now = utc_now() setting = session.get(AppSetting, _HEARTBEAT_KEY) if not setting: setting = AppSetting(key=_HEARTBEAT_KEY, is_secret=False) previous = setting.value_json if isinstance(setting.value_json, dict) else {} branch = str(payload.get("branch", "unknown"))[:40] branch_status = { "branch": branch, "processedEvents": max(0, min(int(payload.get("processedEvents", 0) or 0), 100_000)), "backlogRemaining": bool(payload.get("backlogRemaining", False)), "confidence": str(payload.get("confidence", ""))[:20], "receivedAt": now.isoformat(), } branches = previous.get("branches") if isinstance(previous.get("branches"), dict) else {} branches = {**branches, branch: branch_status} event_status = branch_status if branch == "events" else branches.get("events", {}) setting.value_json = { "workflowId": str(payload.get("workflowId", ""))[:120], "workflowVersion": str(payload.get("workflowVersion", ""))[:40], "branch": branch, "processedEvents": int(event_status.get("processedEvents", 0) or 0), "backlogRemaining": bool(event_status.get("backlogRemaining", False)), "confidence": branch_status["confidence"], "receivedAt": now.isoformat(), "branches": branches, } setting.updated_at = now session.add(setting) session.commit() return {"accepted": True, "receivedAt": now.isoformat(), "canExecute": False} def heartbeat_status(session: Session) -> dict[str, Any]: setting = session.get(AppSetting, _HEARTBEAT_KEY) payload = setting.value_json if setting and isinstance(setting.value_json, dict) else {} received_at = payload.get("receivedAt") fresh = False if received_at: try: observed = _as_utc(datetime.fromisoformat(str(received_at).replace("Z", "+00:00"))) fresh = (datetime.now(UTC) - observed).total_seconds() <= 30 * 60 except ValueError: fresh = False return { "state": "reachable" if fresh else "configured", "lastVerifiedAt": received_at, "workflow": payload or None, } def _trim_idempotency_records(session: Session) -> None: stale = session.exec( select(AppSetting) .where(AppSetting.key.startswith(_IDEMPOTENCY_PREFIX)) .order_by(AppSetting.updated_at.desc()) .offset(_IDEMPOTENCY_MAX_RECORDS - 1) ).all() for row in stale: session.delete(row) def preflight(session: Session, action: str, target: str) -> dict[str, Any]: decision = evaluate_policy(session, action=action, target=target, source="automation") return { "action": action, "target": target, "source": "automation", "risk": decision.get("risk", ACTION_RISK.get(action, "critical")), "policyDecision": decision["decision"], "reason": decision["reason"], "knownAction": action in ACTIONS, "executionAvailable": False, "nextStep": "create_approval" if decision["decision"] == "approval_required" else "manual_review" if decision["decision"] == "blocked" else "use_typed_appops_surface", } def request_approval( session: Session, *, action: str, target: str, reason: str, plan: list[str], idempotency_key: str, ) -> dict[str, Any]: with _IDEMPOTENCY_LOCK: return _request_approval_locked( session, action=action, target=target, reason=reason, plan=plan, idempotency_key=idempotency_key, ) def _request_approval_locked( session: Session, *, action: str, target: str, reason: str, plan: list[str], idempotency_key: str, ) -> dict[str, Any]: request_digest = hashlib.sha256(json.dumps({ "action": action, "target": target, "reason": reason, "plan": plan, }, sort_keys=True, separators=(",", ":")).encode()).hexdigest() setting_key = f"{_IDEMPOTENCY_PREFIX}{hashlib.sha256(idempotency_key.encode()).hexdigest()}" existing = session.get(AppSetting, setting_key) if existing: if existing.value_json.get("requestDigest") != request_digest: raise ValueError("idempotency_key_reused_with_different_request") return {**existing.value_json.get("response", {}), "idempotentReplay": True} preview = preflight(session, action, target) if preview["policyDecision"] == "blocked": log_event( session, source="automation", identity="n8n", action_id=action, target=target, risk=preview["risk"], policy_decision="blocked", reason=preview["reason"], result="blocked", redacted_metadata={"integration": "n8n", "request": "approval"}, ) raise PermissionError("action_blocked_by_policy") if preview["policyDecision"] != "approval_required": response = {**preview, "created": False, "message": "Deze actie vereist volgens de huidige policy geen approval; n8n voert haar niet uit."} else: safe_reason = str(_redact(reason))[:1000] approval = create_approval( session, action=action, target=target, source="automation", risk=preview["risk"], reason=safe_reason, requester="n8n", context={"plan": [_redact(item)[:300] for item in plan[:30]], "integration": "n8n"}, ) audit = log_event( session, source="automation", identity="n8n", action_id="approval.request", target=target, target_type="approval", risk=preview["risk"], policy_decision="approval_required", reason=safe_reason, result="approval_requested", redacted_metadata={"approvalId": approval.id, "requestedAction": action, "integration": "n8n"}, ) response = { **preview, "created": True, "approvalId": approval.id, "approvalStatus": approval.status, "auditId": audit.id, "expiresAt": approval.expires_at.isoformat(), } _trim_idempotency_records(session) session.add(AppSetting(key=setting_key, value_json={"requestDigest": request_digest, "response": response})) session.commit() return {**response, "idempotentReplay": False} def approval_status(session: Session, approval_id: str) -> dict[str, Any] | None: approval = get_approval(session, approval_id) if not approval: return None return { "id": approval.id, "action": approval.action, "target": approval.target, "risk": approval.risk, "status": approval.status, "expiresAt": approval.expires_at, "jobId": approval.job_id, "auditEventId": approval.audit_event_id, } def job_status(session: Session, job_id: str) -> dict[str, Any] | None: job = session.get(JobRun, job_id) if not job: return None return _redact(job.model_dump())