214 lines
8.4 KiB
Python
214 lines
8.4 KiB
Python
"""AIOS Secret Manager V2 reference-only Candidate adapter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Mapping, Protocol, Sequence
|
|
|
|
from .errors import CredentialUnavailable
|
|
from .models import CredentialRequest
|
|
|
|
|
|
PROVIDER_REFERENCES = {
|
|
"openclaw": "systemd-credential:openclaw-gateway-candidate",
|
|
"codex": "systemd-credential:codex-app-server-candidate",
|
|
"native_adapter_control": "systemd-credential:session-native-adapter-control-candidate",
|
|
"gitea": "systemd-credential:gitea-admin-api-candidate",
|
|
}
|
|
FORBIDDEN_RESPONSE_FIELDS = {
|
|
"api_key",
|
|
"token",
|
|
"password",
|
|
"cookie",
|
|
"private_key",
|
|
"credential_body",
|
|
"plaintext",
|
|
}
|
|
|
|
|
|
class SecretManagerTransport(Protocol):
|
|
def exchange(self, operation: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SecretProjectionReceipt:
|
|
task_id: str
|
|
credential_id: str
|
|
reference: str
|
|
provider: str
|
|
audience: str
|
|
consumer_service: str
|
|
state: str
|
|
expires_at: datetime
|
|
plaintext_secret_returned: bool = False
|
|
|
|
|
|
class SecretManagerV2:
|
|
"""Issue, project, inspect and revoke without receiving authentication material."""
|
|
|
|
def __init__(self, transport: SecretManagerTransport) -> None:
|
|
self.transport = transport
|
|
|
|
@staticmethod
|
|
def _assert_safe(value: Any) -> None:
|
|
if isinstance(value, Mapping):
|
|
if FORBIDDEN_RESPONSE_FIELDS.intersection(str(key).lower() for key in value):
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_PLAINTEXT_REJECTED",
|
|
"Secret Manager response included a forbidden plaintext field",
|
|
)
|
|
for item in value.values():
|
|
SecretManagerV2._assert_safe(item)
|
|
elif isinstance(value, (list, tuple)):
|
|
for item in value:
|
|
SecretManagerV2._assert_safe(item)
|
|
|
|
def _exchange(self, operation: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
try:
|
|
response = self.transport.exchange(operation, payload)
|
|
except CredentialUnavailable:
|
|
raise
|
|
except Exception as error:
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_UNAVAILABLE", "Secret Manager V2 operation failed"
|
|
) from error
|
|
if not isinstance(response, Mapping):
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_RESPONSE_INVALID", "Secret Manager V2 returned a non-object"
|
|
)
|
|
self._assert_safe(response)
|
|
if response.get("plaintext_secret_returned") is not False:
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_PLAINTEXT_REJECTED",
|
|
"Secret Manager did not attest plaintext_secret_returned=false",
|
|
)
|
|
return response
|
|
|
|
@staticmethod
|
|
def _payload(request: CredentialRequest) -> dict[str, Any]:
|
|
return {
|
|
"approval_plan_id": request.approval_plan_id,
|
|
"task_id": request.task_id,
|
|
"request_id": request.request_id,
|
|
"session_id": request.session_id,
|
|
"worker_id": request.worker_id,
|
|
"provider": request.provider,
|
|
"audience": request.audience,
|
|
"consumer_service": request.consumer_service,
|
|
"runtime_user": request.runtime_user,
|
|
"repository": request.binding.repository,
|
|
"branch": request.binding.branch,
|
|
"branch_oid": request.binding.base_oid,
|
|
"allowed_paths": list(request.binding.allowed_paths),
|
|
"operations": list(request.operations),
|
|
"not_before": request.not_before.astimezone(timezone.utc).isoformat(),
|
|
"expires_at": request.expires_at.astimezone(timezone.utc).isoformat(),
|
|
"nonce": request.nonce,
|
|
"ttl_seconds": request.ttl_seconds,
|
|
"max_uses": request.max_uses,
|
|
}
|
|
|
|
def issue(
|
|
self, request: CredentialRequest, now: datetime | None = None
|
|
) -> SecretProjectionReceipt:
|
|
now = now or datetime.now(timezone.utc)
|
|
if now < request.not_before or now >= request.expires_at:
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_APPROVAL_WINDOW_INVALID", "credential request is not currently valid"
|
|
)
|
|
expected_reference = PROVIDER_REFERENCES.get(request.provider)
|
|
if expected_reference is None:
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_PROVIDER_DENIED", "provider is not Candidate-approved"
|
|
)
|
|
response = self._exchange("issue", self._payload(request))
|
|
credential_id = response.get("credential_id")
|
|
if (
|
|
response.get("task_id") != request.task_id
|
|
or not isinstance(credential_id, str)
|
|
or not credential_id
|
|
or response.get("credential_reference") != expected_reference
|
|
or response.get("state") != "ISSUED"
|
|
):
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_BINDING_MISMATCH", "issued projection does not match the request"
|
|
)
|
|
expires_at = datetime.fromisoformat(str(response.get("expires_at")))
|
|
if expires_at > request.expires_at or expires_at <= now:
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_EXPIRY_INVALID", "issued projection expiry is outside approval"
|
|
)
|
|
return SecretProjectionReceipt(
|
|
task_id=request.task_id,
|
|
credential_id=credential_id,
|
|
reference=expected_reference,
|
|
provider=request.provider,
|
|
audience=request.audience,
|
|
consumer_service=request.consumer_service,
|
|
state="ISSUED",
|
|
expires_at=expires_at,
|
|
plaintext_secret_returned=False,
|
|
)
|
|
|
|
@staticmethod
|
|
def _receipt_payload(receipt: SecretProjectionReceipt) -> dict[str, Any]:
|
|
return {
|
|
"task_id": receipt.task_id,
|
|
"credential_id": receipt.credential_id,
|
|
"credential_reference": receipt.reference,
|
|
"provider": receipt.provider,
|
|
"audience": receipt.audience,
|
|
"consumer_service": receipt.consumer_service,
|
|
}
|
|
|
|
def _state_operation(
|
|
self, operation: str, receipt: SecretProjectionReceipt, expected_state: str
|
|
) -> Mapping[str, Any]:
|
|
response = self._exchange(operation, self._receipt_payload(receipt))
|
|
if (
|
|
response.get("task_id") != receipt.task_id
|
|
or response.get("credential_id") != receipt.credential_id
|
|
or response.get("state") != expected_state
|
|
):
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_STATE_MISMATCH", f"{operation} response is not task-bound"
|
|
)
|
|
return response
|
|
|
|
def project(self, receipt: SecretProjectionReceipt) -> None:
|
|
self._state_operation("project", receipt, "PROJECTED")
|
|
|
|
def status(self, receipt: SecretProjectionReceipt) -> str:
|
|
return str(self._state_operation("status", receipt, "PROJECTED")["state"])
|
|
|
|
def revoke(self, receipt: SecretProjectionReceipt) -> None:
|
|
self._state_operation("revoke", receipt, "REVOKED")
|
|
|
|
def verify_revocation(self, receipt: SecretProjectionReceipt) -> None:
|
|
response = self._state_operation("verify_revocation", receipt, "REVOKED")
|
|
if response.get("post_revocation_auth") != "REJECTED":
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_REVOCATION_UNVERIFIED",
|
|
"post-revocation authentication was not rejected",
|
|
)
|
|
|
|
def audit(self, receipt: SecretProjectionReceipt) -> Sequence[Mapping[str, Any]]:
|
|
response = self._exchange("audit", self._receipt_payload(receipt))
|
|
if (
|
|
response.get("task_id") != receipt.task_id
|
|
or response.get("credential_id") != receipt.credential_id
|
|
):
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_AUDIT_MISMATCH", "audit response is not task-bound"
|
|
)
|
|
events = response.get("events")
|
|
if not isinstance(events, list) or any(
|
|
not isinstance(event, Mapping) or event.get("secrets_included") is not False
|
|
for event in events
|
|
):
|
|
raise CredentialUnavailable(
|
|
"SECRET_MANAGER_AUDIT_INVALID", "audit events are absent or unsafe"
|
|
)
|
|
return events
|