168 lines
5.9 KiB
Python
168 lines
5.9 KiB
Python
"""Fail-closed operation authorization for an isolated Git writer."""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from isolated_writer_token_registry import TokenRegistry
|
|
|
|
|
|
ROUTE_OPERATIONS = {
|
|
("GET", "/api/runtime-status"): "runtime.status.read",
|
|
("GET", "/api/baseline"): "baseline.read",
|
|
("GET", "/api/patches"): "patches.read",
|
|
("POST", "/api/commit-file"): "commit_file.atomic",
|
|
}
|
|
OID_RE = re.compile(r"[0-9a-f]{40}")
|
|
|
|
|
|
class AuthorizationError(PermissionError):
|
|
def __init__(self, code: str, status: int = 403):
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.status = status
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AuthorizedGrant:
|
|
record: dict[str, Any]
|
|
operation: str
|
|
use_count: int
|
|
|
|
@property
|
|
def token_id(self) -> str:
|
|
return str(self.record["token_id"])
|
|
|
|
@property
|
|
def task_id(self) -> str:
|
|
return str(self.record["task_id"])
|
|
|
|
|
|
def operation_for(method: str, path: str) -> str:
|
|
operation = ROUTE_OPERATIONS.get((method.upper(), path))
|
|
if not operation:
|
|
raise AuthorizationError("isolated_writer_operation_forbidden")
|
|
return operation
|
|
|
|
|
|
def _parse_time(value: Any) -> datetime:
|
|
try:
|
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
except (TypeError, ValueError):
|
|
raise AuthorizationError("writer_token_time_invalid") from None
|
|
if parsed.tzinfo is None:
|
|
raise AuthorizationError("writer_token_time_invalid")
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _git_is_ancestor(workspace: Path, base: str, branch: str) -> bool:
|
|
result = subprocess.run(
|
|
["git", "merge-base", "--is-ancestor", base, branch],
|
|
cwd=workspace,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
|
|
def _validate_grant(
|
|
grant: dict[str, Any],
|
|
*,
|
|
audience: str,
|
|
source_ip: str,
|
|
operation: str,
|
|
branch: str | None,
|
|
path: str | None,
|
|
workspace: Path,
|
|
current_time: datetime,
|
|
) -> None:
|
|
if int(grant.get("schema_version", 0)) != 2:
|
|
raise AuthorizationError("writer_token_policy_v2_required", 401)
|
|
if str(grant.get("audience") or "") != audience:
|
|
raise AuthorizationError("writer_token_audience_rejected")
|
|
try:
|
|
peer = ipaddress.ip_address(source_ip)
|
|
networks = [ipaddress.ip_network(str(value), strict=True) for value in grant.get("source_cidrs", [])]
|
|
except ValueError:
|
|
raise AuthorizationError("writer_token_source_rejected") from None
|
|
if not networks or not any(peer in network for network in networks):
|
|
raise AuthorizationError("writer_token_source_rejected")
|
|
created = _parse_time(grant.get("created_at"))
|
|
not_before = _parse_time(grant.get("not_before"))
|
|
expires = _parse_time(grant.get("expires_at"))
|
|
if expires <= not_before or (expires - created).total_seconds() > 1800:
|
|
raise AuthorizationError("writer_token_lifetime_rejected")
|
|
if current_time < not_before:
|
|
raise AuthorizationError("writer_token_not_yet_valid", 401)
|
|
if current_time >= expires:
|
|
raise AuthorizationError("writer_token_expired", 401)
|
|
if bool(grant.get("revoked")):
|
|
raise AuthorizationError("writer_token_revoked", 401)
|
|
operations = grant.get("allowed_operations")
|
|
if not isinstance(operations, list) or operation not in operations:
|
|
raise AuthorizationError("writer_token_operation_rejected")
|
|
expected_branch = str(grant.get("allowed_branch") or "")
|
|
configured_branch = os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", "")
|
|
if expected_branch != configured_branch:
|
|
raise AuthorizationError("writer_token_branch_rejected")
|
|
if branch is not None and branch != expected_branch:
|
|
raise AuthorizationError("writer_token_branch_rejected")
|
|
if path is not None:
|
|
paths = grant.get("allowed_paths")
|
|
if not isinstance(paths, list) or path not in paths:
|
|
raise AuthorizationError("writer_token_path_rejected")
|
|
base = str(grant.get("approved_base_oid") or "").lower()
|
|
if not OID_RE.fullmatch(base):
|
|
raise AuthorizationError("writer_token_base_rejected")
|
|
if not _git_is_ancestor(workspace, base, expected_branch):
|
|
raise AuthorizationError("writer_token_base_not_ancestor")
|
|
|
|
|
|
def authorize(
|
|
registry: TokenRegistry,
|
|
token: str,
|
|
*,
|
|
method: str,
|
|
path: str,
|
|
source_ip: str,
|
|
workspace: Path,
|
|
branch: str | None = None,
|
|
target_path: str | None = None,
|
|
current_time: datetime | None = None,
|
|
) -> AuthorizedGrant:
|
|
if os.getenv("AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED", "") != "2":
|
|
raise AuthorizationError("writer_token_policy_v2_not_required")
|
|
if not token:
|
|
raise AuthorizationError("unauthorized", 401)
|
|
grant = registry.find(token)
|
|
if grant is None:
|
|
raise AuthorizationError("unauthorized", 401)
|
|
operation = operation_for(method, path)
|
|
_validate_grant(
|
|
grant,
|
|
audience=os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", ""),
|
|
source_ip=source_ip,
|
|
operation=operation,
|
|
branch=branch,
|
|
path=target_path,
|
|
workspace=workspace,
|
|
current_time=current_time or datetime.now(timezone.utc),
|
|
)
|
|
limits = grant.get("max_uses")
|
|
if not isinstance(limits, dict):
|
|
raise AuthorizationError("writer_token_use_limit_missing")
|
|
try:
|
|
used = registry.consume(str(grant["token_id"]), operation, int(limits[operation]))
|
|
except (KeyError, TypeError, ValueError):
|
|
raise AuthorizationError("writer_token_use_limit_missing") from None
|
|
except PermissionError as exc:
|
|
code = str(exc)
|
|
raise AuthorizationError(code, 401 if code == "writer_token_revoked" else 403) from None
|
|
return AuthorizedGrant(grant, operation, used)
|