Files
aios/active-runtime/app/task_identity_overlay.py
T

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