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

296 lines
11 KiB
Python

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())