Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,295 @@
|
||||
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())
|
||||
Reference in new issue
Block a user