Skip to content

Commit 94c4983

Browse files
committed
refactor: isolate workload HTTP routes
1 parent aa88f52 commit 94c4983

2 files changed

Lines changed: 311 additions & 255 deletions

File tree

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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

Comments
 (0)