296 lines
11 KiB
Python
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())
|