|
| 1 | +"""Transport-bound workload identity and mailbox-transition HTTP routes.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import time |
| 6 | +from collections.abc import Awaitable, Callable, Mapping |
| 7 | +from datetime import UTC, datetime |
| 8 | + |
| 9 | +from pydantic import BaseModel, ConfigDict, Field |
| 10 | +from starlette.requests import Request |
| 11 | +from starlette.responses import JSONResponse, Response |
| 12 | +from starlette.routing import Route |
| 13 | + |
| 14 | +from agentnet.authorization.evidence import IssuanceAuthority, SignedAuthorityCommand |
| 15 | +from agentnet.core.app import CommunicationCore |
| 16 | +from agentnet.errors import AuthenticationError, ValidationError |
| 17 | +from agentnet.identity.actors import VerifiedActor |
| 18 | +from agentnet.identity.workload import ( |
| 19 | + AuthenticatedSPIFFETransport, |
| 20 | + WorkloadIdentity, |
| 21 | + WorkloadTransitionProof, |
| 22 | +) |
| 23 | +from agentnet.mailbox.service import ExpiryAuthorization |
| 24 | +from agentnet.protocol.models import DeliveryFact |
| 25 | +from agentnet.security.signatures import canonical_digest |
| 26 | + |
| 27 | + |
| 28 | +BodyAndActor = Callable[ |
| 29 | + [Request, CommunicationCore], |
| 30 | + Awaitable[tuple[bytes, VerifiedActor]], |
| 31 | +] |
| 32 | +DecisionIssuer = Callable[..., IssuanceAuthority] |
| 33 | + |
| 34 | + |
| 35 | +class WorkloadRegistrationBody(BaseModel): |
| 36 | + model_config = ConfigDict(extra="forbid") |
| 37 | + |
| 38 | + registration_id: str = Field(min_length=16, max_length=128) |
| 39 | + workload_id: str = Field(min_length=1, max_length=256) |
| 40 | + workload_role: str = Field(min_length=1, max_length=128) |
| 41 | + recipient_scope: str = Field(min_length=1, max_length=256) |
| 42 | + public_key_pem: str = Field(min_length=128, max_length=16_384) |
| 43 | + key_id: str = Field(min_length=32, max_length=128) |
| 44 | + credential_epoch: int = Field(ge=1) |
| 45 | + revocation_epoch: int = Field(ge=1) |
| 46 | + parent_event_id: str | None = Field(default=None, max_length=256) |
| 47 | + task_grant_id: str | None = Field(default=None, max_length=256) |
| 48 | + issued_at: int = Field(gt=0) |
| 49 | + expires_at: int = Field(gt=0) |
| 50 | + possession_signature: str = Field(min_length=1, max_length=2_048) |
| 51 | + command: SignedAuthorityCommand |
| 52 | + |
| 53 | + |
| 54 | +class WorkloadRenewalBody(BaseModel): |
| 55 | + model_config = ConfigDict(extra="forbid") |
| 56 | + |
| 57 | + expected_credential_epoch: int = Field(ge=1) |
| 58 | + public_key_pem: str = Field(min_length=128, max_length=16_384) |
| 59 | + key_id: str = Field(min_length=32, max_length=128) |
| 60 | + issued_at: int = Field(gt=0) |
| 61 | + expires_at: int = Field(gt=0) |
| 62 | + possession_signature: str = Field(min_length=1, max_length=2_048) |
| 63 | + command: SignedAuthorityCommand |
| 64 | + |
| 65 | + |
| 66 | +class WorkloadRevocationBody(BaseModel): |
| 67 | + model_config = ConfigDict(extra="forbid") |
| 68 | + |
| 69 | + expected_credential_epoch: int = Field(ge=1) |
| 70 | + expected_revocation_epoch: int = Field(ge=1) |
| 71 | + reason: str = Field(min_length=1, max_length=512) |
| 72 | + command: SignedAuthorityCommand |
| 73 | + |
| 74 | + |
| 75 | +class MailboxExpiryDispatchBody(BaseModel): |
| 76 | + model_config = ConfigDict(extra="forbid") |
| 77 | + |
| 78 | + authoritative_clock: int = Field(gt=0) |
| 79 | + proofs: tuple[WorkloadTransitionProof, ...] = Field(min_length=1, max_length=1_000) |
| 80 | + |
| 81 | + |
| 82 | +def _workload_transport( |
| 83 | + request: Request, |
| 84 | + core: CommunicationCore, |
| 85 | +) -> tuple[WorkloadIdentity, int, int, str]: |
| 86 | + raw = request.scope.get("agentnet.workload_transport") |
| 87 | + if not isinstance(raw, AuthenticatedSPIFFETransport): |
| 88 | + raise AuthenticationError("verified workload transport context is required") |
| 89 | + identity = core.workloads.spiffe.resolve(raw) |
| 90 | + return ( |
| 91 | + identity, |
| 92 | + raw.facts.process_id, |
| 93 | + raw.facts.process_start_time, |
| 94 | + raw.facts.session_id, |
| 95 | + ) |
| 96 | + |
| 97 | + |
| 98 | +def create_workload_admin_routes( |
| 99 | + core: CommunicationCore, |
| 100 | + body_and_actor: BodyAndActor, |
| 101 | + issue_decision: DecisionIssuer, |
| 102 | + response_headers: Mapping[str, str], |
| 103 | +) -> list[Route]: |
| 104 | + """Mount workload lifecycle and mailbox-expiry routes.""" |
| 105 | + |
| 106 | + async def register_workload(request: Request) -> Response: |
| 107 | + body, actor = await body_and_actor(request, core) |
| 108 | + parsed = WorkloadRegistrationBody.model_validate_json(body) |
| 109 | + identity, process_id, process_start_time, session_id = _workload_transport(request, core) |
| 110 | + mutation = core.workloads.registration_request( |
| 111 | + registration_id=parsed.registration_id, |
| 112 | + domain_id=core.config.domain_id, |
| 113 | + workload_id=parsed.workload_id, |
| 114 | + workload_role=parsed.workload_role, |
| 115 | + recipient_scope=parsed.recipient_scope, |
| 116 | + process_id=process_id, |
| 117 | + process_start_time=process_start_time, |
| 118 | + session_id=session_id, |
| 119 | + identity=identity, |
| 120 | + public_key_pem=parsed.public_key_pem, |
| 121 | + key_id=parsed.key_id, |
| 122 | + credential_epoch=parsed.credential_epoch, |
| 123 | + revocation_epoch=parsed.revocation_epoch, |
| 124 | + parent_event_id=parsed.parent_event_id, |
| 125 | + task_grant_id=parsed.task_grant_id, |
| 126 | + issued_at=parsed.issued_at, |
| 127 | + expires_at=parsed.expires_at, |
| 128 | + ) |
| 129 | + authority = issue_decision( |
| 130 | + core, |
| 131 | + actor=actor, |
| 132 | + action="identity.workload.register", |
| 133 | + resource=f"workload:{parsed.registration_id}", |
| 134 | + request_digest=canonical_digest(mutation), |
| 135 | + ) |
| 136 | + result = core.workloads.register( |
| 137 | + authority=authority, |
| 138 | + command=parsed.command, |
| 139 | + registration_id=parsed.registration_id, |
| 140 | + domain_id=core.config.domain_id, |
| 141 | + workload_id=parsed.workload_id, |
| 142 | + workload_role=parsed.workload_role, |
| 143 | + recipient_scope=parsed.recipient_scope, |
| 144 | + process_id=process_id, |
| 145 | + process_start_time=process_start_time, |
| 146 | + session_id=session_id, |
| 147 | + identity=identity, |
| 148 | + public_key_pem=parsed.public_key_pem, |
| 149 | + key_id=parsed.key_id, |
| 150 | + credential_epoch=parsed.credential_epoch, |
| 151 | + revocation_epoch=parsed.revocation_epoch, |
| 152 | + parent_event_id=parsed.parent_event_id, |
| 153 | + task_grant_id=parsed.task_grant_id, |
| 154 | + issued_at=parsed.issued_at, |
| 155 | + expires_at=parsed.expires_at, |
| 156 | + possession_signature=parsed.possession_signature, |
| 157 | + ) |
| 158 | + return JSONResponse( |
| 159 | + result.model_dump(mode="json"), |
| 160 | + status_code=201, |
| 161 | + headers=response_headers, |
| 162 | + ) |
| 163 | + |
| 164 | + async def renew_workload(request: Request) -> Response: |
| 165 | + body, actor = await body_and_actor(request, core) |
| 166 | + parsed = WorkloadRenewalBody.model_validate_json(body) |
| 167 | + identity, process_id, process_start_time, session_id = _workload_transport(request, core) |
| 168 | + registration_id = request.path_params["registration_id"] |
| 169 | + authority = issue_decision( |
| 170 | + core, |
| 171 | + actor=actor, |
| 172 | + action="identity.workload.renew", |
| 173 | + resource=f"workload:{registration_id}", |
| 174 | + request_digest=parsed.command.request_digest, |
| 175 | + ) |
| 176 | + current = core.store.fetch_one( |
| 177 | + "SELECT revocation_epoch FROM workload_registrations WHERE registration_id=?", |
| 178 | + (registration_id,), |
| 179 | + ) |
| 180 | + if current is None: |
| 181 | + raise AuthenticationError("workload registration is unavailable") |
| 182 | + mutation = core.workloads.renewal_request( |
| 183 | + registration_id=registration_id, |
| 184 | + expected_credential_epoch=parsed.expected_credential_epoch, |
| 185 | + credential_epoch=parsed.expected_credential_epoch + 1, |
| 186 | + revocation_epoch=int(current["revocation_epoch"]), |
| 187 | + process_id=process_id, |
| 188 | + process_start_time=process_start_time, |
| 189 | + session_id=session_id, |
| 190 | + identity=identity, |
| 191 | + public_key_pem=parsed.public_key_pem, |
| 192 | + key_id=parsed.key_id, |
| 193 | + issued_at=parsed.issued_at, |
| 194 | + expires_at=parsed.expires_at, |
| 195 | + ) |
| 196 | + if parsed.command.request_digest != canonical_digest(mutation): |
| 197 | + raise AuthenticationError("workload renewal command request binding mismatch") |
| 198 | + result = core.workloads.renew( |
| 199 | + authority=authority, |
| 200 | + command=parsed.command, |
| 201 | + registration_id=registration_id, |
| 202 | + expected_credential_epoch=parsed.expected_credential_epoch, |
| 203 | + process_id=process_id, |
| 204 | + process_start_time=process_start_time, |
| 205 | + session_id=session_id, |
| 206 | + identity=identity, |
| 207 | + public_key_pem=parsed.public_key_pem, |
| 208 | + key_id=parsed.key_id, |
| 209 | + issued_at=parsed.issued_at, |
| 210 | + expires_at=parsed.expires_at, |
| 211 | + possession_signature=parsed.possession_signature, |
| 212 | + ) |
| 213 | + return JSONResponse(result.model_dump(mode="json"), headers=response_headers) |
| 214 | + |
| 215 | + async def revoke_workload(request: Request) -> Response: |
| 216 | + body, actor = await body_and_actor(request, core) |
| 217 | + parsed = WorkloadRevocationBody.model_validate_json(body) |
| 218 | + registration_id = request.path_params["registration_id"] |
| 219 | + mutation = core.workloads.revocation_request( |
| 220 | + registration_id=registration_id, |
| 221 | + expected_credential_epoch=parsed.expected_credential_epoch, |
| 222 | + expected_revocation_epoch=parsed.expected_revocation_epoch, |
| 223 | + reason=parsed.reason, |
| 224 | + ) |
| 225 | + authority = issue_decision( |
| 226 | + core, |
| 227 | + actor=actor, |
| 228 | + action="identity.workload.revoke", |
| 229 | + resource=f"workload:{registration_id}", |
| 230 | + request_digest=canonical_digest(mutation), |
| 231 | + ) |
| 232 | + result = core.workloads.revoke( |
| 233 | + authority=authority, |
| 234 | + command=parsed.command, |
| 235 | + registration_id=registration_id, |
| 236 | + expected_credential_epoch=parsed.expected_credential_epoch, |
| 237 | + expected_revocation_epoch=parsed.expected_revocation_epoch, |
| 238 | + reason=parsed.reason, |
| 239 | + ) |
| 240 | + return JSONResponse(result, headers=response_headers) |
| 241 | + |
| 242 | + async def expire_mailbox_due(request: Request) -> Response: |
| 243 | + body = await request.body() |
| 244 | + if len(body) > core.config.max_request_bytes: |
| 245 | + raise ValidationError("mailbox expiry request exceeds the configured limit") |
| 246 | + parsed = MailboxExpiryDispatchBody.model_validate_json(body) |
| 247 | + server_now = int(time.time()) |
| 248 | + if parsed.authoritative_clock > server_now or parsed.authoritative_clock < server_now - 60: |
| 249 | + raise AuthenticationError("mailbox expiry authoritative clock is stale or future-dated") |
| 250 | + _identity, process_id, process_start_time, session_id = _workload_transport(request, core) |
| 251 | + authorizations: dict[tuple[str, str], ExpiryAuthorization] = {} |
| 252 | + registration_id: str | None = None |
| 253 | + for proof in parsed.proofs: |
| 254 | + if proof.proposed_fact is not DeliveryFact.EXPIRED: |
| 255 | + raise AuthenticationError("mailbox expiry proof proposes the wrong delivery fact") |
| 256 | + if registration_id is None: |
| 257 | + registration_id = proof.registration_id |
| 258 | + elif registration_id != proof.registration_id: |
| 259 | + raise AuthenticationError("one transport cannot combine multiple workload registrations") |
| 260 | + actor = core.workloads.resolve( |
| 261 | + transport=request.scope["agentnet.workload_transport"], |
| 262 | + registration_id=proof.registration_id, |
| 263 | + process_id=process_id, |
| 264 | + process_start_time=process_start_time, |
| 265 | + session_id=session_id, |
| 266 | + now=parsed.authoritative_clock, |
| 267 | + ) |
| 268 | + key = (proof.event_id, proof.recipient_id) |
| 269 | + if key in authorizations: |
| 270 | + raise ValidationError("mailbox expiry contains a duplicate event/recipient proof") |
| 271 | + authorizations[key] = ExpiryAuthorization(proof=proof, actor=actor) |
| 272 | + core.quotas.consume( |
| 273 | + scope=f"workload-expiry:{registration_id}", |
| 274 | + metric="expiry_transitions", |
| 275 | + amount=len(authorizations), |
| 276 | + limit=1_000, |
| 277 | + ) |
| 278 | + count = core.mailboxes.expire_due( |
| 279 | + authoritative_now=datetime.fromtimestamp(parsed.authoritative_clock, UTC), |
| 280 | + authorizations=authorizations, |
| 281 | + ) |
| 282 | + return JSONResponse({"expired": count}, headers=response_headers) |
| 283 | + |
| 284 | + return [ |
| 285 | + Route("/v1/admin/workloads", register_workload, methods=["POST"]), |
| 286 | + Route( |
| 287 | + "/v1/admin/workloads/{registration_id}/renew", |
| 288 | + renew_workload, |
| 289 | + methods=["POST"], |
| 290 | + ), |
| 291 | + Route( |
| 292 | + "/v1/admin/workloads/{registration_id}/revoke", |
| 293 | + revoke_workload, |
| 294 | + methods=["POST"], |
| 295 | + ), |
| 296 | + Route("/v1/workloads/mailbox/expire-due", expire_mailbox_due, methods=["POST"]), |
| 297 | + ] |
| 298 | + |
| 299 | + |
| 300 | +__all__ = ["create_workload_admin_routes"] |
0 commit comments