from __future__ import annotations import json from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.parse import urlparse class AuthorizationError(ValueError): pass REQUIRED = { "engagement_id", "authorization_id", "authorizer", "approved_at", "expires_at", "target", "target_owner", "environment", "source", "routes", "fixture_ids", "credential_lane", "credential_role", "credential_max_ttl_seconds", "techniques", "prohibited_techniques", "rate_limit_per_minute", "max_concurrency", "window_start", "window_end", "operator_contact", "abort_contact", "posture_claim", "attacker_model", "finding_destination", "target_owner_acknowledged_at", } @dataclass(frozen=True) class Engagement: raw: dict[str, Any] @classmethod def load(cls, path: str | Path, *, now: datetime | None = None) -> "Engagement": data = json.loads(Path(path).read_text(encoding="utf-8")) missing = sorted(REQUIRED - data.keys()) if missing: raise AuthorizationError(f"incomplete engagement; missing: {', '.join(missing)}") engagement = cls(data) engagement.validate(now=now) return engagement def validate(self, *, now: datetime | None = None) -> None: if self.raw.get("status") == "cancelled": raise AuthorizationError("engagement is cancelled") if self.raw.get("status") == "expired": raise AuthorizationError( "engagement window elapsed with no live run; identifier must not be reused" ) if self.raw.get("status") == "aborted": raise AuthorizationError( "engagement aborted without target evidence; identifier must not be reused" ) if self.raw.get("status") == "proposed": raise AuthorizationError( "engagement is proposed; operator approval and owner acknowledgement are pending" ) current = now or datetime.now(UTC) start = _timestamp(self.raw["window_start"]) end = _timestamp(self.raw["window_end"]) expiry = _timestamp(self.raw["expires_at"]) approved = _timestamp(self.raw["approved_at"]) if not self.raw["target_owner_acknowledged_at"]: raise AuthorizationError("target-owner acknowledgement is pending") acknowledged = _timestamp(self.raw["target_owner_acknowledged_at"]) if not approved <= current <= min(end, expiry): raise AuthorizationError("engagement is outside its approved time/expiry window") if current < start: raise AuthorizationError("engagement window has not started") if acknowledged < approved: raise AuthorizationError("target-owner acknowledgement predates approval") if acknowledged > current: raise AuthorizationError("target-owner acknowledgement is in the future") if start < approved: raise AuthorizationError("engagement window starts before approval") if self.raw["max_concurrency"] != 1: raise AuthorizationError("v0.1 permits exactly one in-flight API/database operation") if not 1 <= self.raw["rate_limit_per_minute"] <= 60: raise AuthorizationError("API rate ceiling must be between 1 and 60 per minute") if self.raw["environment"] == "production" and not self.raw.get("production_approval"): raise AuthorizationError("production requires production-specific approval") if self.raw["finding_destination"] != "risk-nexus": raise AuthorizationError("finding destination must be risk-nexus") parsed = urlparse(self.raw["target"]) if parsed.scheme and parsed.scheme not in {"http", "https", "postgresql"}: raise AuthorizationError("unsupported target scheme") if not self.raw["fixture_ids"]: raise AuthorizationError("at least one disposable fixture id is required") if not 1 <= self.raw["credential_max_ttl_seconds"] <= 3600: raise AuthorizationError("credential TTL must be between 1 and 3600 seconds") def permits(self, *, technique: str, route: str) -> None: if technique not in self.raw["techniques"]: raise AuthorizationError(f"technique not authorized: {technique}") if route not in self.raw["routes"]: raise AuthorizationError(f"route not authorized: {route}") def _timestamp(value: str) -> datetime: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) if parsed.tzinfo is None: raise AuthorizationError("engagement timestamps must include a timezone") return parsed.astimezone(UTC)