761 lines
30 KiB
Python
761 lines
30 KiB
Python
"""Task-scoped identity overlay for bootstrap writer provisioning.
|
|
|
|
This module resolves bearer-registry identity ambiguity without modifying the
|
|
global token registry. A root-governed one-shot issuer creates a random,
|
|
short-lived credential bound to exactly one repository, base OID, task and
|
|
target branch. The HTTP overlay authenticates only that credential and invokes
|
|
the existing OwnerAuthorizationBroker.
|
|
|
|
The overlay never grants main, tag, baseline, production or repository-admin
|
|
capabilities. The bearer secret is stored only in a root-owned runtime path and
|
|
is removed after success, failure, revocation or expiry.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
from authorized_task_writer_provisioning import (
|
|
BRANCH_RE,
|
|
ProvisioningError,
|
|
OID_RE,
|
|
REQUIRED_ALLOWED_OPERATIONS,
|
|
REQUIRED_DENIED_OPERATIONS,
|
|
TASK_RE,
|
|
)
|
|
from owner_authorization_broker import (
|
|
ACTION,
|
|
BROKER_REQUEST_FIELDS,
|
|
OwnerAuthorizationBroker,
|
|
OwnerAuthorizationBrokerConfig,
|
|
OwnerAuthorizationError,
|
|
OwnerPrincipal,
|
|
TaskWriterProvisioner,
|
|
canonical_json_bytes,
|
|
iso_utc,
|
|
sha256_json,
|
|
utc_now,
|
|
)
|
|
|
|
|
|
OVERLAY_SCHEMA = "aios.task-identity-overlay.v1"
|
|
OVERLAY_SCOPE = "owner:task-authorize"
|
|
OVERLAY_ID_RE = re.compile(r"^overlay-[0-9a-f]{32}$")
|
|
OWNER_IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@:+/-]{1,254}$")
|
|
AUTH_ASSERTION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{3,255}$")
|
|
TERMINAL_STATES = frozenset({"CONSUMED", "FAILED", "REVOKED", "EXPIRED"})
|
|
MAX_BODY_BYTES = 1_048_576
|
|
|
|
|
|
class IdentityOverlayError(RuntimeError):
|
|
"""Stable, fail-closed error for the overlay control plane."""
|
|
|
|
def __init__(
|
|
self,
|
|
code: str,
|
|
status: int = 400,
|
|
*,
|
|
details: Mapping[str, Any] | None = None,
|
|
) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.status = status
|
|
self.details = dict(details or {})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TaskBinding:
|
|
task_id: str
|
|
repository: str
|
|
base_ref: str
|
|
base_oid: str
|
|
target_branch: str
|
|
|
|
def as_dict(self) -> dict[str, str]:
|
|
return {
|
|
"task_id": self.task_id,
|
|
"repository": self.repository,
|
|
"base_ref": self.base_ref,
|
|
"base_oid": self.base_oid,
|
|
"target_branch": self.target_branch,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IdentityOverlayConfig:
|
|
state_root: Path
|
|
secret_root: Path
|
|
binding: TaskBinding
|
|
owner_identity: str
|
|
max_ttl_seconds: int = 900
|
|
require_root_issue: bool = True
|
|
collision_secret_refs: tuple[Path, ...] = ()
|
|
broker_state_root: Path | None = None
|
|
|
|
@classmethod
|
|
def from_environment(cls) -> "IdentityOverlayConfig":
|
|
try:
|
|
maximum = int(
|
|
os.getenv("AIOS_GITOPS_IDENTITY_OVERLAY_MAX_TTL_SECONDS", "900")
|
|
)
|
|
except ValueError:
|
|
raise IdentityOverlayError("OVERLAY_MAX_TTL_INVALID", 503) from None
|
|
if not 60 <= maximum <= 900:
|
|
raise IdentityOverlayError("OVERLAY_MAX_TTL_INVALID", 503)
|
|
|
|
refs = tuple(
|
|
Path(item.strip())
|
|
for item in os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS", ""
|
|
).split(",")
|
|
if item.strip()
|
|
)
|
|
owner = os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_OWNER_IDENTITY", ""
|
|
).strip()
|
|
config = cls(
|
|
state_root=Path(
|
|
os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_STATE_ROOT",
|
|
"/var/lib/aios-gitops/task-identity-overlays",
|
|
)
|
|
),
|
|
secret_root=Path(
|
|
os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_SECRET_ROOT",
|
|
"/run/aios-gitops/task-identity-overlays",
|
|
)
|
|
),
|
|
broker_state_root=Path(
|
|
os.getenv(
|
|
"AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT",
|
|
"/var/lib/aios-gitops/owner-authorization-broker",
|
|
)
|
|
),
|
|
binding=TaskBinding(
|
|
task_id=os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_TASK_ID", ""
|
|
).strip(),
|
|
repository=os.getenv(
|
|
"AIOS_GITOPS_REPOSITORY_IDENTITY", "aios/aios"
|
|
).strip(),
|
|
base_ref=os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_BASE_REF",
|
|
"refs/heads/main",
|
|
).strip(),
|
|
base_oid=os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_BASE_OID", ""
|
|
).strip(),
|
|
target_branch=os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_TARGET_BRANCH", ""
|
|
).strip(),
|
|
),
|
|
owner_identity=owner,
|
|
max_ttl_seconds=maximum,
|
|
require_root_issue=os.getenv(
|
|
"AIOS_GITOPS_IDENTITY_OVERLAY_REQUIRE_ROOT", "true"
|
|
).strip().lower()
|
|
not in {"0", "false", "no", "off"},
|
|
collision_secret_refs=refs,
|
|
)
|
|
_validate_config(config)
|
|
return config
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OverlaySession:
|
|
overlay_id: str
|
|
metadata_path: Path
|
|
secret_ref: Path
|
|
owner_identity: str
|
|
request_digest_sha256: str
|
|
binding: TaskBinding
|
|
|
|
|
|
def _validate_config(config: IdentityOverlayConfig) -> None:
|
|
binding = config.binding
|
|
if not TASK_RE.fullmatch(binding.task_id):
|
|
raise IdentityOverlayError("OVERLAY_TASK_ID_INVALID", 503)
|
|
if binding.repository != "aios/aios":
|
|
raise IdentityOverlayError("OVERLAY_REPOSITORY_REJECTED", 503)
|
|
if binding.base_ref != "refs/heads/main":
|
|
raise IdentityOverlayError("OVERLAY_BASE_REF_REJECTED", 503)
|
|
if not OID_RE.fullmatch(binding.base_oid):
|
|
raise IdentityOverlayError("OVERLAY_BASE_OID_INVALID", 503)
|
|
if (
|
|
not BRANCH_RE.fullmatch(binding.target_branch)
|
|
or binding.target_branch in {"main", "master"}
|
|
):
|
|
raise IdentityOverlayError("OVERLAY_TARGET_BRANCH_INVALID", 503)
|
|
if not OWNER_IDENTITY_RE.fullmatch(config.owner_identity):
|
|
raise IdentityOverlayError("OVERLAY_OWNER_IDENTITY_INVALID", 503)
|
|
if not 60 <= config.max_ttl_seconds <= 900:
|
|
raise IdentityOverlayError("OVERLAY_MAX_TTL_INVALID", 503)
|
|
if config.state_root == config.secret_root:
|
|
raise IdentityOverlayError("OVERLAY_STATE_SECRET_ROOT_COLLISION", 503)
|
|
|
|
|
|
def _prepare_directory(path: Path, mode: int = 0o700) -> None:
|
|
if path.is_symlink():
|
|
raise IdentityOverlayError("OVERLAY_PATH_SYMLINK_REJECTED", 500)
|
|
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
|
os.chmod(path, mode)
|
|
if path.stat().st_mode & 0o077:
|
|
raise IdentityOverlayError("OVERLAY_DIRECTORY_PERMISSIONS_REJECTED", 500)
|
|
|
|
|
|
def _atomic_write(path: Path, data: bytes, mode: int) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if path.parent.is_symlink() or path.is_symlink():
|
|
raise IdentityOverlayError("OVERLAY_PATH_SYMLINK_REJECTED", 500)
|
|
fd, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
|
|
try:
|
|
os.fchmod(fd, mode)
|
|
with os.fdopen(fd, "wb", closefd=True) as stream:
|
|
stream.write(data)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
os.chmod(path, mode)
|
|
parent_fd = os.open(
|
|
path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
)
|
|
try:
|
|
os.fsync(parent_fd)
|
|
finally:
|
|
os.close(parent_fd)
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
|
|
|
|
def _atomic_write_json(path: Path, value: Any, mode: int = 0o600) -> None:
|
|
_atomic_write(path, canonical_json_bytes(value), mode)
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise IdentityOverlayError("OVERLAY_METADATA_INVALID", 500)
|
|
if path.stat().st_size <= 0 or path.stat().st_size > MAX_BODY_BYTES:
|
|
raise IdentityOverlayError("OVERLAY_METADATA_INVALID", 500)
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
raise IdentityOverlayError("OVERLAY_METADATA_INVALID", 500) from None
|
|
if not isinstance(value, dict):
|
|
raise IdentityOverlayError("OVERLAY_METADATA_INVALID", 500)
|
|
return value
|
|
|
|
|
|
def _parse_utc(value: str) -> datetime:
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except (TypeError, ValueError):
|
|
raise IdentityOverlayError("OVERLAY_TIMESTAMP_INVALID", 500) from None
|
|
if parsed.tzinfo is None:
|
|
raise IdentityOverlayError("OVERLAY_TIMESTAMP_INVALID", 500)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _token_hash(token: bytes) -> str:
|
|
return hashlib.sha256(token.strip()).hexdigest()
|
|
|
|
|
|
def _safe_unlink(path: Path) -> None:
|
|
try:
|
|
if path.is_symlink():
|
|
raise IdentityOverlayError("OVERLAY_SECRET_SYMLINK_REJECTED", 500)
|
|
path.unlink(missing_ok=True)
|
|
except OSError as exc:
|
|
raise IdentityOverlayError("OVERLAY_SECRET_REMOVAL_FAILED", 500) from exc
|
|
|
|
|
|
def _validate_request_binding(
|
|
request: Mapping[str, Any], binding: TaskBinding, max_ttl: int
|
|
) -> dict[str, Any]:
|
|
if set(request) != BROKER_REQUEST_FIELDS:
|
|
raise IdentityOverlayError("OVERLAY_REQUEST_FIELDS_REJECTED")
|
|
if request.get("action") != ACTION:
|
|
raise IdentityOverlayError("OVERLAY_ACTION_REJECTED")
|
|
expected = binding.as_dict()
|
|
for key, value in expected.items():
|
|
if request.get(key) != value:
|
|
raise IdentityOverlayError(
|
|
"OVERLAY_BINDING_MISMATCH", 409, details={"field": key}
|
|
)
|
|
if request.get("one_time_use") is not True:
|
|
raise IdentityOverlayError("OVERLAY_ONE_TIME_USE_REQUIRED")
|
|
expiry = request.get("expires_in_seconds")
|
|
if (
|
|
not isinstance(expiry, int)
|
|
or isinstance(expiry, bool)
|
|
or not 60 <= expiry <= max_ttl
|
|
):
|
|
raise IdentityOverlayError("OVERLAY_TTL_REJECTED")
|
|
for field in (
|
|
"production_change_authorized",
|
|
"tag_change_authorized",
|
|
"baseline_change_authorized",
|
|
"main_write_authorized",
|
|
):
|
|
if request.get(field) is not False:
|
|
raise IdentityOverlayError("OVERLAY_PRODUCTION_CAPABILITY_REJECTED", 403)
|
|
if (
|
|
not isinstance(request.get("allowed_operations"), list)
|
|
or frozenset(request["allowed_operations"])
|
|
!= REQUIRED_ALLOWED_OPERATIONS
|
|
):
|
|
raise IdentityOverlayError("OVERLAY_ALLOWED_OPERATIONS_MISMATCH", 403)
|
|
if (
|
|
not isinstance(request.get("denied_operations"), list)
|
|
or frozenset(request["denied_operations"])
|
|
!= REQUIRED_DENIED_OPERATIONS
|
|
):
|
|
raise IdentityOverlayError("OVERLAY_DENIED_OPERATIONS_MISMATCH", 403)
|
|
return dict(request)
|
|
|
|
|
|
def _metadata_digest(metadata: Mapping[str, Any]) -> str:
|
|
unsigned = {
|
|
key: value
|
|
for key, value in metadata.items()
|
|
if key != "metadata_digest_sha256"
|
|
}
|
|
return sha256_json(unsigned)
|
|
|
|
|
|
def _verify_metadata(metadata: Mapping[str, Any]) -> None:
|
|
if metadata.get("schema") != OVERLAY_SCHEMA:
|
|
raise IdentityOverlayError("OVERLAY_METADATA_SCHEMA_INVALID", 500)
|
|
overlay_id = str(metadata.get("overlay_id") or "")
|
|
if not OVERLAY_ID_RE.fullmatch(overlay_id):
|
|
raise IdentityOverlayError("OVERLAY_METADATA_ID_INVALID", 500)
|
|
expected = _metadata_digest(metadata)
|
|
if metadata.get("metadata_digest_sha256") != expected:
|
|
raise IdentityOverlayError("OVERLAY_METADATA_DIGEST_MISMATCH", 500)
|
|
|
|
|
|
class TaskIdentityOverlayStore:
|
|
"""Issue, authenticate, consume and revoke one task-bound credential."""
|
|
|
|
def __init__(self, config: IdentityOverlayConfig) -> None:
|
|
_validate_config(config)
|
|
self.config = config
|
|
|
|
@property
|
|
def lock_path(self) -> Path:
|
|
return self.config.state_root / "overlay.lock"
|
|
|
|
def _lock(self):
|
|
_prepare_directory(self.config.state_root)
|
|
_prepare_directory(self.config.secret_root)
|
|
self.lock_path.touch(mode=0o600, exist_ok=True)
|
|
os.chmod(self.lock_path, 0o600)
|
|
stream = self.lock_path.open("r+b")
|
|
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
|
|
return stream
|
|
|
|
def _metadata_paths(self) -> list[Path]:
|
|
return sorted(self.config.state_root.glob("overlay-*/metadata.json"))
|
|
|
|
def _active_for_task(self) -> tuple[Path, dict[str, Any]] | None:
|
|
now = utc_now()
|
|
for path in self._metadata_paths():
|
|
metadata = _read_json(path)
|
|
_verify_metadata(metadata)
|
|
if metadata.get("task_id") != self.config.binding.task_id:
|
|
continue
|
|
state = metadata.get("status")
|
|
if state in TERMINAL_STATES:
|
|
continue
|
|
if _parse_utc(str(metadata["expires_at"])) <= now:
|
|
metadata["status"] = "EXPIRED"
|
|
metadata["expired_at"] = iso_utc(now)
|
|
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
|
_atomic_write_json(path, metadata)
|
|
_safe_unlink(Path(str(metadata["secret_ref"])))
|
|
continue
|
|
return path, metadata
|
|
return None
|
|
|
|
def _assert_hash_unique(self, digest: str) -> None:
|
|
matches = 0
|
|
for path in self._metadata_paths():
|
|
metadata = _read_json(path)
|
|
_verify_metadata(metadata)
|
|
if metadata.get("token_sha256") == digest:
|
|
matches += 1
|
|
for secret_ref in self.config.collision_secret_refs:
|
|
if not secret_ref.exists():
|
|
continue
|
|
if secret_ref.is_symlink() or not secret_ref.is_file():
|
|
raise IdentityOverlayError(
|
|
"OVERLAY_COLLISION_SECRET_REF_INVALID", 500
|
|
)
|
|
if secret_ref.stat().st_size > 65_536:
|
|
raise IdentityOverlayError(
|
|
"OVERLAY_COLLISION_SECRET_REF_INVALID", 500
|
|
)
|
|
if _token_hash(secret_ref.read_bytes()) == digest:
|
|
matches += 1
|
|
if matches:
|
|
raise IdentityOverlayError("OVERLAY_SECRET_IDENTITY_COLLISION", 409)
|
|
|
|
def issue(
|
|
self,
|
|
*,
|
|
ttl_seconds: int,
|
|
authorization_assertion_ref: str,
|
|
operator_identity: str,
|
|
) -> dict[str, Any]:
|
|
if self.config.require_root_issue and os.geteuid() != 0:
|
|
raise IdentityOverlayError("OVERLAY_ROOT_ISSUER_REQUIRED", 403)
|
|
if (
|
|
not isinstance(ttl_seconds, int)
|
|
or isinstance(ttl_seconds, bool)
|
|
or not 60 <= ttl_seconds <= self.config.max_ttl_seconds
|
|
):
|
|
raise IdentityOverlayError("OVERLAY_TTL_REJECTED")
|
|
if not AUTH_ASSERTION_RE.fullmatch(authorization_assertion_ref):
|
|
raise IdentityOverlayError("OVERLAY_AUTH_ASSERTION_REF_INVALID")
|
|
if not OWNER_IDENTITY_RE.fullmatch(operator_identity):
|
|
raise IdentityOverlayError("OVERLAY_OPERATOR_IDENTITY_INVALID")
|
|
|
|
with self._lock():
|
|
active = self._active_for_task()
|
|
if active:
|
|
_, metadata = active
|
|
expected = self.config.binding.as_dict()
|
|
actual = {
|
|
key: metadata.get(key)
|
|
for key in expected
|
|
}
|
|
if actual != expected:
|
|
raise IdentityOverlayError(
|
|
"OVERLAY_ACTIVE_BINDING_CONFLICT", 409
|
|
)
|
|
return {
|
|
"status": "OVERLAY_ACTIVE",
|
|
"idempotent": True,
|
|
"overlay_id": metadata["overlay_id"],
|
|
"secret_ref": metadata["secret_ref"],
|
|
"expires_at": metadata["expires_at"],
|
|
"binding": expected,
|
|
}
|
|
|
|
token_text = secrets.token_urlsafe(48)
|
|
token_bytes = token_text.encode("ascii")
|
|
digest = _token_hash(token_bytes)
|
|
self._assert_hash_unique(digest)
|
|
overlay_id = "overlay-" + hashlib.sha256(
|
|
(
|
|
f"{self.config.binding.task_id}\0"
|
|
f"{self.config.binding.base_oid}\0"
|
|
f"{self.config.binding.target_branch}\0"
|
|
f"{digest}"
|
|
).encode("utf-8")
|
|
).hexdigest()[:32]
|
|
issued_at = utc_now()
|
|
expires_at = issued_at + timedelta(seconds=ttl_seconds)
|
|
overlay_dir = self.config.state_root / overlay_id
|
|
secret_dir = self.config.secret_root / overlay_id
|
|
_prepare_directory(overlay_dir)
|
|
_prepare_directory(secret_dir)
|
|
secret_ref = secret_dir / "bearer.token"
|
|
_atomic_write(secret_ref, token_bytes + b"\n", 0o600)
|
|
|
|
metadata: dict[str, Any] = {
|
|
"schema": OVERLAY_SCHEMA,
|
|
"overlay_id": overlay_id,
|
|
"status": "ACTIVE",
|
|
**self.config.binding.as_dict(),
|
|
"owner_identity": self.config.owner_identity,
|
|
"role": "owner",
|
|
"scope": OVERLAY_SCOPE,
|
|
"wildcard_scope": False,
|
|
"one_time_use": True,
|
|
"token_sha256": digest,
|
|
"secret_ref": str(secret_ref),
|
|
"authorization_assertion_ref": authorization_assertion_ref,
|
|
"operator_identity": operator_identity,
|
|
"issued_at": iso_utc(issued_at),
|
|
"expires_at": iso_utc(expires_at),
|
|
"attempt_count": 0,
|
|
}
|
|
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
|
metadata_path = overlay_dir / "metadata.json"
|
|
_atomic_write_json(metadata_path, metadata)
|
|
_atomic_write_json(
|
|
overlay_dir / "audit-issued.json",
|
|
{
|
|
"event": "TASK_IDENTITY_OVERLAY_ISSUED",
|
|
"overlay_id": overlay_id,
|
|
"task_id": self.config.binding.task_id,
|
|
"binding_digest_sha256": sha256_json(
|
|
self.config.binding.as_dict()
|
|
),
|
|
"authorization_assertion_ref": authorization_assertion_ref,
|
|
"operator_identity": operator_identity,
|
|
"issued_at": metadata["issued_at"],
|
|
"expires_at": metadata["expires_at"],
|
|
"token_sha256": digest,
|
|
},
|
|
)
|
|
return {
|
|
"status": "OVERLAY_ACTIVE",
|
|
"idempotent": False,
|
|
"overlay_id": overlay_id,
|
|
"secret_ref": str(secret_ref),
|
|
"expires_at": metadata["expires_at"],
|
|
"binding": self.config.binding.as_dict(),
|
|
}
|
|
|
|
def authenticate_and_lock(
|
|
self, token: str, request: Mapping[str, Any]
|
|
) -> OverlaySession:
|
|
if not token or len(token) > 4096:
|
|
raise IdentityOverlayError("OVERLAY_AUTHENTICATION_REQUIRED", 401)
|
|
digest = _token_hash(token.encode("utf-8"))
|
|
|
|
with self._lock():
|
|
matches: list[tuple[Path, dict[str, Any]]] = []
|
|
for path in self._metadata_paths():
|
|
metadata = _read_json(path)
|
|
_verify_metadata(metadata)
|
|
if metadata.get("token_sha256") == digest:
|
|
matches.append((path, metadata))
|
|
if not matches:
|
|
raise IdentityOverlayError("OVERLAY_CREDENTIAL_INVALID", 401)
|
|
if len(matches) != 1:
|
|
raise IdentityOverlayError("OVERLAY_SECRET_IDENTITY_COLLISION", 409)
|
|
path, metadata = matches[0]
|
|
if metadata.get("status") in TERMINAL_STATES:
|
|
raise IdentityOverlayError("OVERLAY_CREDENTIAL_INACTIVE", 401)
|
|
if _parse_utc(str(metadata["expires_at"])) <= utc_now():
|
|
metadata["status"] = "EXPIRED"
|
|
metadata["expired_at"] = iso_utc(utc_now())
|
|
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
|
_atomic_write_json(path, metadata)
|
|
_safe_unlink(Path(str(metadata["secret_ref"])))
|
|
raise IdentityOverlayError("OVERLAY_CREDENTIAL_EXPIRED", 401)
|
|
|
|
expected = self.config.binding.as_dict()
|
|
for key, value in expected.items():
|
|
if metadata.get(key) != value:
|
|
raise IdentityOverlayError(
|
|
"OVERLAY_METADATA_BINDING_MISMATCH", 409
|
|
)
|
|
if metadata.get("owner_identity") != self.config.owner_identity:
|
|
raise IdentityOverlayError("OVERLAY_OWNER_IDENTITY_MISMATCH", 409)
|
|
if metadata.get("scope") != OVERLAY_SCOPE:
|
|
raise IdentityOverlayError("OVERLAY_SCOPE_MISMATCH", 409)
|
|
if metadata.get("wildcard_scope") is not False:
|
|
raise IdentityOverlayError("OVERLAY_WILDCARD_SCOPE_REJECTED", 409)
|
|
|
|
# Validate detailed request policy only after the bearer identity is
|
|
# proven, so unauthenticated callers cannot probe task bindings.
|
|
validated = _validate_request_binding(
|
|
request, self.config.binding, self.config.max_ttl_seconds
|
|
)
|
|
request_digest = sha256_json(validated)
|
|
previous = str(metadata.get("request_digest_sha256") or "")
|
|
if metadata.get("status") == "LOCKED" and previous != request_digest:
|
|
raise IdentityOverlayError("OVERLAY_REPLAY_REQUEST_MISMATCH", 409)
|
|
metadata["status"] = "LOCKED"
|
|
metadata["request_digest_sha256"] = request_digest
|
|
metadata["locked_at"] = metadata.get("locked_at") or iso_utc(utc_now())
|
|
metadata["attempt_count"] = int(metadata.get("attempt_count") or 0) + 1
|
|
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
|
_atomic_write_json(path, metadata)
|
|
return OverlaySession(
|
|
overlay_id=str(metadata["overlay_id"]),
|
|
metadata_path=path,
|
|
secret_ref=Path(str(metadata["secret_ref"])),
|
|
owner_identity=str(metadata["owner_identity"]),
|
|
request_digest_sha256=request_digest,
|
|
binding=self.config.binding,
|
|
)
|
|
|
|
def complete(
|
|
self, session: OverlaySession, result: Mapping[str, Any]
|
|
) -> dict[str, Any]:
|
|
with self._lock():
|
|
metadata = _read_json(session.metadata_path)
|
|
_verify_metadata(metadata)
|
|
if metadata.get("status") == "CONSUMED":
|
|
return metadata
|
|
if metadata.get("status") != "LOCKED":
|
|
raise IdentityOverlayError("OVERLAY_NOT_LOCKED", 409)
|
|
if (
|
|
metadata.get("request_digest_sha256")
|
|
!= session.request_digest_sha256
|
|
):
|
|
raise IdentityOverlayError("OVERLAY_REQUEST_DIGEST_MISMATCH", 409)
|
|
metadata["status"] = "CONSUMED"
|
|
metadata["consumed_at"] = iso_utc(utc_now())
|
|
metadata["result_digest_sha256"] = sha256_json(dict(result))
|
|
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
|
_atomic_write_json(session.metadata_path, metadata)
|
|
_safe_unlink(session.secret_ref)
|
|
_atomic_write_json(
|
|
session.metadata_path.parent / "audit-consumed.json",
|
|
{
|
|
"event": "TASK_IDENTITY_OVERLAY_CONSUMED",
|
|
"overlay_id": session.overlay_id,
|
|
"task_id": session.binding.task_id,
|
|
"request_digest_sha256": session.request_digest_sha256,
|
|
"result_digest_sha256": metadata["result_digest_sha256"],
|
|
"consumed_at": metadata["consumed_at"],
|
|
"secret_removed": True,
|
|
},
|
|
)
|
|
return metadata
|
|
|
|
def fail(
|
|
self,
|
|
session: OverlaySession,
|
|
*,
|
|
error_code: str,
|
|
error_status: int,
|
|
) -> dict[str, Any]:
|
|
with self._lock():
|
|
metadata = _read_json(session.metadata_path)
|
|
_verify_metadata(metadata)
|
|
if metadata.get("status") in TERMINAL_STATES:
|
|
return metadata
|
|
metadata["status"] = "FAILED"
|
|
metadata["failed_at"] = iso_utc(utc_now())
|
|
metadata["error_code"] = error_code
|
|
metadata["error_status"] = int(error_status)
|
|
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
|
_atomic_write_json(session.metadata_path, metadata)
|
|
_safe_unlink(session.secret_ref)
|
|
_atomic_write_json(
|
|
session.metadata_path.parent / "audit-failed.json",
|
|
{
|
|
"event": "TASK_IDENTITY_OVERLAY_FAILED",
|
|
"overlay_id": session.overlay_id,
|
|
"task_id": session.binding.task_id,
|
|
"request_digest_sha256": session.request_digest_sha256,
|
|
"error_code": error_code,
|
|
"error_status": int(error_status),
|
|
"failed_at": metadata["failed_at"],
|
|
"secret_removed": True,
|
|
},
|
|
)
|
|
return metadata
|
|
|
|
def revoke(self, overlay_id: str, *, reason: str) -> dict[str, Any]:
|
|
if self.config.require_root_issue and os.geteuid() != 0:
|
|
raise IdentityOverlayError("OVERLAY_ROOT_ISSUER_REQUIRED", 403)
|
|
if not OVERLAY_ID_RE.fullmatch(overlay_id):
|
|
raise IdentityOverlayError("OVERLAY_ID_INVALID")
|
|
if not AUTH_ASSERTION_RE.fullmatch(reason):
|
|
raise IdentityOverlayError("OVERLAY_REVOCATION_REASON_INVALID")
|
|
with self._lock():
|
|
path = self.config.state_root / overlay_id / "metadata.json"
|
|
metadata = _read_json(path)
|
|
_verify_metadata(metadata)
|
|
if metadata.get("status") not in TERMINAL_STATES:
|
|
metadata["status"] = "REVOKED"
|
|
metadata["revoked_at"] = iso_utc(utc_now())
|
|
metadata["revocation_reason"] = reason
|
|
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
|
_atomic_write_json(path, metadata)
|
|
_safe_unlink(Path(str(metadata["secret_ref"])))
|
|
return metadata
|
|
|
|
def status(self, overlay_id: str) -> dict[str, Any]:
|
|
if not OVERLAY_ID_RE.fullmatch(overlay_id):
|
|
raise IdentityOverlayError("OVERLAY_ID_INVALID")
|
|
metadata = _read_json(
|
|
self.config.state_root / overlay_id / "metadata.json"
|
|
)
|
|
_verify_metadata(metadata)
|
|
return {
|
|
key: value
|
|
for key, value in metadata.items()
|
|
if key not in {"token_sha256"}
|
|
}
|
|
|
|
|
|
class TaskIdentityOverlayService:
|
|
"""Authenticate one overlay credential and invoke the existing broker."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: IdentityOverlayConfig,
|
|
provisioner: TaskWriterProvisioner | None = None,
|
|
) -> None:
|
|
self.config = config
|
|
self.store = TaskIdentityOverlayStore(config)
|
|
broker_state = config.broker_state_root or (
|
|
config.state_root / "owner-authorization-events"
|
|
)
|
|
self.broker = OwnerAuthorizationBroker(
|
|
OwnerAuthorizationBrokerConfig(
|
|
repository_identity=config.binding.repository,
|
|
state_root=broker_state,
|
|
allowed_owner_identities=frozenset({config.owner_identity}),
|
|
allowed_intake_scopes=frozenset({OVERLAY_SCOPE}),
|
|
allowed_credential_ids=frozenset(),
|
|
max_expiry_seconds=config.max_ttl_seconds,
|
|
),
|
|
provisioner=provisioner,
|
|
)
|
|
|
|
def provision(
|
|
self, request: Mapping[str, Any], bearer_token: str
|
|
) -> dict[str, Any]:
|
|
session = self.store.authenticate_and_lock(bearer_token, request)
|
|
principal = OwnerPrincipal(
|
|
identity=session.owner_identity,
|
|
role="owner",
|
|
authenticated_scope=OVERLAY_SCOPE,
|
|
token_scopes=frozenset({OVERLAY_SCOPE}),
|
|
credential_id=session.overlay_id,
|
|
)
|
|
try:
|
|
result = self.broker.authorize_and_provision(request, principal)
|
|
if result.get("status") != "READY_FOR_CODE_AUTHORING":
|
|
raise IdentityOverlayError("OVERLAY_PROVISIONER_NOT_READY", 409)
|
|
self.store.complete(session, result)
|
|
response = dict(result)
|
|
response.update(
|
|
{
|
|
"identity_overlay_id": session.overlay_id,
|
|
"identity_overlay_consumed": True,
|
|
"identity_overlay_secret_removed": True,
|
|
"identity_overlay_scope": OVERLAY_SCOPE,
|
|
}
|
|
)
|
|
return response
|
|
except (OwnerAuthorizationError, ProvisioningError) as exc:
|
|
self.store.fail(
|
|
session, error_code=exc.code, error_status=exc.status
|
|
)
|
|
raise
|
|
except IdentityOverlayError as exc:
|
|
self.store.fail(
|
|
session, error_code=exc.code, error_status=exc.status
|
|
)
|
|
raise
|
|
except Exception as exc:
|
|
self.store.fail(
|
|
session,
|
|
error_code="IDENTITY_OVERLAY_INTERNAL_ERROR",
|
|
error_status=500,
|
|
)
|
|
raise IdentityOverlayError(
|
|
"IDENTITY_OVERLAY_INTERNAL_ERROR", 500
|
|
) from exc
|