1636 lines
63 KiB
Python
1636 lines
63 KiB
Python
#!/usr/bin/env python3
|
|
"""Durable Owner Authorization broker for unattended AIOS preparation work.
|
|
|
|
This module deliberately separates durable Owner intent authorization from
|
|
cryptographic release approval. A durable grant may authorize preparation
|
|
operations such as governed-task projection and release-candidate creation,
|
|
but it can never authorize deployment, tag publication, baseline finalization
|
|
or runtime switching.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone, timedelta
|
|
import argparse
|
|
import hashlib
|
|
import grp
|
|
import hmac
|
|
from http.server import BaseHTTPRequestHandler
|
|
import json
|
|
import os
|
|
import pwd
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
import socket
|
|
import socketserver
|
|
import stat
|
|
import tempfile
|
|
import struct
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any, Mapping
|
|
|
|
POLICY_SCHEMA = "aios.owner-durable-authorization.v1"
|
|
RECEIPT_SCHEMA = "aios.owner-durable-authorization-receipt.v1"
|
|
DECISION_SCHEMA = "aios.owner-durable-authorization-decision.v1"
|
|
PROJECTION_SCHEMA = "aios.external-capability-projection.request.v1"
|
|
PROJECTION_LIST_SCHEMA = "aios.external-capability-projection.list.v1"
|
|
CALLER_PROJECTION_SCHEMA = "aios.caller-admission-projection.v1"
|
|
CALLER_PROJECTION_TTL_DEFAULT = 300
|
|
CALLER_PROJECTION_SCOPES = frozenset({
|
|
"governed-task:project",
|
|
"release:candidate",
|
|
})
|
|
GATE_AMENDMENT_SCHEMA = "aios.release-preparation.gate-amendment.v1"
|
|
GATE_AMENDMENT_CLASSIFICATION = "SUPERVISOR_GLOBAL_EXTERNAL_ACK_GATE_REMEDIATED"
|
|
|
|
ALLOWED_PERSISTENT_CAPABILITIES = frozenset({
|
|
"governed-task:project",
|
|
"release:candidate",
|
|
})
|
|
SAFE_PROJECTED_TASK_CAPABILITIES = frozenset({
|
|
"git.read",
|
|
"git.test",
|
|
"governed.write.branch",
|
|
"governed.write.file",
|
|
})
|
|
FORBIDDEN_PERSISTENT_CAPABILITIES = frozenset({
|
|
"submitOwnerApproval",
|
|
"startDeployment",
|
|
"tag_publish",
|
|
"baseline_registry_finalize",
|
|
"runtime_switch",
|
|
"main_write",
|
|
"production_deploy",
|
|
"force_push",
|
|
"credential_admin",
|
|
})
|
|
ACCOUNT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{3,255}$")
|
|
GRANT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,255}$")
|
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
VERSION_RE = re.compile(r"^V[0-9]+R[0-9]+C[0-9]+$")
|
|
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
|
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
|
JOB_RE = re.compile(r"^job-[0-9a-f]{32}$")
|
|
MAX_BODY = 512 * 1024
|
|
SO_PEERCRED_SIZE = struct.calcsize("3i")
|
|
|
|
|
|
def _peer_process_status(pid: int) -> tuple[int, int, set[int]]:
|
|
status_path = Path(f"/proc/{pid}/status")
|
|
try:
|
|
lines = status_path.read_text(encoding="utf-8").splitlines()
|
|
except (OSError, UnicodeError) as exc:
|
|
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403) from exc
|
|
fields: dict[str, str] = {}
|
|
for line in lines:
|
|
if ":" in line:
|
|
key, value = line.split(":", 1)
|
|
if key in {"Uid", "Gid", "Groups"}:
|
|
fields[key] = value.strip()
|
|
try:
|
|
uid = int(fields["Uid"].split()[0])
|
|
gid = int(fields["Gid"].split()[0])
|
|
groups = {int(item) for item in fields["Groups"].split()}
|
|
except (KeyError, IndexError, ValueError) as exc:
|
|
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403) from exc
|
|
return uid, gid, groups
|
|
|
|
|
|
def _peer_process_groups(pid: int) -> set[int]:
|
|
return _peer_process_status(pid)[2]
|
|
|
|
|
|
def peer_credentials_from_connection(connection: object) -> tuple[int, int, int, set[int]]:
|
|
if not hasattr(socket, "SO_PEERCRED"):
|
|
raise AuthorizationError("SO_PEERCRED_UNAVAILABLE", 500)
|
|
try:
|
|
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, SO_PEERCRED_SIZE)
|
|
except OSError as exc:
|
|
raise AuthorizationError("SO_PEERCRED_UNAVAILABLE", 403) from exc
|
|
if len(raw) != SO_PEERCRED_SIZE:
|
|
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403)
|
|
try:
|
|
pid, uid, gid = struct.unpack("3i", raw)
|
|
except struct.error as exc:
|
|
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403) from exc
|
|
if pid <= 1 or uid < 0 or gid < 0:
|
|
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403)
|
|
process_uid, process_gid, groups = _peer_process_status(pid)
|
|
if process_uid != uid or process_gid != gid:
|
|
raise AuthorizationError("SOCKPEER_CREDENTIALS_INVALID", 403)
|
|
return pid, uid, gid, groups | {gid}
|
|
|
|
|
|
def peer_user_from_connection(connection: object) -> str:
|
|
_pid, uid, _gid, _groups = peer_credentials_from_connection(connection)
|
|
try:
|
|
return pwd.getpwuid(uid).pw_name
|
|
except (KeyError, OSError) as exc:
|
|
raise AuthorizationError("SO_PEERCRED_UID_UNKNOWN", 403) from exc
|
|
|
|
|
|
CANONICAL_BROKER_GROUP = os.environ.get(
|
|
"AIOS_OWNER_AUTH_BROKER_ALLOWED_GROUP",
|
|
"aios-autonomous-agent-worker",
|
|
).strip()
|
|
|
|
|
|
def authorize_broker_peer(connection: object) -> bool:
|
|
_pid, _uid, _gid, groups = peer_credentials_from_connection(connection)
|
|
if not CANONICAL_BROKER_GROUP:
|
|
raise AuthorizationError("SOCKGROUP_CONFIGURATION_INVALID", 503)
|
|
try:
|
|
canonical_gid = grp.getgrnam(CANONICAL_BROKER_GROUP).gr_gid
|
|
except KeyError as exc:
|
|
raise AuthorizationError("SOCKGROUP_CONFIGURATION_INVALID", 503) from exc
|
|
if canonical_gid not in groups:
|
|
raise AuthorizationError("SOCKGROUP_NOT_AUTHORIZED", 403)
|
|
return True
|
|
|
|
|
|
class AuthorizationError(RuntimeError):
|
|
def __init__(self, code: str, status: int = 400) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.status = status
|
|
|
|
|
|
def canonical(value: object) -> bytes:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
|
|
|
|
def sha256(value: bytes) -> str:
|
|
return hashlib.sha256(value).hexdigest()
|
|
|
|
|
|
def parse_time(value: object, *, field: str) -> datetime:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise AuthorizationError("POLICY_" + field.upper() + "_INVALID")
|
|
text = value.strip()
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
result = datetime.fromisoformat(text)
|
|
except ValueError as exc:
|
|
raise AuthorizationError("POLICY_" + field.upper() + "_INVALID") from exc
|
|
if result.tzinfo is None:
|
|
raise AuthorizationError("POLICY_" + field.upper() + "_INVALID")
|
|
return result.astimezone(timezone.utc)
|
|
|
|
|
|
def validate_root_file(path: Path, *, max_size: int = 256 * 1024) -> Path:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise AuthorizationError("POLICY_FILE_INVALID", 503)
|
|
info = path.stat(follow_symlinks=False)
|
|
if info.st_uid != 0 or info.st_gid != 0:
|
|
raise AuthorizationError("POLICY_FILE_OWNERSHIP_INVALID", 503)
|
|
if stat.S_IMODE(info.st_mode) != 0o600:
|
|
raise AuthorizationError("POLICY_FILE_MODE_INVALID", 503)
|
|
if info.st_size < 16 or info.st_size > max_size:
|
|
raise AuthorizationError("POLICY_FILE_SIZE_INVALID", 503)
|
|
return path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Constraints:
|
|
repository: str
|
|
account: str
|
|
allowed_target_prefix: str
|
|
release_version_prefix: str
|
|
max_task_risk: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DurablePolicy:
|
|
grant_id: str
|
|
owner_identity: str
|
|
account: str
|
|
capabilities: frozenset[str]
|
|
issued_at: datetime
|
|
not_before: datetime
|
|
persistent_until_revoked: bool
|
|
constraints: Constraints
|
|
policy_sha256: str
|
|
expires_at: datetime | None
|
|
|
|
@classmethod
|
|
def load(
|
|
cls,
|
|
policy_path: Path,
|
|
revocation_path: Path | None = None,
|
|
*,
|
|
now: datetime | None = None,
|
|
) -> "DurablePolicy":
|
|
validate_root_file(policy_path)
|
|
try:
|
|
value = json.loads(policy_path.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
raise AuthorizationError("POLICY_JSON_INVALID", 503) from exc
|
|
if not isinstance(value, Mapping):
|
|
raise AuthorizationError("POLICY_JSON_INVALID", 503)
|
|
allowed_fields = {
|
|
"schema",
|
|
"grant_id",
|
|
"owner_identity",
|
|
"account",
|
|
"capabilities",
|
|
"issued_at",
|
|
"not_before",
|
|
"expires_at",
|
|
"persistent_until_revoked",
|
|
"constraints",
|
|
}
|
|
if (
|
|
not set(value).issubset(allowed_fields)
|
|
or value.get("schema") != POLICY_SCHEMA
|
|
):
|
|
raise AuthorizationError("POLICY_FIELDS_INVALID", 503)
|
|
|
|
grant_id = str(value.get("grant_id") or "")
|
|
owner_identity = str(value.get("owner_identity") or "")
|
|
account = str(value.get("account") or "")
|
|
if GRANT_RE.fullmatch(grant_id) is None:
|
|
raise AuthorizationError("POLICY_GRANT_ID_INVALID", 503)
|
|
if ACCOUNT_RE.fullmatch(owner_identity) is None:
|
|
raise AuthorizationError("POLICY_OWNER_IDENTITY_INVALID", 503)
|
|
if ACCOUNT_RE.fullmatch(account) is None:
|
|
raise AuthorizationError("POLICY_ACCOUNT_INVALID", 503)
|
|
|
|
raw_caps = value.get("capabilities")
|
|
if not isinstance(raw_caps, list) or not raw_caps:
|
|
raise AuthorizationError("POLICY_CAPABILITIES_INVALID", 503)
|
|
capabilities = frozenset(str(item) for item in raw_caps)
|
|
if len(capabilities) != len(raw_caps):
|
|
raise AuthorizationError("POLICY_CAPABILITIES_DUPLICATE", 503)
|
|
if not capabilities.issubset(ALLOWED_PERSISTENT_CAPABILITIES):
|
|
raise AuthorizationError("POLICY_CAPABILITY_NOT_PERSISTABLE", 403)
|
|
if capabilities & FORBIDDEN_PERSISTENT_CAPABILITIES:
|
|
raise AuthorizationError("POLICY_FORBIDDEN_CAPABILITY", 403)
|
|
|
|
issued_at = parse_time(value.get("issued_at"), field="issued_at")
|
|
not_before = parse_time(value.get("not_before"), field="not_before")
|
|
current = now or datetime.now(timezone.utc)
|
|
if issued_at > not_before or current < not_before:
|
|
raise AuthorizationError("POLICY_NOT_ACTIVE", 403)
|
|
expires_at = None
|
|
if value.get("expires_at") is not None:
|
|
expires_at = parse_time(value.get("expires_at"), field="expires_at")
|
|
if expires_at <= not_before:
|
|
raise AuthorizationError("POLICY_EXPIRY_INVALID", 503)
|
|
if current >= expires_at:
|
|
raise AuthorizationError("POLICY_EXPIRED", 403)
|
|
if value.get("persistent_until_revoked") is not True:
|
|
raise AuthorizationError("POLICY_PERSISTENCE_INVALID", 503)
|
|
|
|
raw_constraints = value.get("constraints")
|
|
if not isinstance(raw_constraints, Mapping) or set(raw_constraints) != {
|
|
"repository",
|
|
"allowed_target_prefix",
|
|
"release_version_prefix",
|
|
"max_task_risk",
|
|
}:
|
|
raise AuthorizationError("POLICY_CONSTRAINTS_INVALID", 503)
|
|
if raw_constraints.get("repository") != "aios/aios":
|
|
raise AuthorizationError("POLICY_REPOSITORY_INVALID", 503)
|
|
prefix = str(raw_constraints.get("allowed_target_prefix") or "")
|
|
if prefix != "agent/":
|
|
raise AuthorizationError("POLICY_TARGET_PREFIX_INVALID", 503)
|
|
version_prefix = str(raw_constraints.get("release_version_prefix") or "")
|
|
if version_prefix != "V2R2C":
|
|
raise AuthorizationError("POLICY_RELEASE_VERSION_PREFIX_INVALID", 503)
|
|
max_risk = str(raw_constraints.get("max_task_risk") or "")
|
|
if max_risk not in {"low", "engineering"}:
|
|
raise AuthorizationError("POLICY_MAX_RISK_INVALID", 503)
|
|
|
|
if revocation_path is not None and revocation_path.exists():
|
|
validate_root_file(revocation_path, max_size=64 * 1024)
|
|
try:
|
|
revoked = json.loads(revocation_path.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
raise AuthorizationError("REVOCATION_JSON_INVALID", 503) from exc
|
|
if not isinstance(revoked, Mapping):
|
|
raise AuthorizationError("REVOCATION_JSON_INVALID", 503)
|
|
grants = revoked.get("revoked_grant_ids")
|
|
if not isinstance(grants, list) or not all(isinstance(x, str) for x in grants):
|
|
raise AuthorizationError("REVOCATION_FIELDS_INVALID", 503)
|
|
if grant_id in grants:
|
|
raise AuthorizationError("OWNER_GRANT_REVOKED", 403)
|
|
|
|
return cls(
|
|
grant_id=grant_id,
|
|
owner_identity=owner_identity,
|
|
account=account,
|
|
capabilities=capabilities,
|
|
issued_at=issued_at,
|
|
not_before=not_before,
|
|
persistent_until_revoked=True,
|
|
constraints=Constraints(
|
|
repository="aios/aios",
|
|
account=account,
|
|
allowed_target_prefix=prefix,
|
|
release_version_prefix=version_prefix,
|
|
max_task_risk=max_risk,
|
|
),
|
|
policy_sha256=sha256(policy_path.read_bytes()),
|
|
expires_at=expires_at,
|
|
)
|
|
|
|
def validate_caller_scope(
|
|
self,
|
|
caller: str,
|
|
scope: str,
|
|
request: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
if caller != self.account:
|
|
raise AuthorizationError("CALLER_MISMATCH", 403)
|
|
if scope not in CALLER_PROJECTION_SCOPES:
|
|
raise AuthorizationError("SCOPE_MISMATCH", 403)
|
|
if scope not in self.capabilities:
|
|
raise AuthorizationError("CAPABILITY_NOT_AUTHORIZED", 403)
|
|
if not isinstance(request, Mapping):
|
|
raise AuthorizationError("CALLER_PROJECTION_REQUEST_INVALID")
|
|
if scope == "release:candidate":
|
|
if set(request) != {"source_commit", "version"}:
|
|
raise AuthorizationError("CALLER_PROJECTION_FIELDS_INVALID")
|
|
source_commit = str(request.get("source_commit") or "")
|
|
version = str(request.get("version") or "")
|
|
if OID_RE.fullmatch(source_commit) is None:
|
|
raise AuthorizationError("RELEASE_SOURCE_COMMIT_INVALID")
|
|
if VERSION_RE.fullmatch(version) is None or not version.startswith(
|
|
self.constraints.release_version_prefix
|
|
):
|
|
raise AuthorizationError("RELEASE_VERSION_NOT_AUTHORIZED", 403)
|
|
elif request:
|
|
raise AuthorizationError("CALLER_PROJECTION_FIELDS_INVALID")
|
|
return {
|
|
"schema": CALLER_PROJECTION_SCHEMA,
|
|
"caller": caller,
|
|
"scope": scope,
|
|
"request_sha256": sha256(canonical(request)),
|
|
}
|
|
|
|
def authorize(self, capability: str, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
if capability not in self.capabilities:
|
|
raise AuthorizationError("CAPABILITY_NOT_AUTHORIZED", 403)
|
|
if capability in FORBIDDEN_PERSISTENT_CAPABILITIES:
|
|
raise AuthorizationError("CAPABILITY_REQUIRES_SEPARATE_OWNER_APPROVAL", 403)
|
|
|
|
if capability == "governed-task:project":
|
|
required = {
|
|
"task_id",
|
|
"repository",
|
|
"base_oid",
|
|
"expected_branch_oid",
|
|
"target_branch",
|
|
"generation",
|
|
"risk",
|
|
"description",
|
|
"idempotency_key",
|
|
"priority",
|
|
"max_attempts",
|
|
"require_online_task_projection",
|
|
"online_task",
|
|
"execution",
|
|
}
|
|
optional = {"branch_mode"}
|
|
if set(request) - (required | optional) or not required.issubset(request):
|
|
raise AuthorizationError("GOVERNED_TASK_FIELDS_INVALID")
|
|
if request["repository"] != self.constraints.repository:
|
|
raise AuthorizationError("REPOSITORY_NOT_AUTHORIZED", 403)
|
|
if not TASK_RE.fullmatch(str(request["task_id"])):
|
|
raise AuthorizationError("TASK_ID_INVALID")
|
|
base = str(request["base_oid"])
|
|
expected = str(request["expected_branch_oid"])
|
|
if not OID_RE.fullmatch(base) or expected != base:
|
|
raise AuthorizationError("BASE_BINDING_INVALID")
|
|
branch = str(request["target_branch"])
|
|
if BRANCH_RE.fullmatch(branch) is None or not branch.startswith(
|
|
self.constraints.allowed_target_prefix
|
|
):
|
|
raise AuthorizationError("TARGET_BRANCH_NOT_AUTHORIZED", 403)
|
|
if int(request["generation"]) != 1:
|
|
raise AuthorizationError("GENERATION_NOT_AUTHORIZED", 403)
|
|
risk = str(request["risk"])
|
|
allowed_risks = {"low"} if self.constraints.max_task_risk == "low" else {
|
|
"low",
|
|
"engineering",
|
|
}
|
|
if risk not in allowed_risks:
|
|
raise AuthorizationError("TASK_RISK_NOT_AUTHORIZED", 403)
|
|
if request.get("branch_mode", "CREATE_NEW") != "CREATE_NEW":
|
|
raise AuthorizationError("BRANCH_MODE_NOT_AUTHORIZED", 403)
|
|
priority = request["priority"]
|
|
if (
|
|
not isinstance(priority, int)
|
|
or isinstance(priority, bool)
|
|
or not -100 <= priority <= 100
|
|
):
|
|
raise AuthorizationError("TASK_PRIORITY_INVALID")
|
|
max_attempts = request["max_attempts"]
|
|
if (
|
|
not isinstance(max_attempts, int)
|
|
or isinstance(max_attempts, bool)
|
|
or not 1 <= max_attempts <= 3
|
|
):
|
|
raise AuthorizationError("TASK_MAX_ATTEMPTS_INVALID")
|
|
if request["require_online_task_projection"] is not True:
|
|
raise AuthorizationError("ONLINE_TASK_PROJECTION_REQUIRED", 403)
|
|
|
|
online = request["online_task"]
|
|
execution = request["execution"]
|
|
if not isinstance(online, Mapping) or not isinstance(execution, Mapping):
|
|
raise AuthorizationError("ONLINE_TASK_ENVELOPE_INVALID")
|
|
online_allowed = {
|
|
"goal",
|
|
"instructions",
|
|
"success_criteria",
|
|
"constraints",
|
|
"required_capabilities",
|
|
"priority",
|
|
"max_attempts",
|
|
}
|
|
if set(online) != online_allowed:
|
|
raise AuthorizationError("ONLINE_TASK_FIELDS_INVALID")
|
|
if (
|
|
not isinstance(online["goal"], str)
|
|
or not online["goal"].strip()
|
|
or len(online["goal"]) > 12_000
|
|
or not isinstance(online["instructions"], str)
|
|
or len(online["instructions"]) > 24_000
|
|
or not isinstance(online["success_criteria"], list)
|
|
or len(online["success_criteria"]) > 32
|
|
or not isinstance(online["constraints"], Mapping)
|
|
):
|
|
raise AuthorizationError("ONLINE_TASK_CONTENT_INVALID")
|
|
projected_caps = online["required_capabilities"]
|
|
if (
|
|
not isinstance(projected_caps, list)
|
|
or not projected_caps
|
|
or len(projected_caps) != len(set(projected_caps))
|
|
or not frozenset(projected_caps).issubset(
|
|
SAFE_PROJECTED_TASK_CAPABILITIES
|
|
)
|
|
):
|
|
raise AuthorizationError("ONLINE_TASK_CAPABILITIES_NOT_AUTHORIZED", 403)
|
|
if online["priority"] != priority or online["max_attempts"] != max_attempts:
|
|
raise AuthorizationError("ONLINE_TASK_LIMITS_MISMATCH")
|
|
|
|
execution_allowed = {
|
|
"action",
|
|
"adapter",
|
|
"capability_scope",
|
|
"source_signals",
|
|
}
|
|
if set(execution) != execution_allowed:
|
|
raise AuthorizationError("EXECUTION_FIELDS_INVALID")
|
|
if execution.get("action") != "source_capture" or execution.get(
|
|
"adapter"
|
|
) != "source_capture":
|
|
raise AuthorizationError("INITIAL_EXECUTION_ACTION_NOT_AUTHORIZED", 403)
|
|
scope = execution.get("capability_scope")
|
|
if (
|
|
not isinstance(scope, list)
|
|
or sorted(scope) != sorted(projected_caps)
|
|
):
|
|
raise AuthorizationError("EXECUTION_CAPABILITY_SCOPE_MISMATCH")
|
|
signals = execution.get("source_signals")
|
|
if (
|
|
not isinstance(signals, list)
|
|
or len(signals) > 32
|
|
or not all(isinstance(item, str) and len(item) <= 1_000 for item in signals)
|
|
):
|
|
raise AuthorizationError("SOURCE_SIGNALS_INVALID")
|
|
|
|
elif capability == "release:candidate":
|
|
required = {"candidate_id", "source_commit", "version"}
|
|
if set(request) != required:
|
|
raise AuthorizationError("RELEASE_CANDIDATE_FIELDS_INVALID")
|
|
if not GRANT_RE.fullmatch(str(request["candidate_id"])):
|
|
raise AuthorizationError("RELEASE_CANDIDATE_ID_INVALID")
|
|
if OID_RE.fullmatch(str(request["source_commit"])) is None:
|
|
raise AuthorizationError("RELEASE_SOURCE_COMMIT_INVALID")
|
|
version = str(request["version"])
|
|
if VERSION_RE.fullmatch(version) is None or not version.startswith(
|
|
self.constraints.release_version_prefix
|
|
):
|
|
raise AuthorizationError("RELEASE_VERSION_NOT_AUTHORIZED", 403)
|
|
else:
|
|
raise AuthorizationError("CAPABILITY_NOT_SUPPORTED", 403)
|
|
|
|
return {
|
|
"schema": DECISION_SCHEMA,
|
|
"decision": "ALLOW",
|
|
"grant_id": self.grant_id,
|
|
"owner_identity": self.owner_identity,
|
|
"account": self.account,
|
|
"capability": capability,
|
|
"policy_sha256": self.policy_sha256,
|
|
"request_sha256": sha256(canonical(request)),
|
|
"persistent_until_revoked": True,
|
|
}
|
|
|
|
|
|
class ReceiptStore:
|
|
def __init__(self, path: Path) -> None:
|
|
self.path = path
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
with sqlite3.connect(self.path) as con:
|
|
con.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS receipts(
|
|
idempotency_key TEXT PRIMARY KEY,
|
|
capability TEXT NOT NULL,
|
|
request_sha256 TEXT NOT NULL,
|
|
response_sha256 TEXT NOT NULL,
|
|
response_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
con.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS projection_outbox(
|
|
projection_id TEXT PRIMARY KEY,
|
|
idempotency_key TEXT NOT NULL UNIQUE,
|
|
grant_id TEXT NOT NULL,
|
|
account TEXT NOT NULL,
|
|
capability TEXT NOT NULL,
|
|
policy_sha256 TEXT NOT NULL,
|
|
request_sha256 TEXT NOT NULL,
|
|
payload_sha256 TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
con.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_projection_outbox_state
|
|
ON projection_outbox(state, capability, created_at)
|
|
"""
|
|
)
|
|
con.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS caller_projections(
|
|
projection_id TEXT PRIMARY KEY,
|
|
caller TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
grant_id TEXT NOT NULL,
|
|
policy_sha256 TEXT NOT NULL,
|
|
projection_state TEXT NOT NULL,
|
|
credential_state TEXT NOT NULL,
|
|
rehydration_state TEXT NOT NULL,
|
|
issued_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(caller, scope)
|
|
)
|
|
"""
|
|
)
|
|
con.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS gate_amendments(
|
|
idempotency_key TEXT PRIMARY KEY,
|
|
amendment_sha256 TEXT NOT NULL,
|
|
amendment_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
con.commit()
|
|
self._lock = threading.RLock()
|
|
|
|
@staticmethod
|
|
def caller_projection_id(caller: str, scope: str) -> str:
|
|
digest = sha256(canonical({"caller": caller, "scope": scope}))
|
|
return "caller-projection-" + digest[:32]
|
|
|
|
def upsert_caller_projection(
|
|
self,
|
|
*,
|
|
caller: str,
|
|
scope: str,
|
|
grant_id: str,
|
|
policy_sha256: str,
|
|
ttl_seconds: int,
|
|
effective_scopes: Any,
|
|
) -> dict[str, Any]:
|
|
if not 30 <= ttl_seconds <= 900:
|
|
raise AuthorizationError("CALLER_PROJECTION_TTL_INVALID", 503)
|
|
normalized_scopes = sorted({str(item) for item in effective_scopes})
|
|
if (
|
|
not normalized_scopes
|
|
or scope not in normalized_scopes
|
|
or not set(normalized_scopes).issubset(CALLER_PROJECTION_SCOPES)
|
|
):
|
|
raise AuthorizationError("CALLER_PROJECTION_SCOPES_INVALID", 503)
|
|
requested_projection_id = self.caller_projection_id(caller, scope)
|
|
issued = datetime.now(timezone.utc)
|
|
issued_at = issued.isoformat()
|
|
expires_at = (issued + timedelta(seconds=ttl_seconds)).isoformat()
|
|
requested_recovery = False
|
|
with self._lock, sqlite3.connect(self.path) as con:
|
|
con.row_factory = sqlite3.Row
|
|
con.execute("BEGIN IMMEDIATE")
|
|
for projected_scope in normalized_scopes:
|
|
projection_id = self.caller_projection_id(caller, projected_scope)
|
|
row = con.execute(
|
|
"SELECT * FROM caller_projections WHERE caller=? AND scope=?",
|
|
(caller, projected_scope),
|
|
).fetchone()
|
|
if row is not None and row["projection_state"] not in {"ACKED", "PENDING"}:
|
|
con.rollback()
|
|
raise AuthorizationError("PROJECTION_STATE_INVALID", 503)
|
|
recovery = row is not None
|
|
if projected_scope == scope:
|
|
requested_recovery = recovery
|
|
if row is None:
|
|
con.execute(
|
|
"""
|
|
INSERT INTO caller_projections(
|
|
projection_id,caller,scope,grant_id,policy_sha256,
|
|
projection_state,credential_state,rehydration_state,
|
|
issued_at,expires_at,updated_at
|
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
|
""",
|
|
(
|
|
projection_id,
|
|
caller,
|
|
projected_scope,
|
|
grant_id,
|
|
policy_sha256,
|
|
"PENDING",
|
|
"GENERATING",
|
|
"RECOVERING" if recovery else "GENERATING",
|
|
issued_at,
|
|
expires_at,
|
|
issued_at,
|
|
),
|
|
)
|
|
else:
|
|
con.execute(
|
|
"""
|
|
UPDATE caller_projections
|
|
SET projection_id=?,grant_id=?,policy_sha256=?,
|
|
projection_state='PENDING',
|
|
credential_state='GENERATING',
|
|
rehydration_state=?,
|
|
issued_at=?,expires_at=?,updated_at=?
|
|
WHERE caller=? AND scope=?
|
|
""",
|
|
(
|
|
projection_id,
|
|
grant_id,
|
|
policy_sha256,
|
|
"RECOVERING",
|
|
issued_at,
|
|
expires_at,
|
|
issued_at,
|
|
caller,
|
|
projected_scope,
|
|
),
|
|
)
|
|
con.execute(
|
|
"""
|
|
UPDATE caller_projections
|
|
SET projection_state='ACKED',
|
|
credential_state='REHYDRATED',
|
|
rehydration_state=?,
|
|
updated_at=?
|
|
WHERE caller=? AND scope=?
|
|
""",
|
|
(
|
|
"RECOVERED" if recovery else "GENERATED",
|
|
issued_at,
|
|
caller,
|
|
projected_scope,
|
|
),
|
|
)
|
|
con.commit()
|
|
return {
|
|
"schema": CALLER_PROJECTION_SCHEMA,
|
|
"projection_id": requested_projection_id,
|
|
"caller": caller,
|
|
"scope": scope,
|
|
"grant_id": grant_id,
|
|
"policy_sha256": policy_sha256,
|
|
"projection_state": "ACKED",
|
|
"credential_state": "REHYDRATED",
|
|
"rehydration_state": "RECOVERED" if requested_recovery else "GENERATED",
|
|
"effective_scopes": normalized_scopes,
|
|
"issued_at": issued_at,
|
|
"expires_at": expires_at,
|
|
"secret_exposed": False,
|
|
}
|
|
|
|
def get(self, key: str) -> dict[str, Any] | None:
|
|
with sqlite3.connect(self.path) as con:
|
|
row = con.execute(
|
|
"SELECT request_sha256,response_json FROM receipts WHERE idempotency_key=?",
|
|
(key,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return {"request_sha256": row[0], "response": json.loads(row[1])}
|
|
|
|
def put(
|
|
self,
|
|
key: str,
|
|
capability: str,
|
|
request_sha256: str,
|
|
response: Mapping[str, Any],
|
|
) -> None:
|
|
encoded = json.dumps(response, sort_keys=True, separators=(",", ":"))
|
|
with self._lock, sqlite3.connect(self.path) as con:
|
|
con.execute(
|
|
"""
|
|
INSERT INTO receipts(
|
|
idempotency_key,capability,request_sha256,response_sha256,
|
|
response_json,created_at
|
|
) VALUES(?,?,?,?,?,?)
|
|
""",
|
|
(
|
|
key,
|
|
capability,
|
|
request_sha256,
|
|
sha256(encoded.encode()),
|
|
encoded,
|
|
datetime.now(timezone.utc).isoformat(),
|
|
),
|
|
)
|
|
con.commit()
|
|
|
|
def update_response(
|
|
self,
|
|
key: str,
|
|
request_sha256: str,
|
|
response: Mapping[str, Any],
|
|
) -> None:
|
|
encoded = json.dumps(response, sort_keys=True, separators=(",", ":"))
|
|
with self._lock, sqlite3.connect(self.path) as con:
|
|
con.execute("BEGIN IMMEDIATE")
|
|
row = con.execute(
|
|
"""
|
|
SELECT request_sha256 FROM receipts
|
|
WHERE idempotency_key=?
|
|
""",
|
|
(key,),
|
|
).fetchone()
|
|
if row is None:
|
|
con.rollback()
|
|
raise AuthorizationError("RECEIPT_NOT_FOUND", 409)
|
|
if row[0] != request_sha256:
|
|
con.rollback()
|
|
raise AuthorizationError("IDEMPOTENCY_CONFLICT", 409)
|
|
con.execute(
|
|
"""
|
|
UPDATE receipts
|
|
SET response_sha256=?,response_json=?
|
|
WHERE idempotency_key=?
|
|
""",
|
|
(sha256(encoded.encode()), encoded, key),
|
|
)
|
|
con.commit()
|
|
|
|
def put_gate_amendment(
|
|
self,
|
|
idempotency_key: str,
|
|
amendment: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
encoded = json.dumps(amendment, sort_keys=True, separators=(",", ":"))
|
|
lowered = encoded.lower()
|
|
if any(marker in lowered for marker in (
|
|
"bearer ", "access_token", "refresh_token", "private_key", "credential_file"
|
|
)):
|
|
raise AuthorizationError("GATE_AMENDMENT_SECRET_MATERIAL_DENY", 403)
|
|
digest = sha256(encoded.encode())
|
|
with self._lock, sqlite3.connect(self.path) as con:
|
|
con.execute("BEGIN IMMEDIATE")
|
|
row = con.execute(
|
|
"SELECT amendment_sha256,amendment_json FROM gate_amendments WHERE idempotency_key=?",
|
|
(idempotency_key,),
|
|
).fetchone()
|
|
if row is not None:
|
|
if row[0] != digest or row[1] != encoded:
|
|
con.rollback()
|
|
raise AuthorizationError("GATE_AMENDMENT_IDEMPOTENCY_CONFLICT", 409)
|
|
con.commit()
|
|
return {"amendment_sha256": digest, "idempotent": True}
|
|
con.execute(
|
|
"INSERT INTO gate_amendments(idempotency_key,amendment_sha256,amendment_json,created_at) VALUES(?,?,?,?)",
|
|
(idempotency_key, digest, encoded, datetime.now(timezone.utc).isoformat()),
|
|
)
|
|
con.commit()
|
|
return {"amendment_sha256": digest, "idempotent": False}
|
|
|
|
@staticmethod
|
|
def projection_id(
|
|
grant_id: str,
|
|
account: str,
|
|
capability: str,
|
|
request_sha256: str,
|
|
) -> str:
|
|
digest = sha256(
|
|
canonical(
|
|
{
|
|
"grant_id": grant_id,
|
|
"account": account,
|
|
"capability": capability,
|
|
"request_sha256": request_sha256,
|
|
}
|
|
)
|
|
)
|
|
return "projection-" + digest[:32]
|
|
|
|
def enqueue_projection(
|
|
self,
|
|
*,
|
|
idempotency_key: str,
|
|
grant_id: str,
|
|
account: str,
|
|
capability: str,
|
|
policy_sha256: str,
|
|
request_sha256: str,
|
|
request: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
projection_id = self.projection_id(
|
|
grant_id,
|
|
account,
|
|
capability,
|
|
request_sha256,
|
|
)
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
payload = {
|
|
"schema": PROJECTION_SCHEMA,
|
|
"projection_id": projection_id,
|
|
"state": "PENDING_EXTERNAL_CONNECTOR",
|
|
"grant_id": grant_id,
|
|
"account": account,
|
|
"capability": capability,
|
|
"policy_sha256": policy_sha256,
|
|
"request_sha256": request_sha256,
|
|
"request": dict(request),
|
|
"created_at": now,
|
|
"separate_owner_release_approval_still_required": True,
|
|
}
|
|
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
payload_sha = sha256(encoded.encode())
|
|
|
|
with self._lock, sqlite3.connect(self.path) as con:
|
|
con.row_factory = sqlite3.Row
|
|
con.execute("BEGIN IMMEDIATE")
|
|
existing = con.execute(
|
|
"""
|
|
SELECT * FROM projection_outbox
|
|
WHERE idempotency_key=?
|
|
""",
|
|
(idempotency_key,),
|
|
).fetchone()
|
|
if existing is not None:
|
|
if (
|
|
existing["projection_id"] != projection_id
|
|
or existing["request_sha256"] != request_sha256
|
|
or existing["grant_id"] != grant_id
|
|
or existing["account"] != account
|
|
or existing["capability"] != capability
|
|
or existing["policy_sha256"] != policy_sha256
|
|
):
|
|
con.rollback()
|
|
raise AuthorizationError(
|
|
"PROJECTION_IDEMPOTENCY_CONFLICT",
|
|
409,
|
|
)
|
|
value = json.loads(existing["payload_json"])
|
|
con.commit()
|
|
return value
|
|
|
|
con.execute(
|
|
"""
|
|
INSERT INTO projection_outbox(
|
|
projection_id,idempotency_key,grant_id,account,capability,
|
|
policy_sha256,request_sha256,payload_sha256,payload_json,
|
|
state,created_at,updated_at
|
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
|
""",
|
|
(
|
|
projection_id,
|
|
idempotency_key,
|
|
grant_id,
|
|
account,
|
|
capability,
|
|
policy_sha256,
|
|
request_sha256,
|
|
payload_sha,
|
|
encoded,
|
|
"PENDING_EXTERNAL_CONNECTOR",
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
con.commit()
|
|
return payload
|
|
|
|
def get_projection(self, projection_id: str) -> dict[str, Any] | None:
|
|
if not re.fullmatch(r"projection-[0-9a-f]{32}", projection_id):
|
|
raise AuthorizationError("PROJECTION_ID_INVALID")
|
|
with sqlite3.connect(self.path) as con:
|
|
row = con.execute(
|
|
"""
|
|
SELECT payload_json FROM projection_outbox
|
|
WHERE projection_id=?
|
|
""",
|
|
(projection_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return json.loads(row[0])
|
|
|
|
def list_projections(
|
|
self,
|
|
*,
|
|
grant_id: str,
|
|
account: str,
|
|
capability: str = "release:candidate",
|
|
limit: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
if not 1 <= limit <= 500:
|
|
raise AuthorizationError("PROJECTION_LIMIT_INVALID")
|
|
with sqlite3.connect(self.path) as con:
|
|
rows = con.execute(
|
|
"""
|
|
SELECT payload_json
|
|
FROM projection_outbox
|
|
WHERE grant_id=? AND account=? AND capability=?
|
|
ORDER BY created_at,idempotency_key
|
|
LIMIT ?
|
|
""",
|
|
(grant_id, account, capability, limit),
|
|
).fetchall()
|
|
return [json.loads(row[0]) for row in rows]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Upstream:
|
|
bridge_url: str
|
|
bridge_token: str
|
|
|
|
def post_bridge(
|
|
self,
|
|
path: str,
|
|
body: Mapping[str, Any],
|
|
) -> tuple[int, dict[str, Any]]:
|
|
req = urllib.request.Request(
|
|
self.bridge_url.rstrip("/") + path,
|
|
method="POST",
|
|
data=canonical(body),
|
|
headers={
|
|
"Authorization": "Bearer " + self.bridge_token,
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as response:
|
|
raw, status = response.read(), response.status
|
|
except urllib.error.HTTPError as exc:
|
|
raw, status = exc.read(), exc.code
|
|
except Exception as exc:
|
|
raise AuthorizationError("UPSTREAM_TRANSPORT_FAILED", 503) from exc
|
|
try:
|
|
value = json.loads(raw.decode())
|
|
except Exception as exc:
|
|
raise AuthorizationError("UPSTREAM_RESPONSE_INVALID", 502) from exc
|
|
if not isinstance(value, Mapping):
|
|
raise AuthorizationError("UPSTREAM_RESPONSE_INVALID", 502)
|
|
return status, dict(value)
|
|
|
|
def project_governed_task(
|
|
self,
|
|
body: Mapping[str, Any],
|
|
) -> tuple[int, dict[str, Any]]:
|
|
return self.post_bridge("/v2/governed-task", body)
|
|
|
|
|
|
class Broker:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
policy_path: Path,
|
|
revocation_path: Path,
|
|
store: ReceiptStore,
|
|
upstream: Upstream,
|
|
expected_account: str,
|
|
expected_peer_user: str,
|
|
allowed_peer_users: frozenset[str] | None = None,
|
|
) -> None:
|
|
self.policy_path = policy_path
|
|
self.revocation_path = revocation_path
|
|
self.store = store
|
|
self.upstream = upstream
|
|
self.expected_account = expected_account
|
|
self.expected_peer_user = expected_peer_user
|
|
self.allowed_peer_users = allowed_peer_users or frozenset({expected_peer_user})
|
|
|
|
@staticmethod
|
|
def validate_caller_projection(
|
|
value: Mapping[str, Any],
|
|
*,
|
|
expected_scopes: Any = None,
|
|
) -> dict[str, Any]:
|
|
expected = sorted(
|
|
set(expected_scopes) if expected_scopes is not None else CALLER_PROJECTION_SCOPES
|
|
)
|
|
if value.get("caller") != "chatgpt-governed-implementer-singlehost":
|
|
raise AuthorizationError("CALLER_MISMATCH", 503)
|
|
if value.get("scope") not in expected:
|
|
raise AuthorizationError("SCOPE_MISMATCH", 503)
|
|
if value.get("projection_state") != "ACKED":
|
|
raise AuthorizationError("PROJECTION_STATE_INVALID", 503)
|
|
if value.get("credential_state") != "REHYDRATED":
|
|
raise AuthorizationError("CREDENTIAL_REHYDRATION_INVALID", 503)
|
|
if value.get("rehydration_state") not in {"GENERATED", "RECOVERED"}:
|
|
raise AuthorizationError("REHYDRATION_STATE_INVALID", 503)
|
|
if value.get("effective_scopes") != expected:
|
|
raise AuthorizationError("PROJECTION_SCOPE_INVALID", 503)
|
|
if value.get("secret_exposed") is not False:
|
|
raise AuthorizationError("PROJECTION_SECRET_EXPOSURE", 503)
|
|
return dict(value)
|
|
|
|
def project_caller(
|
|
self,
|
|
caller: str,
|
|
scope: str,
|
|
request: Mapping[str, Any],
|
|
*,
|
|
idempotency_key: str,
|
|
) -> dict[str, Any]:
|
|
if not GRANT_RE.fullmatch(idempotency_key):
|
|
raise AuthorizationError("IDEMPOTENCY_KEY_INVALID")
|
|
if caller != self.expected_account:
|
|
raise AuthorizationError("CALLER_MISMATCH", 403)
|
|
if scope not in CALLER_PROJECTION_SCOPES:
|
|
raise AuthorizationError("SCOPE_MISMATCH", 403)
|
|
policy = self.policy()
|
|
policy.validate_caller_scope(caller, scope, request)
|
|
effective_scopes = sorted(policy.capabilities & CALLER_PROJECTION_SCOPES)
|
|
try:
|
|
ttl_seconds = int(
|
|
os.environ.get(
|
|
"AIOS_OWNER_AUTH_CALLER_PROJECTION_TTL_SECONDS",
|
|
str(CALLER_PROJECTION_TTL_DEFAULT),
|
|
)
|
|
)
|
|
except ValueError as exc:
|
|
raise AuthorizationError("CALLER_PROJECTION_TTL_INVALID", 503) from exc
|
|
credential = secrets.token_urlsafe(32)
|
|
value = self.store.upsert_caller_projection(
|
|
caller=caller,
|
|
scope=scope,
|
|
grant_id=policy.grant_id,
|
|
policy_sha256=policy.policy_sha256,
|
|
ttl_seconds=ttl_seconds,
|
|
effective_scopes=effective_scopes,
|
|
)
|
|
value = self.validate_caller_projection(
|
|
value,
|
|
expected_scopes=effective_scopes,
|
|
)
|
|
value["_credential"] = credential
|
|
return value
|
|
|
|
def policy(self) -> DurablePolicy:
|
|
policy = DurablePolicy.load(self.policy_path, self.revocation_path)
|
|
if policy.account != self.expected_account:
|
|
raise AuthorizationError("POLICY_ACCOUNT_MISMATCH", 403)
|
|
return policy
|
|
|
|
def projection(self, projection_id: str) -> dict[str, Any]:
|
|
policy = self.policy()
|
|
value = self.store.get_projection(projection_id)
|
|
if value is None:
|
|
raise AuthorizationError("PROJECTION_NOT_FOUND", 404)
|
|
if value.get("grant_id") != policy.grant_id:
|
|
raise AuthorizationError("PROJECTION_GRANT_MISMATCH", 403)
|
|
if value.get("account") != policy.account:
|
|
raise AuthorizationError("PROJECTION_ACCOUNT_MISMATCH", 403)
|
|
return value
|
|
|
|
def projections(self, *, limit: int = 100) -> dict[str, Any]:
|
|
policy = self.policy()
|
|
items = self.store.list_projections(
|
|
grant_id=policy.grant_id,
|
|
account=policy.account,
|
|
limit=limit,
|
|
)
|
|
return {
|
|
"schema": PROJECTION_LIST_SCHEMA,
|
|
"grant_id": policy.grant_id,
|
|
"account": policy.account,
|
|
"capability": "release:candidate",
|
|
"count": len(items),
|
|
"items": items,
|
|
"external_connector_ack_supported": False,
|
|
"separate_owner_release_approval_still_required": True,
|
|
}
|
|
|
|
@staticmethod
|
|
def validate_gate_amendment(
|
|
amendment: Mapping[str, Any],
|
|
*,
|
|
policy: DurablePolicy,
|
|
request: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
expected = {
|
|
"schema": GATE_AMENDMENT_SCHEMA,
|
|
"classification": GATE_AMENDMENT_CLASSIFICATION,
|
|
"supervisor_task_id": "r42-release-preparation-autonomous-supervisor-20260820",
|
|
"supervisor_execution_digest": "491457595667a95809f6f3cd19e9e7c9e74181070fd8038b86f30eb41bdfc231",
|
|
"actor_type": "credentialed_host_coordinator",
|
|
"operation": "createGovernedTask",
|
|
"repository": "aios/aios",
|
|
"original_external_request_sha256": "08fc2f3d1b64a2ca11da9f22da48de834cff3442fa7fa2d3bc390f8dbf9b254a",
|
|
"external_connector_rule": "ACK_STILL_REQUIRED",
|
|
"host_coordinator_rule": "DURABLE_OWNER_GOVERNED_TASK_PROJECT",
|
|
"owner_grant_id": policy.grant_id,
|
|
"owner_policy_sha256": policy.policy_sha256,
|
|
"builder_task_id": "r42-trusted-candidate-builder-runtime-wiring-20260820",
|
|
"builder_base_oid": "d957a216a6e9bd4c2292ffc6e44876a1c08fa1e3",
|
|
"builder_expected_branch_oid": "d957a216a6e9bd4c2292ffc6e44876a1c08fa1e3",
|
|
"builder_target_branch": "agent/r42-trusted-candidate-builder-runtime-wiring-20260820",
|
|
"builder_generation": 1,
|
|
"builder_idempotency_key": "r42-trusted-candidate-builder-runtime-wiring-binding-v1",
|
|
}
|
|
value = dict(amendment)
|
|
if value != expected:
|
|
raise AuthorizationError("GATE_AMENDMENT_BINDING_INVALID", 403)
|
|
request_coordinates = {
|
|
"task_id": expected["builder_task_id"],
|
|
"base_oid": expected["builder_base_oid"],
|
|
"expected_branch_oid": expected["builder_expected_branch_oid"],
|
|
"target_branch": expected["builder_target_branch"],
|
|
"generation": expected["builder_generation"],
|
|
"idempotency_key": expected["builder_idempotency_key"],
|
|
"repository": expected["repository"],
|
|
}
|
|
if any(request.get(key) != item for key, item in request_coordinates.items()):
|
|
raise AuthorizationError("GATE_AMENDMENT_BUILDER_BINDING_INVALID", 403)
|
|
return value
|
|
|
|
def execute(
|
|
self,
|
|
capability: str,
|
|
request: Mapping[str, Any],
|
|
*,
|
|
idempotency_key: str,
|
|
amendment: Mapping[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
if not GRANT_RE.fullmatch(idempotency_key):
|
|
raise AuthorizationError("IDEMPOTENCY_KEY_INVALID")
|
|
policy = self.policy()
|
|
decision = policy.authorize(capability, request)
|
|
request_sha = decision["request_sha256"]
|
|
amendment_value: dict[str, Any] | None = None
|
|
if amendment is not None:
|
|
if capability != "governed-task:project":
|
|
raise AuthorizationError("GATE_AMENDMENT_CAPABILITY_INVALID", 403)
|
|
amendment_value = self.validate_gate_amendment(
|
|
amendment,
|
|
policy=policy,
|
|
request=request,
|
|
)
|
|
|
|
prior = self.store.get(idempotency_key)
|
|
if prior is not None:
|
|
if prior["request_sha256"] != request_sha:
|
|
raise AuthorizationError("IDEMPOTENCY_CONFLICT", 409)
|
|
response = prior["response"]
|
|
if amendment_value is not None:
|
|
amendment_result = self.store.put_gate_amendment(
|
|
idempotency_key,
|
|
amendment_value,
|
|
)
|
|
response = {
|
|
**response,
|
|
"gate_amendment": {
|
|
"classification": GATE_AMENDMENT_CLASSIFICATION,
|
|
"amendment_sha256": amendment_result["amendment_sha256"],
|
|
},
|
|
}
|
|
self.store.update_response(idempotency_key, request_sha, response)
|
|
if (
|
|
capability == "release:candidate"
|
|
and not response.get("projection_id")
|
|
):
|
|
projection = self.store.enqueue_projection(
|
|
idempotency_key=idempotency_key,
|
|
grant_id=policy.grant_id,
|
|
account=policy.account,
|
|
capability=capability,
|
|
policy_sha256=policy.policy_sha256,
|
|
request_sha256=request_sha,
|
|
request=request,
|
|
)
|
|
response = {
|
|
**response,
|
|
"projection_id": projection["projection_id"],
|
|
"projection_state": projection["state"],
|
|
"external_capability_projection_required": True,
|
|
"separate_owner_release_approval_still_required": True,
|
|
}
|
|
self.store.update_response(
|
|
idempotency_key,
|
|
request_sha,
|
|
response,
|
|
)
|
|
return response
|
|
|
|
if capability == "governed-task:project":
|
|
status, value = self.upstream.project_governed_task(request)
|
|
if status not in {200, 201, 202}:
|
|
raise AuthorizationError(
|
|
"GOVERNED_TASK_PROJECTION_FAILED",
|
|
409 if status == 409 else 503,
|
|
)
|
|
if value.get("task_id") != request["task_id"]:
|
|
raise AuthorizationError("GOVERNED_TASK_RESPONSE_INVALID", 502)
|
|
operation_state = "EXECUTED"
|
|
upstream_status: int | None = status
|
|
upstream_response: Mapping[str, Any] | None = value
|
|
elif capability == "release:candidate":
|
|
# Persist a machine-readable projection request. The connector
|
|
# remains the only component that can turn this request into a real
|
|
# account scope; this broker never self-grants or calls a fake
|
|
# release-builder endpoint.
|
|
projection = self.store.enqueue_projection(
|
|
idempotency_key=idempotency_key,
|
|
grant_id=policy.grant_id,
|
|
account=policy.account,
|
|
capability=capability,
|
|
policy_sha256=policy.policy_sha256,
|
|
request_sha256=request_sha,
|
|
request=request,
|
|
)
|
|
operation_state = "AUTHORIZED_PENDING_EXTERNAL_CAPABILITY_PROJECTION"
|
|
upstream_status = None
|
|
upstream_response = None
|
|
else:
|
|
raise AuthorizationError("CAPABILITY_NOT_SUPPORTED", 403)
|
|
|
|
response = {
|
|
"schema": RECEIPT_SCHEMA,
|
|
"grant_id": policy.grant_id,
|
|
"owner_identity": policy.owner_identity,
|
|
"account": policy.account,
|
|
"capability": capability,
|
|
"policy_sha256": policy.policy_sha256,
|
|
"request_sha256": request_sha,
|
|
"idempotency_key": idempotency_key,
|
|
"operation_state": operation_state,
|
|
"upstream_status": upstream_status,
|
|
"upstream_response": upstream_response,
|
|
"external_capability_projection_required": (
|
|
capability == "release:candidate"
|
|
),
|
|
"projection_id": (
|
|
projection["projection_id"]
|
|
if capability == "release:candidate"
|
|
else None
|
|
),
|
|
"projection_state": (
|
|
projection["state"]
|
|
if capability == "release:candidate"
|
|
else None
|
|
),
|
|
"separate_owner_release_approval_still_required": True,
|
|
}
|
|
if amendment_value is not None:
|
|
amendment_result = self.store.put_gate_amendment(
|
|
idempotency_key,
|
|
amendment_value,
|
|
)
|
|
response["gate_amendment"] = {
|
|
"classification": GATE_AMENDMENT_CLASSIFICATION,
|
|
"amendment_sha256": amendment_result["amendment_sha256"],
|
|
}
|
|
self.store.put(idempotency_key, capability, request_sha, response)
|
|
return response
|
|
|
|
|
|
def load_secret(path: Path, *, code: str) -> str:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise RuntimeError(code + "_UNAVAILABLE")
|
|
value = path.read_text(encoding="utf-8").strip()
|
|
if len(value) < 32 or any(ch.isspace() for ch in value):
|
|
raise RuntimeError(code + "_INVALID")
|
|
return value
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
broker: Broker | None = None
|
|
|
|
def log_message(self, *_args: Any) -> None:
|
|
return
|
|
|
|
def send_json(
|
|
self,
|
|
value: Mapping[str, Any],
|
|
status: int,
|
|
*,
|
|
internal_credential: str | None = None,
|
|
) -> None:
|
|
raw = canonical(value)
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Cache-Control", "no-store")
|
|
if internal_credential:
|
|
self.send_header(
|
|
"X-AIOS-Caller-Projection-Credential",
|
|
internal_credential,
|
|
)
|
|
self.send_header("Content-Length", str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
|
|
def caller_account(self) -> str:
|
|
return self.headers.get("X-AIOS-Account", "").strip()
|
|
|
|
def peer_user(self) -> str:
|
|
if self.broker is None:
|
|
return ""
|
|
return peer_user_from_connection(self.connection)
|
|
|
|
def peer_authorized(self) -> bool:
|
|
if self.broker is None:
|
|
return False
|
|
return authorize_broker_peer(self.connection)
|
|
|
|
def account_authorized(self) -> bool:
|
|
if self.broker is None:
|
|
return False
|
|
return self.caller_account() == self.broker.expected_account
|
|
|
|
def body(self) -> dict[str, Any]:
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "-1"))
|
|
except ValueError as exc:
|
|
raise AuthorizationError("CONTENT_LENGTH_INVALID") from exc
|
|
if length < 1 or length > MAX_BODY:
|
|
raise AuthorizationError("REQUEST_SIZE_INVALID", 413)
|
|
try:
|
|
value = json.loads(self.rfile.read(length).decode())
|
|
except Exception as exc:
|
|
raise AuthorizationError("REQUEST_JSON_INVALID") from exc
|
|
if not isinstance(value, Mapping):
|
|
raise AuthorizationError("REQUEST_OBJECT_REQUIRED")
|
|
return dict(value)
|
|
|
|
def do_GET(self) -> None:
|
|
if self.path == "/health":
|
|
self.send_json(
|
|
{
|
|
"status": "ok",
|
|
"service": "aios-owner-durable-authorization-broker",
|
|
"transport": "unix",
|
|
},
|
|
200,
|
|
)
|
|
return
|
|
try:
|
|
peer_ok = self.peer_authorized()
|
|
except AuthorizationError as exc:
|
|
self.send_json({"error": exc.code}, exc.status)
|
|
return
|
|
if not peer_ok:
|
|
self.send_json({"error": "SOCKGROUP_NOT_AUTHORIZED"}, 403)
|
|
return
|
|
if not self.account_authorized():
|
|
self.send_json({"error": "ACCOUNT_NOT_AUTHORIZED"}, 403)
|
|
return
|
|
if self.broker is None:
|
|
self.send_json({"error": "NOT_FOUND"}, 404)
|
|
return
|
|
try:
|
|
if self.path == "/v1/capabilities":
|
|
policy = self.broker.policy()
|
|
value = {
|
|
"schema": POLICY_SCHEMA,
|
|
"grant_id": policy.grant_id,
|
|
"account": policy.account,
|
|
"capabilities": sorted(policy.capabilities),
|
|
"policy_sha256": policy.policy_sha256,
|
|
"constraints": {
|
|
"repository": policy.constraints.repository,
|
|
"allowed_target_prefix": policy.constraints.allowed_target_prefix,
|
|
"max_task_risk": policy.constraints.max_task_risk,
|
|
},
|
|
"persistent_until_revoked": True,
|
|
"separate_owner_release_approval_still_required": True,
|
|
}
|
|
elif self.path == "/v1/projections":
|
|
value = self.broker.projections()
|
|
else:
|
|
match = re.fullmatch(
|
|
r"/v1/projections/(projection-[0-9a-f]{32})",
|
|
self.path,
|
|
)
|
|
if match is None:
|
|
self.send_json({"error": "NOT_FOUND"}, 404)
|
|
return
|
|
value = self.broker.projection(match.group(1))
|
|
self.send_json(value, 200)
|
|
except AuthorizationError as exc:
|
|
self.send_json({"error": exc.code}, exc.status)
|
|
|
|
def do_POST(self) -> None:
|
|
try:
|
|
peer_ok = self.peer_authorized()
|
|
except AuthorizationError as exc:
|
|
self.send_json({"error": exc.code}, exc.status)
|
|
return
|
|
if not peer_ok:
|
|
self.send_json({"error": "SOCKGROUP_NOT_AUTHORIZED"}, 403)
|
|
return
|
|
if not self.account_authorized():
|
|
self.send_json({"error": "ACCOUNT_NOT_AUTHORIZED"}, 403)
|
|
return
|
|
routes = {
|
|
"/v1/project-governed-task": "governed-task:project",
|
|
"/v1/authorize-release-candidate": "release:candidate",
|
|
"/v1/project-caller": "caller:projection",
|
|
}
|
|
capability = routes.get(self.path)
|
|
if capability is None or self.broker is None:
|
|
self.send_json({"error": "NOT_FOUND"}, 404)
|
|
return
|
|
try:
|
|
body = self.body()
|
|
if self.path == "/v1/project-caller":
|
|
if set(body) != {
|
|
"caller",
|
|
"scope",
|
|
"idempotency_key",
|
|
"request",
|
|
}:
|
|
raise AuthorizationError("CALLER_PROJECTION_FIELDS_INVALID")
|
|
if not isinstance(body["request"], Mapping):
|
|
raise AuthorizationError("CALLER_PROJECTION_REQUEST_INVALID")
|
|
value = self.broker.project_caller(
|
|
str(body["caller"]),
|
|
str(body["scope"]),
|
|
dict(body["request"]),
|
|
idempotency_key=str(body["idempotency_key"]),
|
|
)
|
|
credential = str(value.pop("_credential", ""))
|
|
if len(credential) < 32:
|
|
raise AuthorizationError("CREDENTIAL_REHYDRATION_MISSING", 503)
|
|
self.send_json(
|
|
value,
|
|
200,
|
|
internal_credential=credential,
|
|
)
|
|
return
|
|
if set(body) not in (
|
|
{"idempotency_key", "request"},
|
|
{"idempotency_key", "request", "amendment"},
|
|
):
|
|
raise AuthorizationError("BROKER_FIELDS_INVALID")
|
|
if not isinstance(body["request"], Mapping):
|
|
raise AuthorizationError("BROKER_REQUEST_INVALID")
|
|
if "amendment" in body and not isinstance(body["amendment"], Mapping):
|
|
raise AuthorizationError("GATE_AMENDMENT_INVALID")
|
|
value = self.broker.execute(
|
|
capability,
|
|
dict(body["request"]),
|
|
idempotency_key=str(body["idempotency_key"]),
|
|
amendment=(
|
|
dict(body["amendment"])
|
|
if isinstance(body.get("amendment"), Mapping)
|
|
else None
|
|
),
|
|
)
|
|
self.send_json(value, 200)
|
|
except AuthorizationError as exc:
|
|
self.send_json({"error": exc.code}, exc.status)
|
|
|
|
|
|
class UnixHTTPServer(socketserver.ThreadingUnixStreamServer):
|
|
daemon_threads = True
|
|
|
|
def serve() -> None:
|
|
credentials = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
|
raw_allowed_users = os.environ.get(
|
|
"AIOS_OWNER_AUTH_BROKER_ALLOWED_USERS",
|
|
"",
|
|
).strip()
|
|
if raw_allowed_users:
|
|
allowed_peer_users = tuple(
|
|
dict.fromkeys(
|
|
item.strip()
|
|
for item in raw_allowed_users.split(",")
|
|
if item.strip()
|
|
)
|
|
)
|
|
else:
|
|
single_allowed_user = os.environ.get(
|
|
"AIOS_OWNER_AUTH_BROKER_ALLOWED_USER",
|
|
"aios-autonomous-agent-worker",
|
|
).strip()
|
|
allowed_peer_users = (single_allowed_user,) if single_allowed_user else ()
|
|
if not allowed_peer_users:
|
|
raise RuntimeError("BROKER_ALLOWED_USER_MISSING")
|
|
allowed_peer_user = allowed_peer_users[0]
|
|
for username in allowed_peer_users:
|
|
pwd.getpwnam(username)
|
|
allowed_peer_uid = pwd.getpwnam(allowed_peer_user).pw_uid
|
|
global CANONICAL_BROKER_GROUP
|
|
CANONICAL_BROKER_GROUP = os.environ.get(
|
|
"AIOS_OWNER_AUTH_BROKER_ALLOWED_GROUP",
|
|
"aios-autonomous-agent-worker",
|
|
).strip()
|
|
if not CANONICAL_BROKER_GROUP:
|
|
raise RuntimeError("BROKER_ALLOWED_GROUP_MISSING")
|
|
try:
|
|
allowed_peer_gid = grp.getgrnam(CANONICAL_BROKER_GROUP).gr_gid
|
|
except KeyError as exc:
|
|
raise RuntimeError("BROKER_ALLOWED_GROUP_INVALID") from exc
|
|
policy_path = Path(
|
|
os.environ.get(
|
|
"AIOS_OWNER_DURABLE_AUTHORIZATION_POLICY",
|
|
"/etc/aios-gitops/owner-durable-authorization.json",
|
|
)
|
|
)
|
|
revocation_path = Path(
|
|
os.environ.get(
|
|
"AIOS_OWNER_DURABLE_AUTHORIZATION_REVOCATIONS",
|
|
"/etc/aios-gitops/owner-durable-authorization-revocations.json",
|
|
)
|
|
)
|
|
bridge_token = load_secret(
|
|
Path(
|
|
os.environ.get(
|
|
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
|
str(credentials / "agent_task_control_token"),
|
|
)
|
|
),
|
|
code="BRIDGE_CONTROL_CREDENTIAL",
|
|
)
|
|
account = os.environ.get(
|
|
"AIOS_OWNER_AUTH_BROKER_ACCOUNT",
|
|
"chatgpt-governed-implementer-singlehost",
|
|
).strip()
|
|
state_root = Path(
|
|
os.environ.get(
|
|
"AIOS_OWNER_AUTH_BROKER_STATE_ROOT",
|
|
"/var/lib/aios-owner-authorization-broker",
|
|
)
|
|
)
|
|
Handler.broker = Broker(
|
|
policy_path=policy_path,
|
|
revocation_path=revocation_path,
|
|
store=ReceiptStore(state_root / "receipts.sqlite3"),
|
|
upstream=Upstream(
|
|
bridge_url="http://127.0.0.1:18892",
|
|
bridge_token=bridge_token,
|
|
),
|
|
expected_account=account,
|
|
expected_peer_user=allowed_peer_user,
|
|
allowed_peer_users=frozenset(allowed_peer_users),
|
|
)
|
|
socket_path = Path(
|
|
os.environ.get(
|
|
"AIOS_OWNER_AUTH_BROKER_SOCKET",
|
|
"/run/aios-owner-authorization-broker/broker.sock",
|
|
)
|
|
)
|
|
if not socket_path.is_absolute() or socket_path.is_symlink():
|
|
raise RuntimeError("BROKER_SOCKET_PATH_INVALID")
|
|
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if socket_path.exists():
|
|
if not stat.S_ISSOCK(socket_path.stat().st_mode):
|
|
raise RuntimeError("BROKER_SOCKET_CONFLICT")
|
|
socket_path.unlink()
|
|
server = UnixHTTPServer(str(socket_path), Handler)
|
|
os.chown(socket_path, allowed_peer_uid, allowed_peer_gid)
|
|
os.chmod(socket_path, 0o660)
|
|
try:
|
|
server.serve_forever()
|
|
finally:
|
|
server.server_close()
|
|
socket_path.unlink(missing_ok=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
serve()
|