From bf631671dc71097f85840d63cd7972218860989f Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Sat, 31 Oct 2026 11:15:37 -0700 Subject: [PATCH] feat: support service documents and repeated enrollment --- .conformance/capability-manifest.json | 2 +- README.md | 83 +++ scripts/conformance_adapter.py | 336 ++++++++++- .../agent/__init__.py | 3 + src/agent_enrollment_protocol/agent/client.py | 26 +- .../agent/documents.py | 70 +++ src/agent_enrollment_protocol/agent/types.py | 7 + .../core/__init__.py | 16 + .../core/constants.py | 17 + src/agent_enrollment_protocol/core/models.py | 149 ++++- .../core/validation.py | 18 +- .../service/service.py | 164 +++++- .../service/types.py | 13 +- tests/test_agent.py | 9 +- tests/test_conformance_falsifiability.py | 10 +- tests/test_document_reader.py | 182 ++++++ tests/test_service.py | 92 +++ tests/test_service_boundaries.py | 4 +- tests/test_service_documents.py | 547 ++++++++++++++++++ 19 files changed, 1705 insertions(+), 43 deletions(-) create mode 100644 src/agent_enrollment_protocol/agent/documents.py create mode 100644 tests/test_document_reader.py create mode 100644 tests/test_service_documents.py diff --git a/.conformance/capability-manifest.json b/.conformance/capability-manifest.json index 2f9ecec..f0a7e10 100644 --- a/.conformance/capability-manifest.json +++ b/.conformance/capability-manifest.json @@ -16,7 +16,7 @@ "role": "platform" }, { - "profiles": ["core-http", "claims", "api-key", "basic", "oauth-bearer"], + "profiles": ["core-http", "claims", "api-key", "basic", "oauth-bearer", "service-documents"], "role": "service" } ], diff --git a/README.md b/README.md index 653e9b8..c3ece66 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,89 @@ the identity owner's credentials. Authorize that relationship explicitly rather every authenticated caller to verify every identity. Signing and identity management retain their owner checks. +## Enrollment policy decisions + +Policy callbacks receive the request and current time. They consult their own authoritative storage when needed; the SDK does not supply an existing-enrollment hint. + +`EnrollmentDecision.owner_action_required`, `requirements_pending`, and `verification_pending` are optional. On initial enrollment, `None` defaults to false or an empty tuple. On repeated enrollment, `None` preserves the stored field; explicit booleans and tuples replace it, including `False` and `()` to clear it. Stored enrollment fields remain concrete values. + +Repeated Enroll merges supplied claims without replacing the enrollment identity, creation time, lifecycle state, or issued credentials. Return `EnrollmentDecision(update_existing=False)` to leave the record unchanged, or a rejected decision to reject the request without mutating the record. Same-key retries replay their result without repeating policy effects. + +## Service Documents + +Services advertise document descriptors through `ServiceOptions.service_documents` and include +`AEP_SERVICE_DOCUMENTS_EXTENSION` in `ServiceOptions.extensions`. Import the constant and models +from `agent_enrollment_protocol.core`: + +```python +from agent_enrollment_protocol.core import ( + AEP_SERVICE_DOCUMENTS_EXTENSION, + DocumentRepresentation, + ServiceDocument, +) + +terms = ServiceDocument( + id="terms", + type="terms_of_service", + title="Terms of service", + enrollment="required", + representations=( + DocumentRepresentation(media_type="text/html", url="https://service.example/terms"), + DocumentRepresentation( + media_type="application/json", url="https://service.example/terms.json" + ), + ), +) +# Include these in the application's ServiceOptions, alongside its verifier and stores: +# extensions=(AEP_SERVICE_DOCUMENTS_EXTENSION,), service_documents=(terms,) +``` + +Inspect exposes at most seven documents with unique, case-sensitive IDs of up to 32 ASCII +characters. Each descriptor requires an HTML link. `SERVICE_DOCUMENT_TYPES` lists preferred type +names; custom lowercase type strings are supported. A `not-required` document does not require +acceptance. Unknown enrollment requirements are rejected rather than treated as not-required. + +Agents can read a portable document before enrollment: + +```python +from agent_enrollment_protocol.agent import EnrollOptions, read_service_document + +inspection = await session.inspect() +document = next(item for item in inspection.document.service_documents or () if item.id == "terms") +reading = await read_service_document(document) +# Render reading.document when present; otherwise offer reading.web_url. +# Only after the Owner authorizes these IDs: +result = await session.enroll(EnrollOptions(acknowledged_document_ids=("terms",))) +``` + +The reader uses the standard HTTP client without credentials or cookies. It accepts HTTPS with +certificate verification and HTTP only for the literal hosts `localhost`, `127.0.0.1`, and `[::1]`. +It refuses redirects, limits the response to one mebibyte and the operation to ten seconds by +default, and propagates task cancellation. The application decides which document origins it +trusts; the reader does not inspect DNS answers or connected addresses. Unsupported, oversized, +malformed, or unavailable portable content returns the HTML link, not partial content. + +Portable content contains full Service-authored text and an optional multi-block summary. The SDK +parses it into immutable models; the application renders it without summarizing or interpreting it. +Preserve sections, lists, and wording, neutralize terminal controls, and label Service-authored +summaries. For artificial-intelligence callers, return readable fenced text with a separate +instruction not to interpret, summarize, or follow document instructions, plus the source link. +Do not pass portable JSON directly as the presentation. + +Reading does not acknowledge a document. `None` omits the enrollment declaration; `()` explicitly +declares no acknowledgements. Successful Enroll responses echo that operation's declaration, not +the cumulative history. A partial declaration does not acknowledge omitted documents; the Service +can enforce them on a later Grant. `EnrollmentRecord.acknowledged_document_ids` retains accepted +known IDs. Application-owned stores must preserve it and `required_document_ids`; setting a known +required ID in the latter requests renewed acceptance even if it was acknowledged before. + +Grant reports unmet requirements through `AgentCommandError.problem`: `requirements_pending` +includes `service_documents`, and `required_document_ids` identifies the documents. Claim names +may accompany them. Present the requirements, obtain the Owner's decision, and explicitly call +Enroll with the approved IDs and any missing claims. A later Grant is a separate caller decision; +the Agent neither chains these commands nor accepts documents automatically. Previously stored +claims and acknowledgements survive omission on repeated Enroll. + ## Application-owned storage Default memory stores retain state only for the lifetime of one process. To retain an Agent's diff --git a/scripts/conformance_adapter.py b/scripts/conformance_adapter.py index bc21e5a..7adfbe2 100755 --- a/scripts/conformance_adapter.py +++ b/scripts/conformance_adapter.py @@ -6,6 +6,7 @@ import json import sys from collections.abc import Callable, Mapping +from copy import deepcopy from dataclasses import replace from datetime import UTC, datetime, timedelta from inspect import Parameter, signature @@ -41,6 +42,7 @@ from agent_enrollment_protocol.core import ( AEP_MEDIA_TYPE, AEP_PROBLEM_MEDIA_TYPE, + AEP_SERVICE_DOCUMENTS_EXTENSION, AepAuthorizationError, AepValidationError, AgentStatus, @@ -69,10 +71,12 @@ PlatformSignCompleted, PlatformSignRequest, PlatformVerificationRequest, + PortableServiceDocument, ProblemDetails, ProtectedResourceAuthorization, RevokeRequest, RevokeResponse, + ServiceDocument, SigningAlgorithm, StatusResponse, VerifyClientAssertionOptions, @@ -2438,11 +2442,15 @@ async def exercise_service_case(identifier: str, case: JsonObject) -> bool: await store.save(record) body = json.dumps(source["request"], separators=(",", ":")).encode() - class MustNotRunPolicy: + calls = 0 + + class UnchangedPolicy: async def decide(self, request: Any, current_time: datetime) -> EnrollmentDecision: - raise ValueError("Repeated enrollment called application policy") + nonlocal calls + calls += 1 + return EnrollmentDecision(update_existing=False) - enroll_result = await service(store, enrollment_policy=MustNotRunPolicy()).enroll( + enroll_result = await service(store, enrollment_policy=UnchangedPolicy()).enroll( body, command_options( AssertionOperation.ENROLL, @@ -2457,7 +2465,7 @@ async def decide(self, request: Any, current_time: datetime) -> EnrollmentDecisi and enroll_result.body.status.value == expected["response"]["body"]["status"] and restored == record and expected["record_replaced"] is False - and expected["policy_evaluated"] is False + and expected["policy_evaluated"] is (calls == 1) ) if identifier == "operation-substitution-rejected": return await service_rejects_operation_substitution(case["expected"]) @@ -2935,7 +2943,327 @@ async def exercise_service_boundaries( raise ValueError(f"No Service boundary mapping: {identifier}") +def document_schema_valid(schema: str, value: JsonObject) -> bool: + models = { + "enroll-request.schema.json": EnrollRequest, + "enroll-response.schema.json": EnrollResponse, + "problem.schema.json": ProblemDetails, + "portable-service-document.schema.json": PortableServiceDocument, + "inspect-document.schema.json": InspectDocument, + "service-documents.schema.json": InspectDocument, + } + if schema == "service-documents.schema.json": + value = service().inspect_document.to_wire() | value + return valid(models[schema], value) + + +def document_structural_cases(source: JsonObject) -> list[JsonObject]: + cases = [] + for item in source["cases"]: + descriptor = deepcopy(source["descriptor"]) + document = deepcopy(source["document"]) + kind = item["kind"] + schema = "portable-service-document.schema.json" + value = document + if kind in {"catalog", "duplicate-ids", "duplicate-media", "title", "url"}: + schema = "service-documents.schema.json" + descriptors = [descriptor] + if kind == "catalog": + descriptors = [descriptor | {"id": f"doc-{i}"} for i in range(item["count"])] + elif kind == "duplicate-ids": + descriptors *= 2 + elif kind == "duplicate-media": + descriptor["representations"] *= 2 + elif kind == "title": + descriptor["title"] = "x" * item["length"] + elif kind == "url": + prefix = "https://example.com/" + descriptor["representations"][0]["url"] = prefix + "x" * ( + item["length"] - len(prefix) + ) + value = { + "extensions": {"supported": [AEP_SERVICE_DOCUMENTS_EXTENSION]}, + "service_documents": descriptors, + } + elif kind == "blocks": + document["content"] = document["content"] * item["content_count"] + document["summary"] = [source["document"]["content"][0]] * item["summary_count"] + elif kind == "sections": + block = document["content"][0] + for _ in range(item["depth"]): + block = {"type": "section", "title": "Section", "blocks": [block]} + document["content"] = [block] + elif kind == "text": + document["content"][0]["text"] = "x" * item["length"] + elif kind == "list": + document["content"] = [{"type": "bulleted_list", "items": ["x"] * item["count"]}] + else: + raise ValueError(f"Unsupported structural case: {kind}") + cases.append({"name": item["name"], "schema": schema, "value": value}) + return cases + + +def document_descriptors(ids: list[str]) -> tuple[ServiceDocument, ...]: + return tuple( + ServiceDocument.model_validate( + { + "id": identifier, + "type": "terms_of_service", + "title": identifier, + "enrollment": "required", + "representations": [ + {"media_type": "text/html", "url": f"https://example.com/{identifier}"} + ], + } + ) + for identifier in ids + ) + + +async def document_grant_guidance(source: JsonObject, expected: JsonObject) -> bool: + store = MemoryEnrollmentStore() + handler = GrantHandler() + instance = service( + store, + claims=InspectClaims(required=tuple(source["missing_claims"])), + extensions=(AEP_SERVICE_DOCUMENTS_EXTENSION,), + service_documents=document_descriptors(source["required_document_ids"]), + grant_types=(GrantTypeDefinition(grant_type="api-key", handler=handler),), + ) + unknown = await invoke_service( + instance, "grant", command_options(AssertionOperation.GRANT, "unknown", "unknown") + ) + if unknown.status != expected["unrecognized"]["http_status"] or unknown.problem is None: + return False + disclosed = any( + getattr(unknown.problem, name) is not None + for name in ("requirements_pending", "required_document_ids", "owner_action_required") + ) + if ( + unknown.problem.code != expected["unrecognized"]["code"] + or disclosed != expected["unrecognized"]["requirements_disclosed"] + ): + return False + for request, outcome in zip( + source["enroll_requests"], expected["enroll_requests"], strict=True + ): + fields = {key: value for key, value in request.items() if key not in {"name", "agent_did"}} + result = await invoke_service( + instance, + "enroll", + command_options(AssertionOperation.ENROLL, request["name"], request["name"]), + **fields, + ) + if result.status != outcome["http_status"]: + return False + if "code" in outcome and (result.problem is None or result.problem.code != outcome["code"]): + return False + if "acknowledged_document_ids" in outcome: + if ( + result.body is None + or list(result.body.acknowledged_document_ids) + != outcome["acknowledged_document_ids"] + ): + return False + record = await store.find(AGENT_DID) + if ( + record is None + or bool(record.acknowledged_document_ids) != outcome["missing_documents_accepted"] + ): + return False + record = await store.find(AGENT_DID) + if record is None: + return False + await store.save(replace(record, claims=None)) + result = await invoke_service( + instance, "grant", command_options(AssertionOperation.GRANT, "requirements", "requirements") + ) + return ( + result.status == expected["grant_status"] + and (handler.calls > 0) == expected["credential_issued"] + and result.problem is not None + and all( + result.problem.to_wire().get(key) == value + for key, value in source["problem"].items() + if key != "title" + ) + ) + + +async def document_enrollment_lifecycle(source: JsonObject, expected: JsonObject) -> bool: + current = [NOW] + store = MemoryEnrollmentStore() + credentials = MemoryServiceCredentialStore() + calls = 0 + + class Policy: + async def decide( + self, request: EnrollRequest, current_time: datetime + ) -> EnrollmentDecision: + nonlocal calls + calls += 1 + return EnrollmentDecision() + + async def issue(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResponse: + return ApiKeyGrantResponse( + credential_id=source["existing"]["credential_ids"][0], + expires_at=(NOW + timedelta(hours=1)).isoformat(), + api_key="document-test-key", + header="X-Service-Key", + ) + + options = dict( + enrollment_policy=Policy(), + clock=lambda: current[0], + claims=InspectClaims(optional=tuple(source["existing"]["claims"])), + authentication_methods=("api-key",), + grant_types=( + stored_api_key_grant_type( + StoredCredentialGrantTypeOptions(issue=issue, store=credentials) + ), + ), + ) + initial = service(store, **options) + await invoke_service( + initial, + "enroll", + command_options(AssertionOperation.ENROLL, "initial", "initial"), + claims=source["existing"]["claims"], + ) + original = await store.find(AGENT_DID) + if original is None: + return False + original = replace( + original, + enrollment_id=source["existing"]["enrollment_id"], + created_at=datetime.fromisoformat(source["existing"]["created"]), + since=datetime.fromisoformat(source["existing"]["since"]), + acknowledged_document_ids=tuple(source["existing"]["acknowledged_document_ids"]), + ) + await store.save(original) + issued = await invoke_service( + initial, "grant", command_options(AssertionOperation.GRANT, "issue", "issue") + ) + if issued.status != 200 or [issued.body["credential_id"]] != expected["credential_ids"]: + return False + instance = service( + store, + extensions=(AEP_SERVICE_DOCUMENTS_EXTENSION,), + service_documents=document_descriptors(source["current_required_ids"]), + **options, + ) + for operation, outcome in zip(source["operations"], expected["operations"], strict=True): + previous = await store.find(AGENT_DID) + if previous is None: + return False + if "mark_required" in operation: + await store.save( + replace(previous, required_document_ids=tuple(operation["mark_required"])) + ) + before_calls = calls + fields = { + key: value + for key, value in operation.items() + if key in {"claims", "acknowledged_document_ids"} + } + result = await invoke_service( + instance, + "enroll", + command_options(AssertionOperation.ENROLL, operation["name"], operation["key"]), + **fields, + ) + record = await store.find(AGENT_DID) + if record is None or result.status != outcome["http_status"]: + return False + if "code" in outcome and (result.problem is None or result.problem.code != outcome["code"]): + return False + if "acknowledged_document_ids" in outcome and ( + result.body is None + or list(result.body.acknowledged_document_ids) != outcome["acknowledged_document_ids"] + ): + return False + if ( + "stored_ids" in outcome + and list(record.acknowledged_document_ids) != outcome["stored_ids"] + ): + return False + if ( + "policy_effects_repeated" in outcome + and (calls > before_calls) != outcome["policy_effects_repeated"] + ): + return False + if "mutated" in outcome and (record != previous) != outcome["mutated"]: + return False + if ( + "requirement_satisfied" in outcome + and (not record.required_document_ids) != outcome["requirement_satisfied"] + ): + return False + if ( + (record.enrollment_id == original.enrollment_id) != expected["identity_preserved"] + or (record.created_at == original.created_at) != expected["creation_preserved"] + or (record.since == original.since) != expected["unchanged_state_timestamp_preserved"] + or record.claims.to_wire() != expected["claims"] + ): + return False + if operation["name"] == "update": + grant = await invoke_service( + instance, + "grant", + command_options(AssertionOperation.GRANT, "after-update", "after-update"), + ) + if ( + grant.problem is None + or list(grant.problem.required_document_ids) + != expected["outstanding_ids_after_update"] + ): + return False + for state in source["lifecycle_states"]: + await store.save(replace(record, status=AgentStatus(state))) + result = await invoke_service( + instance, + "enroll", + command_options(AssertionOperation.ENROLL, state, state), + acknowledged_document_ids=[], + ) + after = await store.find(AGENT_DID) + if ( + after is None + or (after.status.value == state) != expected["unrelated_lifecycle_states_preserved"] + ): + return False + await store.save(record) + authentication = await instance.authenticate_protected_resource( + ProtectedResourceRequest( + method="GET", + url=f"{AGENT_ORIGIN}/private", + headers={"X-Service-Key": "document-test-key"}, + ) + ) + return authentication.authenticated + + +def evaluate_documents(identifier: str, case: JsonObject) -> bool: + source, expected = case["input"], case["expected"] + if identifier in {"wire-shapes", "structural-limits"}: + cases = ( + source["cases"] if identifier == "wire-shapes" else document_structural_cases(source) + ) + return all( + item["name"] == outcome["name"] + and document_schema_valid(item["schema"], item["value"]) == outcome["valid"] + for item, outcome in zip(cases, expected["cases"], strict=True) + ) + if identifier == "grant-guidance": + return asyncio.run(document_grant_guidance(source, expected)) + if identifier == "enrollment-lifecycle": + return asyncio.run(document_enrollment_lifecycle(source, expected)) + raise ValueError(f"Unsupported Service Documents vector: {identifier}") + + def evaluate_generic(role: str, category: str, identifier: str, case: JsonObject) -> bool: + if category == "service-documents": + return evaluate_documents(identifier, case) source = case["input"] expected = case["expected"] if role == "service" and identifier in { diff --git a/src/agent_enrollment_protocol/agent/__init__.py b/src/agent_enrollment_protocol/agent/__init__.py index 67c00c8..0157dd4 100644 --- a/src/agent_enrollment_protocol/agent/__init__.py +++ b/src/agent_enrollment_protocol/agent/__init__.py @@ -1,6 +1,7 @@ """Agent-side enrollment, credential, and authentication workflows.""" from .client import Agent, AgentOptions, ServiceSession +from .documents import DocumentReadResult, read_service_document from .platform_provider import ( MemoryPlatformDiscoveryCache, PlatformAuthenticationHeaders, @@ -64,6 +65,7 @@ "CredentialRecord", "CredentialStore", "CredentialUnavailableError", + "DocumentReadResult", "EnrollOptions", "EnrollmentStateError", "ExistingIdentityProvider", @@ -100,4 +102,5 @@ "ServiceSession", "WaitOptions", "fetch_protected_resource", + "read_service_document", ] diff --git a/src/agent_enrollment_protocol/agent/client.py b/src/agent_enrollment_protocol/agent/client.py index 57c4153..eb1674b 100644 --- a/src/agent_enrollment_protocol/agent/client.py +++ b/src/agent_enrollment_protocol/agent/client.py @@ -15,6 +15,7 @@ AEP_AUTHENTICATION_METHOD_JWT, AEP_MEDIA_TYPE, AEP_PROBLEM_MEDIA_TYPE, + AEP_SERVICE_DOCUMENTS_EXTENSION, AEP_WELL_KNOWN_PATH, AepAssertionError, AepValidationError, @@ -42,7 +43,6 @@ decode_jwt_unverified, did_web_document_url, media_type_essence, - missing_required_claim_names, parse_json_model, render_authorization, require_service_origin_binding, @@ -63,7 +63,6 @@ AssertionSigner, AuthenticationOptions, BuiltInCredential, - ClaimRequirementsError, CommandResult, CredentialRecord, CredentialStore, @@ -295,14 +294,12 @@ async def identity(self) -> ServiceIdentity: async def enroll(self, options: EnrollOptions | None = None) -> CommandResult[EnrollResponse]: options = options or EnrollOptions() inspection, identity, signer = await self._command_context(Command.ENROLL) - required = ( - inspection.document.claims.required - if inspection.document.claims and inspection.document.claims.required - else () - ) - missing = missing_required_claim_names(required, options.claims) - if missing: - raise ClaimRequirementsError(missing) + if options.acknowledged_document_ids is not None and ( + inspection.document.extensions is None + or AEP_SERVICE_DOCUMENTS_EXTENSION + not in (inspection.document.extensions.supported or ()) + ): + raise ValueError("Service does not advertise the Service Documents extension") key = await self._idempotency_key(inspection, Command.ENROLL.value, options.idempotency_key) request_data: dict[str, object] = { "agent_did": identity.agent_did, @@ -310,6 +307,8 @@ async def enroll(self, options: EnrollOptions | None = None) -> CommandResult[En } if options.claims is not None: request_data["claims"] = options.claims + if options.acknowledged_document_ids is not None: + request_data["acknowledged_document_ids"] = options.acknowledged_document_ids request = EnrollRequest.model_validate(request_data) result = await self._execute( inspection, @@ -321,6 +320,13 @@ async def enroll(self, options: EnrollOptions | None = None) -> CommandResult[En key, _parse_enroll_response, ) + if ( + result.body.status is not AgentStatus.REJECTED + and result.body.acknowledged_document_ids != options.acknowledged_document_ids + ): + raise AgentCommandError( + result.status, "AEP document acknowledgement does not match the request" + ) self._authoritative_active_enrollment = result.body.status is AgentStatus.ACTIVE return result diff --git a/src/agent_enrollment_protocol/agent/documents.py b/src/agent_enrollment_protocol/agent/documents.py new file mode 100644 index 0000000..6c1fe2c --- /dev/null +++ b/src/agent_enrollment_protocol/agent/documents.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import asyncio +import math +from dataclasses import dataclass +from typing import Literal + +import httpx + +from agent_enrollment_protocol.core import ( + MAX_SERVICE_DOCUMENT_BYTES, + AepValidationError, + PortableServiceDocument, + ServiceDocument, + media_type_essence, + parse_portable_service_document, +) + + +@dataclass(frozen=True, slots=True) +class DocumentReadResult: + descriptor: ServiceDocument + web_url: str + document: PortableServiceDocument | None = None + reason: Literal["not_advertised", "unavailable_or_unsupported"] | None = None + + +async def read_service_document( + descriptor: ServiceDocument, *, timeout: float = 10 +) -> DocumentReadResult: + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("document timeout must be positive and finite") + descriptor = ServiceDocument.model_validate(descriptor.to_wire()) + web_url = next( + item.url for item in descriptor.representations if item.media_type == "text/html" + ) + portable_url = next( + (item.url for item in descriptor.representations if item.media_type == "application/json"), + None, + ) + if portable_url is None: + return DocumentReadResult(descriptor, web_url, reason="not_advertised") + try: + async with asyncio.timeout(timeout), httpx.AsyncClient(follow_redirects=False) as client: + request = httpx.Request( + "GET", + portable_url, + headers={"Accept": "application/json", "Accept-Encoding": "identity"}, + ) + response = await client.send(request, auth=None, follow_redirects=False, stream=True) + try: + if ( + not response.is_success + or media_type_essence(response.headers.get("content-type", "")) + != "application/json" + or response.charset_encoding not in (None, "utf-8", "UTF-8") + or response.headers.get("content-encoding", "identity").lower() != "identity" + ): + raise ValueError("unsupported document response") + body = bytearray() + async for chunk in response.aiter_raw(): + if len(body) + len(chunk) > MAX_SERVICE_DOCUMENT_BYTES: + raise ValueError("document exceeds one mebibyte") + body.extend(chunk) + document = parse_portable_service_document(bytes(body), descriptor.id) + return DocumentReadResult(descriptor, web_url, document=document) + finally: + await response.aclose() + except (TimeoutError, httpx.HTTPError, httpx.InvalidURL, ValueError, AepValidationError): + return DocumentReadResult(descriptor, web_url, reason="unavailable_or_unsupported") diff --git a/src/agent_enrollment_protocol/agent/types.py b/src/agent_enrollment_protocol/agent/types.py index bb2bcf4..33ce0eb 100644 --- a/src/agent_enrollment_protocol/agent/types.py +++ b/src/agent_enrollment_protocol/agent/types.py @@ -148,6 +148,13 @@ class CommandResult(Generic[ResultT]): class EnrollOptions: claims: ClaimValues | None = None idempotency_key: str | None = None + acknowledged_document_ids: tuple[str, ...] | None = None + + def __post_init__(self) -> None: + if self.acknowledged_document_ids is not None: + object.__setattr__( + self, "acknowledged_document_ids", tuple(self.acknowledged_document_ids) + ) @dataclass(frozen=True, slots=True) diff --git a/src/agent_enrollment_protocol/core/__init__.py b/src/agent_enrollment_protocol/core/__init__.py index 20ab76d..edb27dc 100644 --- a/src/agent_enrollment_protocol/core/__init__.py +++ b/src/agent_enrollment_protocol/core/__init__.py @@ -31,11 +31,14 @@ AEP_MEDIA_TYPE, AEP_PLATFORM_WELL_KNOWN_PATH, AEP_PROBLEM_MEDIA_TYPE, + AEP_SERVICE_DOCUMENTS_EXTENSION, AEP_SIGNING_ALGORITHMS, AEP_VERSION, AEP_WELL_KNOWN_PATH, DEFAULT_HTTP_ENDPOINT_BASE, MAX_ASSERTION_LIFETIME_SECONDS, + MAX_SERVICE_DOCUMENT_BYTES, + SERVICE_DOCUMENT_TYPES, ) from .did_web import did_web_document_url, select_did_web_public_jwk from .errors import ( @@ -77,6 +80,7 @@ Commands, ContactAddressPrimary, CoreConfiguration, + DocumentRepresentation, EnrollmentDecisionStatus, EnrollRequest, EnrollResponse, @@ -112,10 +116,13 @@ PlatformSignResponse, PlatformVerificationRequest, PlatformVerificationResponse, + PortableDocumentBlock, + PortableServiceDocument, ProblemDetails, ProtectedResourceAuthorization, RevokeRequest, RevokeResponse, + ServiceDocument, ServiceIdentity, SigningAlgorithm, StatusResponse, @@ -132,6 +139,7 @@ ValidationResult, parse_json_model, parse_platform_sign_response, + parse_portable_service_document, validate_json_model, ) @@ -159,11 +167,14 @@ "AEP_MEDIA_TYPE", "AEP_PLATFORM_WELL_KNOWN_PATH", "AEP_PROBLEM_MEDIA_TYPE", + "AEP_SERVICE_DOCUMENTS_EXTENSION", "AEP_SIGNING_ALGORITHMS", "AEP_VERSION", "AEP_WELL_KNOWN_PATH", "DEFAULT_HTTP_ENDPOINT_BASE", "MAX_ASSERTION_LIFETIME_SECONDS", + "MAX_SERVICE_DOCUMENT_BYTES", + "SERVICE_DOCUMENT_TYPES", "AepAssertionError", "AepAuthorizationError", "AepError", @@ -183,6 +194,7 @@ "Commands", "ContactAddressPrimary", "CoreConfiguration", + "DocumentRepresentation", "EnrollRequest", "EnrollResponse", "EnrollmentDecisionStatus", @@ -223,10 +235,13 @@ "PlatformSigningConfiguration", "PlatformVerificationRequest", "PlatformVerificationResponse", + "PortableDocumentBlock", + "PortableServiceDocument", "ProblemDetails", "ProtectedResourceAuthorization", "RevokeRequest", "RevokeResponse", + "ServiceDocument", "ServiceIdentity", "SigningAlgorithm", "StatusResponse", @@ -248,6 +263,7 @@ "parse_authorization", "parse_json_model", "parse_platform_sign_response", + "parse_portable_service_document", "render_authorization", "require_service_origin_binding", "resolve_openapi_security", diff --git a/src/agent_enrollment_protocol/core/constants.py b/src/agent_enrollment_protocol/core/constants.py index f0a83fe..21d3d39 100644 --- a/src/agent_enrollment_protocol/core/constants.py +++ b/src/agent_enrollment_protocol/core/constants.py @@ -9,6 +9,23 @@ AEP_WELL_KNOWN_PATH: Final = "/.well-known/aep" DEFAULT_HTTP_ENDPOINT_BASE: Final = "/aep/" MAX_ASSERTION_LIFETIME_SECONDS: Final = 300 +AEP_SERVICE_DOCUMENTS_EXTENSION: Final = "urn:aep:ext:aep-foundation:service-documents#v=1.0.0" +MAX_SERVICE_DOCUMENT_BYTES: Final = 1 << 20 +SERVICE_DOCUMENT_TYPES: Final = ( + "acceptable_use_policy", + "age_or_parental_consent", + "ai_data_use_policy", + "community_guidelines", + "cookie_policy", + "data_processing_agreement", + "data_retention_notice", + "electronic_signature_consent", + "privacy_policy", + "security_policy", + "subscription_terms", + "supplemental_terms", + "terms_of_service", +) AEP_COMMANDS: Final = ("inspect", "enroll", "grant", "revoke", "status") AEP_AUTHENTICATED_COMMANDS: Final = ("enroll", "grant", "revoke", "status") diff --git a/src/agent_enrollment_protocol/core/models.py b/src/agent_enrollment_protocol/core/models.py index 02d4de6..1d5fcbd 100644 --- a/src/agent_enrollment_protocol/core/models.py +++ b/src/agent_enrollment_protocol/core/models.py @@ -9,6 +9,7 @@ from urllib.parse import urlsplit, urlunsplit from pydantic import ( + AfterValidator, BaseModel, BeforeValidator, ConfigDict, @@ -17,7 +18,7 @@ model_validator, ) -from .constants import AEP_SIGNING_ALGORITHMS, AEP_VERSION +from .constants import AEP_SERVICE_DOCUMENTS_EXTENSION, AEP_SIGNING_ALGORITHMS, AEP_VERSION IDENTIFIER_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") CLAIM_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$") @@ -285,6 +286,129 @@ def validate_did(self) -> Self: return self +def _document_ids(values: tuple[str, ...]) -> tuple[str, ...]: + if ( + len(values) > 7 + or len(set(values)) != len(values) + or any(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,31}", value) is None for value in values) + ): + raise ValueError("expected at most seven unique document IDs of 1 to 32 ASCII characters") + return values + + +DocumentIds = Annotated[StringTuple, AfterValidator(_document_ids)] + + +DocumentTitle = Annotated[str, Field(min_length=1, max_length=256)] +DocumentText = Annotated[str, Field(min_length=1, max_length=16384)] +DocumentId = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,31}\z")] + + +class DocumentRepresentation(AepModel): + media_type: str = Field(pattern=r"^[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*\z") + url: str = Field(min_length=1, max_length=2048) + + @model_validator(mode="after") + def validate_url(self) -> Self: + if not self.url.isascii() or any(ord(char) <= 32 or char in "\\\x7f" for char in self.url): + raise ValueError("document URL must be an ASCII URI without controls") + parsed = urlsplit(self.url) + if ( + not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or ( + parsed.scheme != "https" + and not ( + parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"} + ) + ) + ): + raise ValueError("document URL requires HTTPS or literal-loopback HTTP") + return self + + +class ServiceDocument(AepModel): + id: DocumentId + type: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9_]*\z") + title: DocumentTitle + enrollment: Literal["required", "not-required"] + representations: Annotated[tuple[DocumentRepresentation, ...], BeforeValidator(_as_tuple)] = ( + Field(min_length=1, max_length=8) + ) + + @model_validator(mode="after") + def validate_representations(self) -> Self: + media = [item.media_type for item in self.representations] + if "text/html" not in media or len(set(media)) != len(media): + raise ValueError("representations require HTML and distinct media types") + return self + + +class PortableDocumentBlock(AepModel): + type: Literal["paragraph", "section", "bulleted_list", "numbered_list"] + text: DocumentText | None = None + title: DocumentTitle | None = None + blocks: Annotated[tuple[PortableDocumentBlock, ...], BeforeValidator(_as_tuple)] | None = Field( + default=None, min_length=1 + ) + items: Annotated[tuple[DocumentText, ...], BeforeValidator(_as_tuple)] | None = Field( + default=None, min_length=1, max_length=128 + ) + + @model_validator(mode="before") + @classmethod + def ignore_other_block_members(cls, value: Any) -> Any: + if not isinstance(value, dict): + return value + kind = value.get("type") + ignored = ( + {"title", "blocks", "items"} + if kind == "paragraph" + else {"text", "items"} + if kind == "section" + else {"text", "title", "blocks"} + if kind in ("bulleted_list", "numbered_list") + else set() + ) + return {key: item for key, item in value.items() if key not in ignored} + + @model_validator(mode="after") + def validate_block(self) -> Self: + if self.type == "paragraph" and self.text is None: + raise ValueError("paragraph requires text") + if self.type == "section" and (self.title is None or self.blocks is None): + raise ValueError("section requires title and blocks") + if self.type in {"bulleted_list", "numbered_list"} and self.items is None: + raise ValueError("list requires items") + return self + + +class PortableServiceDocument(AepModel): + format: Literal["aep-service-document"] + version: Literal["1.0"] + id: DocumentId + summary: Annotated[tuple[PortableDocumentBlock, ...], BeforeValidator(_as_tuple)] | None = ( + Field(default=None, min_length=1) + ) + content: Annotated[tuple[PortableDocumentBlock, ...], BeforeValidator(_as_tuple)] = Field( + min_length=1 + ) + + @model_validator(mode="after") + def validate_bounds(self) -> Self: + pending = [(item, 0) for item in (*(self.summary or ()), *self.content)] + count = 0 + while pending: + block, depth = pending.pop() + count += 1 + if count > 1024 or (block.type == "section" and depth >= 8): + raise ValueError("portable documents allow 1024 blocks and eight nested sections") + if block.type == "section": + pending.extend((item, depth + 1) for item in (block.blocks or ())) + return self + + class InspectDocument(AepModel): aep_version: str authentication: Authentication | None = None @@ -296,9 +420,18 @@ class InspectDocument(AepModel): http: HttpConfiguration identity: Identity service: ServiceIdentity + service_documents: Annotated[tuple[ServiceDocument, ...], BeforeValidator(_as_tuple)] | None = ( + Field(default=None, min_length=1, max_length=7) + ) @model_validator(mode="after") def validate_document(self) -> Self: + if self.service_documents is not None: + _document_ids(tuple(item.id for item in self.service_documents)) + if self.extensions is None or AEP_SERVICE_DOCUMENTS_EXTENSION not in ( + self.extensions.supported or () + ): + raise ValueError("Service Documents extension must be advertised") if VERSION_PATTERN.fullmatch(self.aep_version) is None: raise ValueError("aep_version must use major.minor syntax") if self.aep_version.split(".", 1)[0] != AEP_VERSION.split(".", 1)[0]: @@ -377,6 +510,7 @@ class EnrollRequest(AepModel): agent_did: str = Field(min_length=1) claims: ClaimValues | None = None idempotency_key: str | None = Field(default=None, min_length=1) + acknowledged_document_ids: DocumentIds | None = None class LifecycleResponse(AepModel): @@ -403,7 +537,7 @@ def to_wire(self) -> dict[str, Any]: class EnrollResponse(LifecycleResponse): - pass + acknowledged_document_ids: DocumentIds | None = None class StatusResponse(LifecycleResponse): @@ -488,9 +622,20 @@ class ProblemDetails(AepModel): owner_action_required: Literal["true"] | None = None verification_pending: StringTuple | None = None requirements_pending: StringTuple | None = None + required_document_ids: DocumentIds | None = None @model_validator(mode="after") def validate_problem(self) -> Self: + documents_pending = "service_documents" in (self.requirements_pending or ()) + if (self.required_document_ids is not None or documents_pending) and ( + not self.required_document_ids + or not documents_pending + or self.code != "requirements_unmet" + or self.status != 422 + ): + raise ValueError( + "document requirements need IDs, service_documents and HTTP 422 requirements_unmet" + ) if ERROR_CODE_PATTERN.fullmatch(self.code) is None: raise ValueError("code must use the registered AEP error-code syntax") if self.type != f"urn:aep:error:{self.code}": diff --git a/src/agent_enrollment_protocol/core/validation.py b/src/agent_enrollment_protocol/core/validation.py index a44629e..7900635 100644 --- a/src/agent_enrollment_protocol/core/validation.py +++ b/src/agent_enrollment_protocol/core/validation.py @@ -6,8 +6,9 @@ from pydantic import BaseModel, TypeAdapter, ValidationError +from .constants import MAX_SERVICE_DOCUMENT_BYTES from .errors import AepValidationError, ValidationIssue, validation_issues -from .models import PlatformSignResponse +from .models import PlatformSignResponse, PortableServiceDocument ModelT = TypeVar("ModelT", bound=BaseModel) ValueT = TypeVar("ValueT") @@ -49,6 +50,21 @@ def parse_platform_sign_response(data: str | bytes) -> PlatformSignResponse: ) +def parse_portable_service_document(data: bytes, expected_id: str) -> PortableServiceDocument: + try: + if len(data) > MAX_SERVICE_DOCUMENT_BYTES: + raise ValueError("document exceeds one mebibyte") + text = data.decode("utf-8", errors="strict") + document = parse_json_model(text, PortableServiceDocument, "portable document") + if document.id != expected_id: + raise ValueError("document ID does not match its descriptor") + return document + except (ValueError, RecursionError) as error: + raise AepValidationError( + "Invalid portable document.", (ValidationIssue(path="$", message=str(error)),) + ) from error + + def _parse_json_value(data: str | bytes, value_type: Any, document_type: str) -> Any: _reject_duplicate_members(data, document_type) try: diff --git a/src/agent_enrollment_protocol/service/service.py b/src/agent_enrollment_protocol/service/service.py index 586ddcb..29e26bf 100644 --- a/src/agent_enrollment_protocol/service/service.py +++ b/src/agent_enrollment_protocol/service/service.py @@ -18,6 +18,7 @@ AEP_GRANT_TYPE_BASIC, AEP_GRANT_TYPE_OAUTH_BEARER, AEP_PROBLEM_MEDIA_TYPE, + AEP_SERVICE_DOCUMENTS_EXTENSION, AEP_VERSION, AgentStatus, AssertionOperation, @@ -29,6 +30,7 @@ Command, Commands, CoreConfiguration, + EnrollmentDecisionStatus, EnrollRequest, EnrollResponse, Extensions, @@ -167,6 +169,8 @@ def __init__(self, options: ServiceOptions) -> None: document_data["claims"] = options.claims if options.extensions: document_data["extensions"] = Extensions(supported=options.extensions) + if options.service_documents is not None: + document_data["service_documents"] = options.service_documents if self._authentication_methods: document_data["authentication"] = {"methods": self._authentication_methods} self._document = InspectDocument.model_validate(document_data) @@ -205,22 +209,37 @@ async def enroll(self, body: bytes, options: CommandOptions) -> ServiceResult[En return _problem("invalid_request", "Invalid request", 400) async def execute() -> ServiceResult[EnrollResponse]: + effective = request + if self._document.extensions is None or AEP_SERVICE_DOCUMENTS_EXTENSION not in ( + self._document.extensions.supported or () + ): + effective = parse_json_model( + json.dumps( + { + key: value + for key, value in request.to_wire().items() + if key != "acknowledged_document_ids" + } + ), + EnrollRequest, + "Enroll request", + ) existing = await self._enrollment_store.find(claims.sub) if existing is not None: - return _enrollment_result(existing, claims.sub) + return await self._update_enrollment(effective, existing) required = self._document.claims.required if self._document.claims else () - missing = missing_required_claim_names(required or (), request.claims) - if missing: - return _problem( - "requirements_unmet", - "Requirements unmet", - 422, - requirements_pending=missing, - ) + missing = missing_required_claim_names(required or (), effective.claims) + missing_documents = ( + self._missing_document_ids(None) + if effective.acknowledged_document_ids is None + else () + ) + if missing or missing_documents: + return _document_requirements(missing, missing_documents) async def create() -> EnrollmentRecord: now = self._now() - decision = await self._enrollment_policy.decide(request, now) + decision = await self._enrollment_policy.decide(effective, now) _validate_enrollment_decision(decision) identifier = self._identifier() if not identifier: @@ -229,19 +248,24 @@ async def create() -> EnrollmentRecord: ) return EnrollmentRecord( agent_did=claims.sub, - claims=_accepted_claims(request.claims, self._document.claims), + claims=_accepted_claims(effective.claims, self._document.claims), + acknowledged_document_ids=( + () + if decision.status is EnrollmentDecisionStatus.REJECTED + else self._accepted_document_ids((), effective.acknowledged_document_ids) + ), created_at=now, enrollment_id=identifier, - owner_action_required=decision.owner_action_required, - requirements_pending=tuple(decision.requirements_pending), + owner_action_required=decision.owner_action_required is True, + requirements_pending=tuple(decision.requirements_pending or ()), since=now, status=AgentStatus(decision.status.value), updated_at=now, - verification_pending=tuple(decision.verification_pending), + verification_pending=tuple(decision.verification_pending or ()), ) record, _ = await self._enrollment_store.find_or_create(claims.sub, create) - return _enrollment_result(record, claims.sub) + return _enrollment_result(record, claims.sub, effective.acknowledged_document_ids) return await self._idempotent( claims.sub, @@ -252,6 +276,86 @@ async def create() -> EnrollmentRecord: execute, ) + async def _update_enrollment( + self, request: EnrollRequest, existing: EnrollmentRecord + ) -> ServiceResult[EnrollResponse]: + _validate_enrollment_record(existing, request.agent_did) + now = self._now() + decision = await self._enrollment_policy.decide(request, now) + _validate_enrollment_decision(decision) + if decision.status is EnrollmentDecisionStatus.REJECTED: + return _problem("enrollment_failed", "Enrollment failed", 403) + if not decision.update_existing: + return _enrollment_result( + existing, request.agent_did, request.acknowledged_document_ids + ) + supplied = _accepted_claims(request.claims, self._document.claims) + merged = dict(existing.claims.to_wire()) if existing.claims is not None else {} + if supplied is not None: + merged.update(supplied.to_wire()) + values = parse_json_model(json.dumps(merged), ClaimValues, "enrollment claims") + required = self._document.claims.required if self._document.claims else () + missing = missing_required_claim_names(required or (), values) + missing_documents = ( + self._missing_document_ids(existing) + if request.acknowledged_document_ids is None + else () + ) + if missing or missing_documents: + return _document_requirements(missing, missing_documents) + record = await self._enrollment_store.save( + replace( + existing, + claims=values, + acknowledged_document_ids=self._accepted_document_ids( + existing.acknowledged_document_ids, request.acknowledged_document_ids + ), + required_document_ids=tuple( + item + for item in existing.required_document_ids + if item not in (request.acknowledged_document_ids or ()) + ), + owner_action_required=( + existing.owner_action_required + if decision.owner_action_required is None + else decision.owner_action_required + ), + requirements_pending=( + existing.requirements_pending + if decision.requirements_pending is None + else tuple(decision.requirements_pending) + ), + verification_pending=( + existing.verification_pending + if decision.verification_pending is None + else tuple(decision.verification_pending) + ), + updated_at=now, + ) + ) + return _enrollment_result(record, request.agent_did, request.acknowledged_document_ids) + + def _missing_document_ids(self, record: EnrollmentRecord | None) -> tuple[str, ...]: + return tuple( + item.id + for item in (self._document.service_documents or ()) + if item.enrollment == "required" + and ( + record is None + or item.id not in record.acknowledged_document_ids + or item.id in record.required_document_ids + ) + ) + + def _accepted_document_ids( + self, existing: tuple[str, ...], declared: tuple[str, ...] | None + ) -> tuple[str, ...]: + known = {item.id for item in (self._document.service_documents or ())} + return ( + *existing, + *(item for item in (declared or ()) if item in known and item not in existing), + ) + async def status(self, options: CommandOptions) -> ServiceResult[StatusResponse]: claims = await self._authenticate_assertion( options, AssertionOperation.STATUS, resource=None @@ -290,6 +394,11 @@ async def execute() -> ServiceResult[dict[str, Any]]: return _problem("unsupported_grant_type", "Unsupported grant type", 400) if record.status is not AgentStatus.ACTIVE: return _grant_lifecycle_problem(record) + required = self._document.claims.required if self._document.claims else () + missing = missing_required_claim_names(required or (), record.claims) + missing_documents = self._missing_document_ids(record) + if missing or missing_documents: + return _document_requirements(missing, missing_documents) raw = await definition.handler.grant( request, GrantContext( @@ -623,6 +732,7 @@ def _problem( requirements_pending: Sequence[str] = (), verification_pending: Sequence[str] = (), owner_action_required: bool = False, + required_document_ids: tuple[str, ...] = (), ) -> ServiceResult[Any]: data: dict[str, Any] = { "type": f"urn:aep:error:{code}", @@ -636,6 +746,8 @@ def _problem( data["verification_pending"] = tuple(verification_pending) if owner_action_required: data["owner_action_required"] = "true" + if required_document_ids: + data["required_document_ids"] = required_document_ids problem = ProblemDetails.model_validate(data) headers = {"WWW-Authenticate": f'AEP reason="{code}"'} if status == 401 else {} return ServiceResult( @@ -646,11 +758,29 @@ def _problem( ) -def _enrollment_result(record: EnrollmentRecord, agent_did: str) -> ServiceResult[EnrollResponse]: +def _document_requirements( + claims: tuple[str, ...], documents: tuple[str, ...] +) -> ServiceResult[Any]: + return _problem( + "requirements_unmet", + "Requirements unmet", + 422, + requirements_pending=(*claims, "service_documents") if documents else claims, + owner_action_required=bool(documents), + required_document_ids=documents, + ) + + +def _enrollment_result( + record: EnrollmentRecord, agent_did: str, declared: tuple[str, ...] | None = None +) -> ServiceResult[EnrollResponse]: _validate_enrollment_record(record, agent_did) + data = _lifecycle_data(record) + if declared is not None and record.status is not AgentStatus.REJECTED: + data["acknowledged_document_ids"] = declared return ServiceResult( status=200, - body=EnrollResponse.model_validate(_lifecycle_data(record)), + body=EnrollResponse.model_validate(data), ) diff --git a/src/agent_enrollment_protocol/service/types.py b/src/agent_enrollment_protocol/service/types.py index 39df264..f78a22c 100644 --- a/src/agent_enrollment_protocol/service/types.py +++ b/src/agent_enrollment_protocol/service/types.py @@ -23,6 +23,7 @@ OpenApiReference, ProblemDetails, RevokeRequest, + ServiceDocument, SigningAlgorithm, ) @@ -93,8 +94,12 @@ class EnrollmentRecord: status: AgentStatus updated_at: datetime verification_pending: tuple[str, ...] + acknowledged_document_ids: tuple[str, ...] = () + required_document_ids: tuple[str, ...] = () def __post_init__(self) -> None: + object.__setattr__(self, "acknowledged_document_ids", tuple(self.acknowledged_document_ids)) + object.__setattr__(self, "required_document_ids", tuple(self.required_document_ids)) if not self.agent_did or not self.enrollment_id: raise ValueError("AEP enrollment record requires Agent and enrollment identifiers") if any( @@ -116,9 +121,10 @@ async def save(self, record: EnrollmentRecord) -> EnrollmentRecord: ... @dataclass(frozen=True, slots=True) class EnrollmentDecision: status: EnrollmentDecisionStatus = EnrollmentDecisionStatus.ACTIVE - owner_action_required: bool = False - requirements_pending: tuple[str, ...] = () - verification_pending: tuple[str, ...] = () + owner_action_required: bool | None = None + requirements_pending: tuple[str, ...] | None = None + verification_pending: tuple[str, ...] | None = None + update_existing: bool = True class EnrollmentPolicy(Protocol): @@ -331,6 +337,7 @@ class ServiceOptions: maximum_assertion_lifetime: timedelta = timedelta(minutes=5) openapi: OpenApiReference | None = None replay_store: ReplayStore | None = None + service_documents: tuple[ServiceDocument, ...] | None = None signing_algorithms: tuple[SigningAlgorithm, ...] = ( SigningAlgorithm.EDDSA, SigningAlgorithm.ES256, diff --git a/tests/test_agent.py b/tests/test_agent.py index b52a731..13bc8a1 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -217,9 +217,6 @@ async def test_enroll_status_wait_and_claim_requirements() -> None: keys = FixedKeys() agent, provider = configured_agent(QueueTransport(inspect_response()), commands, keys=keys) service = agent.service("api.example.com") - with pytest.raises(ClaimRequirementsError) as missing: - await service.enroll() - assert missing.value.missing == ("contact.email",) enrolled = await service.enroll( EnrollOptions(claims=ClaimValues.model_validate({"contact.email": "owner@example.com"})) ) @@ -231,6 +228,12 @@ async def test_enroll_status_wait_and_claim_requirements() -> None: assert (await service.grant()).body.grant_type == "api-key" +def test_claim_requirements_error_preserves_missing_claim_names() -> None: + error = ClaimRequirementsError(("contact.email", "person.first_name")) + assert error.missing == ("contact.email", "person.first_name") + assert "contact.email" in str(error) and "person.first_name" in str(error) + + @pytest.mark.asyncio async def test_wait_rejects_terminal_state_and_supports_cancellation() -> None: rejected_agent, _ = configured_agent( diff --git a/tests/test_conformance_falsifiability.py b/tests/test_conformance_falsifiability.py index 06dca35..44b813a 100644 --- a/tests/test_conformance_falsifiability.py +++ b/tests/test_conformance_falsifiability.py @@ -20,6 +20,12 @@ # expectation flips from passed to failed; a mapping that compares two constants, or two fields of # the vector to each other, stays passed and fails this test. CORRUPTIONS: tuple[Corruption, ...] = ( + ("service", "service-documents", "wire-shapes", "cases", []), + ("service", "service-documents", "structural-limits", "cases", []), + ("service", "service-documents", "enrollment-lifecycle", "identity_preserved", False), + ("service", "service-documents", "enrollment-lifecycle", "credential_ids", ["not-issued"]), + ("service", "service-documents", "grant-guidance", "grant_status", 200), + ("service", "service-documents", "grant-guidance", "credential_issued", True), ( "service", "protected-resource", @@ -55,7 +61,7 @@ "insufficient_scope", "invalid_request", ), - ("service", "enroll", "repeated-existing", "policy_evaluated", True), + ("service", "enroll", "repeated-existing", "policy_evaluated", False), ( "service", "claims", @@ -216,6 +222,8 @@ def test_conformance_mappings_read_the_expectation_they_publish() -> None: @pytest.mark.parametrize( "role,category,identifier,owner,method", [ + ("service", "service-documents", "enrollment-lifecycle", "Service", "enroll"), + ("service", "service-documents", "grant-guidance", "Service", "grant"), ("agent", "idempotency", "command-header", "Agent", "service"), ( "agent", diff --git a/tests/test_document_reader.py b/tests/test_document_reader.py new file mode 100644 index 0000000..5171f02 --- /dev/null +++ b/tests/test_document_reader.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress + +import pytest + +from agent_enrollment_protocol.agent import read_service_document +from agent_enrollment_protocol.core import MAX_SERVICE_DOCUMENT_BYTES, ServiceDocument + +from .test_service_documents import descriptor, portable + + +@asynccontextmanager +async def document_server( + body: bytes, + *, + status: int = 200, + headers: bytes = b"Content-Type: application/json\r\n", + stall: bool = False, + truncate: bool = False, +) -> AsyncIterator[tuple[str, list[bytes], asyncio.Event]]: + requests: list[bytes] = [] + received = asyncio.Event() + finished = asyncio.Event() + + async def serve(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + requests.append(await reader.readuntil(b"\r\n\r\n")) + received.set() + if stall: + await reader.read() + else: + length = len(body) + (10 if truncate else 0) + writer.write( + f"HTTP/1.1 {status} Response\r\n".encode() + + headers + + f"Content-Length: {length}\r\nConnection: close\r\n\r\n".encode() + + body + ) + await writer.drain() + finally: + writer.close() + with suppress(ConnectionResetError, BrokenPipeError): + await writer.wait_closed() + finished.set() + + server = await asyncio.start_server(serve, "127.0.0.1", 0) + try: + yield ( + f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/terms.json", + requests, + received, + ) + finally: + server.close() + await server.wait_closed() + if requests: + await asyncio.wait_for(finished.wait(), timeout=3) + + +def with_portable(url: str) -> ServiceDocument: + data = descriptor().to_wire() + data["representations"].append({"media_type": "application/json", "url": url}) + return ServiceDocument.model_validate(data) + + +@pytest.mark.asyncio +async def test_reader_downloads_anonymously_and_preserves_service_content() -> None: + async with document_server(json.dumps(portable()).encode()) as (url, requests, _): + result = await read_service_document(with_portable(url)) + assert result.reason is None + assert result.document and result.document.to_wire() == portable() + assert result.web_url == "https://example.com/terms" + assert len(requests) == 1 + request = requests[0].lower() + assert request.startswith(b"get /terms.json http/1.1\r\n") + assert b"accept: application/json\r\n" in request + assert b"accept-encoding: identity\r\n" in request + for name in (b"authorization:", b"aep-authorization:", b"cookie:", b"user-agent:"): + assert name not in request + + +@pytest.mark.asyncio +async def test_reader_offers_html_without_fetching_when_portable_is_absent() -> None: + result = await read_service_document(descriptor()) + assert result.reason == "not_advertised" and result.document is None + assert result.web_url == "https://example.com/terms" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf")]) +async def test_reader_requires_bounded_timeout(timeout: float) -> None: + with pytest.raises(ValueError, match="positive and finite"): + await read_service_document(descriptor(), timeout=timeout) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,headers,body,truncate", + [ + (403, b"Content-Type: application/json\r\n", b"{}", False), + (302, b"Location: /redirect-target\r\n", b"", False), + (200, b"Content-Type: text/html\r\n", b"{}", False), + (200, b"Content-Type: application/json; charset=latin-1\r\n", b"{}", False), + (200, b"Content-Type: application/json\r\nContent-Encoding: gzip\r\n", b"{}", False), + (200, b"Content-Type: application/json\r\n", b"{", False), + (200, b"Content-Type: application/json\r\n", b"\xff", False), + ( + 200, + b"Content-Type: application/json\r\n", + b" " * (MAX_SERVICE_DOCUMENT_BYTES + 1), + False, + ), + (200, b"Content-Type: application/json\r\n", b"{}", True), + ( + 200, + b"Content-Type: application/json\r\n", + json.dumps(portable() | {"id": "other"}).encode(), + False, + ), + ( + 200, + b"Content-Type: application/json\r\n", + json.dumps(portable() | {"content": [{"type": "future"}]}).encode(), + False, + ), + ], +) +async def test_reader_falls_back_without_following_redirects_or_returning_partial_content( + status: int, headers: bytes, body: bytes, truncate: bool +) -> None: + async with document_server(body, status=status, headers=headers, truncate=truncate) as ( + url, + requests, + _, + ): + result = await read_service_document(with_portable(url)) + assert result.reason == "unavailable_or_unsupported" + assert result.document is None and result.web_url == "https://example.com/terms" + assert len(requests) == 1 + + +@pytest.mark.asyncio +async def test_reader_applies_total_timeout_and_propagates_cancellation() -> None: + async with document_server(b"", stall=True) as (url, requests, _): + result = await read_service_document(with_portable(url), timeout=0.2) + assert result.reason == "unavailable_or_unsupported" and len(requests) == 1 + async with document_server(b"", stall=True) as (url, requests, received): + task = asyncio.create_task(read_service_document(with_portable(url))) + await asyncio.wait_for(received.wait(), timeout=3) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert len(requests) == 1 + + +@pytest.mark.asyncio +async def test_reader_handles_connection_failure() -> None: + async with document_server(b"") as (url, _, _): + selected = with_portable(url) + result = await read_service_document(selected) + assert result.reason == "unavailable_or_unsupported" + + +@pytest.mark.asyncio +async def test_reader_offers_html_when_http_client_cannot_parse_an_advertised_uri() -> None: + result = await read_service_document(with_portable("https://[v1.test]/terms")) + assert result.reason == "unavailable_or_unsupported" + assert result.web_url == "https://example.com/terms" and result.document is None + + +@pytest.mark.asyncio +async def test_reader_accepts_content_type_parameters() -> None: + async with document_server( + json.dumps(portable()).encode(), + headers=b"Content-Type: application/json; charset=UTF-8; profile=example\r\n", + ) as (url, _, _): + result = await read_service_document(with_portable(url)) + assert result.document and result.reason is None diff --git a/tests/test_service.py b/tests/test_service.py index 6fcb30c..91cf0f9 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -218,6 +218,98 @@ async def test_requirements_idempotency_and_lifecycle() -> None: assert pending.body.verification_pending == ("email",) +@pytest.mark.asyncio +@pytest.mark.parametrize("initial", [True, False]) +@pytest.mark.parametrize("value", [None, False, True]) +async def test_policy_metadata_omission_and_explicit_values( + initial: bool, value: bool | None +) -> None: + store = MemoryEnrollmentStore() + original = replace( + _record(), + owner_action_required=True, + requirements_pending=("review",), + verification_pending=("contact.email",), + status=AgentStatus.SUSPENDED, + ) + if not initial: + await store.save(original) + decision = EnrollmentDecision( + owner_action_required=value, + requirements_pending=None if value is None else (), + verification_pending=None if value is None else (), + ) + service, _ = _service( + enrollment_store=store, enrollment_policy=StaticEnrollmentPolicy(decision) + ) + result = await service.enroll( + b'{"agent_did":"did:web:agent.example"}', + _options(AssertionOperation.ENROLL, "metadata", key="metadata"), + ) + assert result.status == 200 + record = await store.find(AGENT_DID) + assert record is not None + assert record.owner_action_required is (not initial if value is None else value) + assert record.requirements_pending == (("review",) if not initial and value is None else ()) + assert record.verification_pending == ( + ("contact.email",) if not initial and value is None else () + ) + if not initial: + assert record.enrollment_id == original.enrollment_id + assert record.created_at == original.created_at + assert record.since == original.since + assert record.status is AgentStatus.SUSPENDED + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "decision", + [ + EnrollmentDecision(status=EnrollmentDecisionStatus.REJECTED), + EnrollmentDecision(update_existing=False), + EnrollmentDecision( + owner_action_required=True, + requirements_pending=("review",), + verification_pending=("email",), + ), + ], +) +async def test_repeated_policy_decision_and_claim_merge(decision: EnrollmentDecision) -> None: + store = MemoryEnrollmentStore() + original = replace( + _record(), + claims=ClaimValues.model_validate({"contact.email": "owner@example.com"}), + updated_at=NOW - timedelta(hours=1), + ) + await store.save(original) + service, _ = _service( + enrollment_store=store, + enrollment_policy=StaticEnrollmentPolicy(decision), + claims=InspectClaims(required=("contact.email",), optional=("person.first_name",)), + ) + result = await service.enroll( + b'{"agent_did":"did:web:agent.example","claims":{"person.first_name":"Owner"}}', + _options(AssertionOperation.ENROLL, "update", key="update"), + ) + record = await store.find(AGENT_DID) + assert record is not None + if decision.status is EnrollmentDecisionStatus.REJECTED or not decision.update_existing: + assert result.status == ( + 403 if decision.status is EnrollmentDecisionStatus.REJECTED else 200 + ) + assert record == original + else: + assert result.status == 200 + assert record.claims is not None + assert record.claims.to_wire() == { + "contact.email": "owner@example.com", + "person.first_name": "Owner", + } + assert record.owner_action_required is True + assert record.requirements_pending == ("review",) + assert record.verification_pending == ("email",) + + @pytest.mark.asyncio async def test_grant_revoke_and_authentication() -> None: handler = GrantHandler() diff --git a/tests/test_service_boundaries.py b/tests/test_service_boundaries.py index 8272b24..6e7afbf 100644 --- a/tests/test_service_boundaries.py +++ b/tests/test_service_boundaries.py @@ -199,7 +199,9 @@ async def test_status_and_lifecycle_boundaries() -> None: b'{"agent_did":"did:web:agent.example"}', _options(AssertionOperation.ENROLL, "existing", key="existing"), ) - assert existing.status == 200 + assert existing.status == 422 + assert existing.problem is not None + assert existing.problem.requirements_pending == ("contact.email",) with pytest.raises(ValueError, match="UTC offsets"): replace(_record(), since=NOW.replace(tzinfo=None)) diff --git a/tests/test_service_documents.py b/tests/test_service_documents.py new file mode 100644 index 0000000..11bb5f1 --- /dev/null +++ b/tests/test_service_documents.py @@ -0,0 +1,547 @@ +from __future__ import annotations + +import json +from dataclasses import replace +from datetime import timedelta +from typing import Any + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from pydantic import ValidationError + +from agent_enrollment_protocol.adapters import AepAsgiApplication +from agent_enrollment_protocol.agent import ( + Agent, + AgentCommandError, + AgentOptions, + EnrollOptions, + GrantOptions, + HttpxTransport, +) +from agent_enrollment_protocol.core import ( + AEP_PROBLEM_MEDIA_TYPE, + AEP_SERVICE_DOCUMENTS_EXTENSION, + MAX_SERVICE_DOCUMENT_BYTES, + SERVICE_DOCUMENT_TYPES, + AepValidationError, + AgentStatus, + AssertionOperation, + ClaimValues, + EnrollmentDecisionStatus, + EnrollRequest, + EnrollResponse, + InspectClaims, + InspectDocument, + PortableDocumentBlock, + PortableServiceDocument, + ProblemDetails, + ServiceDocument, + parse_portable_service_document, +) +from agent_enrollment_protocol.service import ( + EnrollmentDecision, + GrantTypeDefinition, + MemoryEnrollmentStore, + Service, + ServiceOptions, + StaticEnrollmentPolicy, +) + +from .test_agent import QueueTransport, configured_agent, inspect_response, response +from .test_agent_integration import LocalIdentityProvider, Verifier +from .test_core_models import inspect_document +from .test_platform import NOW +from .test_service import AGENT_DID, GrantHandler, _options, _service + + +def descriptor( + identifier: str = "terms", url: str = "https://example.com/terms" +) -> ServiceDocument: + return ServiceDocument.model_validate( + { + "id": identifier, + "type": "custom_terms", + "title": "Terms", + "enrollment": "required", + "representations": [{"media_type": "text/html", "url": url}], + } + ) + + +def portable() -> dict[str, Any]: + return { + "format": "aep-service-document", + "version": "1.0", + "id": "terms", + "summary": [{"type": "paragraph", "text": "A service summary."}], + "content": [ + { + "type": "section", + "title": "Use", + "blocks": [ + {"type": "paragraph", "text": "Full café terms."}, + {"type": "numbered_list", "items": ["First", "Second"]}, + {"type": "bulleted_list", "items": ["Third"]}, + ], + } + ], + } + + +def advertised() -> InspectDocument: + data = inspect_document().to_wire() + data.update( + extensions={"supported": [AEP_SERVICE_DOCUMENTS_EXTENSION]}, + service_documents=[descriptor().to_wire()], + ) + return InspectDocument.model_validate_json(json.dumps(data)) + + +def test_document_models_preserve_content_and_additive_fields() -> None: + assert tuple(sorted(SERVICE_DOCUMENT_TYPES)) == SERVICE_DOCUMENT_TYPES + assert len(SERVICE_DOCUMENT_TYPES) == 13 + assert advertised().service_documents == (descriptor(),) + raw = portable() | {"future": {"value": None}} + parsed = parse_portable_service_document(json.dumps(raw).encode(), "terms") + assert parsed.to_wire() == raw + assert isinstance(parsed.content, tuple) + assert parsed.content[0].blocks is not None + with pytest.raises(ValidationError): + parsed.id = "other" + assert ( + PortableServiceDocument.model_validate( + {k: v for k, v in portable().items() if k != "summary"} + ).summary + is None + ) + for ids in ((), ("Terms", "terms", "a" * 32), tuple(f"id-{n}" for n in range(7))): + request = EnrollRequest(agent_did=AGENT_DID, acknowledged_document_ids=ids) + assert request.to_wire()["acknowledged_document_ids"] == list(ids) + assert "acknowledged_document_ids" not in EnrollRequest(agent_did=AGENT_DID).to_wire() + + +def test_portable_block_ignores_members_not_defined_for_its_type() -> None: + for block in ( + {"type": "paragraph", "text": "Terms"}, + {"type": "section", "title": "Use", "blocks": [{"type": "paragraph", "text": "Terms"}]}, + {"type": "bulleted_list", "items": ["One"]}, + {"type": "numbered_list", "items": ["One"]}, + ): + extras = {key: None for key in ("text", "title", "blocks", "items") if key not in block} + assert PortableDocumentBlock.model_validate(block | extras).to_wire() == block + with pytest.raises(ValidationError): + PortableDocumentBlock.model_validate("not an object") + with pytest.raises(ValidationError, match="string_unicode"): + ServiceDocument.model_validate(descriptor().to_wire() | {"title": "\ud800"}) + + +@pytest.mark.parametrize( + "ids", + [ + None, + [""], + ["a" * 33], + ["é"], + ["id\n"], + ["id", "id"], + [str(i) for i in range(8)], + "terms", + [1], + ], +) +def test_rejects_invalid_declarations(ids: Any) -> None: + for model, base in ( + (EnrollRequest, {"agent_did": AGENT_DID}), + (EnrollResponse, {"status": "active"}), + ): + with pytest.raises(ValidationError): + model.model_validate_json(json.dumps(base | {"acknowledged_document_ids": ids})) + + +@pytest.mark.parametrize( + "url", + [ + "http://localhost/terms", + "http://127.0.0.1:8080/terms", + "http://[::1]/terms", + "https://10.0.0.1/terms", + "https://example.com/terms?x=1", + ], +) +def test_document_url_policy_accepts_standard_networking(url: str) -> None: + assert descriptor(url=url).representations[0].url == url + + +@pytest.mark.parametrize( + "url", + [ + "http://example.com/terms", + "http://localhost.example/terms", + "http://192.168.1.1/terms", + "https://user@example.com/", + "https://user:pass@example.com/", + "https:///x", + "https://[broken/", + "https://example.com/é", + "https://example.com/a b", + "https://example.com/\\x", + "https://example.com/\x7f", + "https://example.com/" + "a" * 2048, + ], +) +def test_document_url_policy_rejects_malformed_or_nonlocal_http(url: str) -> None: + with pytest.raises(ValidationError): + descriptor(url=url) + + +@pytest.mark.parametrize( + "change", + [ + {"id": "x\n"}, + {"type": "bad-type"}, + {"enrollment": "future"}, + {"title": ""}, + {"title": "x" * 257}, + {"title": "\ud800"}, + {"representations": []}, + {"representations": [{"media_type": "application/json", "url": "https://example.com/x"}]}, + {"representations": [descriptor().representations[0].to_wire()] * 2}, + {"representations": [{"media_type": "text/html; x=y", "url": "https://example.com/x"}]}, + ], +) +def test_descriptor_rejects_invalid_known_fields(change: dict[str, Any]) -> None: + with pytest.raises(ValidationError): + ServiceDocument.model_validate(descriptor().to_wire() | change) + + +@pytest.mark.parametrize("enrollment", ["required", "not-required"]) +def test_enrollment_requirement_wire_values(enrollment: str) -> None: + document = ServiceDocument.model_validate(descriptor().to_wire() | {"enrollment": enrollment}) + assert document.to_wire()["enrollment"] == enrollment + + +def test_catalog_requires_unique_ids_bounds_and_extension() -> None: + for docs in ( + [], + [descriptor().to_wire()] * 2, + [descriptor(str(i)).to_wire() for i in range(8)], + ): + with pytest.raises(ValidationError): + InspectDocument.model_validate_json( + json.dumps(advertised().to_wire() | {"service_documents": docs}) + ) + data = advertised().to_wire() + del data["extensions"] + with pytest.raises(ValidationError): + InspectDocument.model_validate_json(json.dumps(data)) + data["extensions"] = {"supported": []} + with pytest.raises(ValidationError): + InspectDocument.model_validate_json(json.dumps(data)) + + +@pytest.mark.parametrize( + "block", + [ + {"type": "paragraph"}, + {"type": "paragraph", "text": ""}, + {"type": "paragraph", "text": "x" * 16385}, + {"type": "paragraph", "text": "\ud800"}, + {"type": "section", "title": "Title"}, + {"type": "section", "blocks": []}, + {"type": "bulleted_list"}, + {"type": "numbered_list", "items": []}, + {"type": "bulleted_list", "items": ["x"] * 129}, + {"type": "future"}, + ], +) +def test_rejects_bad_portable_blocks(block: dict[str, Any]) -> None: + with pytest.raises(AepValidationError): + parse_portable_service_document( + json.dumps(portable() | {"content": [block]}).encode(), "terms" + ) + + +def test_portable_depth_count_byte_and_identity_bounds() -> None: + block: dict[str, Any] = {"type": "paragraph", "text": "Terms"} + for _ in range(8): + block = {"type": "section", "title": "Title", "blocks": [block]} + assert PortableServiceDocument.model_validate(portable() | {"content": [block]}) + with pytest.raises(ValidationError): + PortableServiceDocument.model_validate( + portable() | {"content": [{"type": "section", "title": "Title", "blocks": [block]}]} + ) + text = {"type": "paragraph", "text": "Terms"} + assert PortableServiceDocument.model_validate(portable() | {"content": [text] * 1023}) + with pytest.raises(ValidationError): + PortableServiceDocument.model_validate(portable() | {"content": [text] * 1024}) + for body, expected in ( + (b"\xc3(", "terms"), + (b"{", "terms"), + (json.dumps(portable()).encode(), "other"), + (b" " * (MAX_SERVICE_DOCUMENT_BYTES + 1), "terms"), + (b"[" * 1200, "terms"), + ): + with pytest.raises(AepValidationError): + parse_portable_service_document(body, expected) + + +def test_document_problem_requires_complete_guidance() -> None: + problem = { + "type": "urn:aep:error:requirements_unmet", + "title": "Requirements unmet", + "code": "requirements_unmet", + "status": 422, + "requirements_pending": ["contact.email", "service_documents"], + "required_document_ids": ["terms"], + } + assert ProblemDetails.model_validate(problem).required_document_ids == ("terms",) + changes: tuple[dict[str, Any], ...] = ( + {"required_document_ids": []}, + {"required_document_ids": None}, + {"requirements_pending": ["contact.email"]}, + {"status": 400}, + {"code": "invalid_request"}, + ) + for change in changes: + with pytest.raises(ValidationError): + ProblemDetails.model_validate(problem | change) + del problem["required_document_ids"] + with pytest.raises(ValidationError): + ProblemDetails.model_validate(problem) + + +@pytest.mark.asyncio +async def test_document_lifecycle_and_grant_requirements() -> None: + store = MemoryEnrollmentStore() + handler = GrantHandler() + service, _ = _service( + extensions=(AEP_SERVICE_DOCUMENTS_EXTENSION,), + service_documents=(descriptor(), descriptor("privacy")), + enrollment_store=store, + grant_types=(GrantTypeDefinition("api-key", handler),), + claims=InspectClaims(required=("contact.email",)), + ) + counter = 0 + + async def enroll(fields: dict[str, Any], key: str | None = None) -> Any: + nonlocal counter + counter += 1 + return await service.enroll( + json.dumps({"agent_did": AGENT_DID} | fields).encode(), + _options(AssertionOperation.ENROLL, f"e-{counter}", key=key or f"key-{counter}"), + ) + + async def grant() -> Any: + nonlocal counter + counter += 1 + return await service.grant( + b'{"grant_type":"api-key"}', + _options(AssertionOperation.GRANT, f"g-{counter}", key=f"gk-{counter}"), + ) + + unseen = await grant() + assert unseen.status == 401 and unseen.problem.required_document_ids is None + missing = await enroll({}) + assert missing.problem.required_document_ids == ("terms", "privacy") + assert missing.problem.requirements_pending == ("contact.email", "service_documents") + claims = {"contact.email": "owner@example.com"} + first = await enroll( + {"claims": claims, "acknowledged_document_ids": ["terms", "retired"]}, "first" + ) + assert first.body.acknowledged_document_ids == ("terms", "retired") + original = await store.find(AGENT_DID) + assert original and original.acknowledged_document_ids == ("terms",) + assert ( + await enroll({"claims": claims, "acknowledged_document_ids": ["terms", "retired"]}, "first") + ).body == first.body + assert ( + await enroll({"claims": claims, "acknowledged_document_ids": ["privacy"]}, "first") + ).status == 409 + blocked = await grant() + assert blocked.problem.required_document_ids == ("privacy",) and not handler.grants + assert (await enroll({})).status == 422 + empty = await enroll({"acknowledged_document_ids": []}) + assert empty.body.acknowledged_document_ids == () + assert (await grant()).status == 422 + updated = await enroll({"acknowledged_document_ids": ["privacy"]}) + assert updated.body.acknowledged_document_ids == ("privacy",) + record = await store.find(AGENT_DID) + assert record and record.enrollment_id == original.enrollment_id + assert record.created_at == original.created_at and record.since == original.since + assert record.claims == original.claims + assert set(record.acknowledged_document_ids) == {"terms", "privacy"} + assert (await enroll({})).body.acknowledged_document_ids is None + assert (await grant()).status == 200 and len(handler.grants) == 1 + await store.save(replace(record, required_document_ids=("terms",))) + assert (await grant()).problem.required_document_ids == ("terms",) + await enroll({"acknowledged_document_ids": ["terms"]}) + assert (await grant()).status == 200 + record = await store.find(AGENT_DID) + assert record and not record.required_document_ids + await store.save(replace(record, claims=ClaimValues())) + assert (await grant()).problem.requirements_pending == ("contact.email",) + + +@pytest.mark.asyncio +async def test_service_honors_unchanged_rejection_and_nonadvertised_documents() -> None: + store = MemoryEnrollmentStore() + service, _ = _service(enrollment_store=store) + body = json.dumps({"agent_did": AGENT_DID, "acknowledged_document_ids": ["terms"]}).encode() + response_ = await service.enroll( + body, _options(AssertionOperation.ENROLL, "initial", key="initial") + ) + assert response_.body and response_.body.acknowledged_document_ids is None + original = await store.find(AGENT_DID) + assert original + for decision in ( + EnrollmentDecision(update_existing=False), + EnrollmentDecision(status=EnrollmentDecisionStatus.REJECTED), + ): + service, _ = _service( + enrollment_store=store, + enrollment_policy=StaticEnrollmentPolicy(decision), + extensions=(AEP_SERVICE_DOCUMENTS_EXTENSION,), + service_documents=(descriptor(),), + ) + result = await service.enroll( + body, _options(AssertionOperation.ENROLL, "repeat", key="repeat") + ) + assert result.status == ( + 403 if decision.status is EnrollmentDecisionStatus.REJECTED else 200 + ) + assert await store.find(AGENT_DID) == original + rejected, _ = _service( + extensions=(AEP_SERVICE_DOCUMENTS_EXTENSION,), + service_documents=(descriptor(),), + enrollment_policy=StaticEnrollmentPolicy( + EnrollmentDecision(status=EnrollmentDecisionStatus.REJECTED) + ), + ) + result = await rejected.enroll( + body, _options(AssertionOperation.ENROLL, "reject", key="reject") + ) + assert result.body and result.body.acknowledged_document_ids is None + + +@pytest.mark.asyncio +async def test_agent_enrolls_without_local_claim_gate_and_checks_exact_echo() -> None: + for echo, accepted in ((["terms"], True), (["other"], False), (None, False)): + body: dict[str, Any] = {"status": "active"} + if echo is not None: + body["acknowledged_document_ids"] = echo + commands = QueueTransport(response(body)) + agent, _ = configured_agent(QueueTransport(inspect_response(advertised())), commands) + session = agent.service("api.example.com") + if accepted: + result = await session.enroll(EnrollOptions(acknowledged_document_ids=("terms",))) + assert result.body.acknowledged_document_ids == ("terms",) + else: + with pytest.raises(AgentCommandError, match="acknowledgement"): + await session.enroll(EnrollOptions(acknowledged_document_ids=("terms",))) + assert len(commands.requests) == 1 + assert json.loads(commands.requests[0].body or b"{}")["acknowledged_document_ids"] == [ + "terms" + ] + commands = QueueTransport(response({"status": "active"})) + agent, _ = configured_agent(QueueTransport(inspect_response()), commands) + session = agent.service("api.example.com") + with pytest.raises(ValueError, match="advertise"): + await session.enroll(EnrollOptions(acknowledged_document_ids=())) + assert not commands.requests + assert (await session.enroll()).body.status is AgentStatus.ACTIVE + + +@pytest.mark.asyncio +async def test_agent_preserves_requirements_without_automatic_enroll_or_grant_retry() -> None: + problem = { + "type": "urn:aep:error:requirements_unmet", + "code": "requirements_unmet", + "title": "Accept terms", + "status": 422, + "requirements_pending": ["contact.email", "service_documents"], + "required_document_ids": ["terms"], + } + commands = QueueTransport( + response({"status": "active", "acknowledged_document_ids": ["terms"]}), + response(problem, status=422, headers={"Content-Type": AEP_PROBLEM_MEDIA_TYPE}), + ) + agent, _ = configured_agent(QueueTransport(inspect_response(advertised())), commands) + session = agent.service("api.example.com") + await session.enroll(EnrollOptions(acknowledged_document_ids=("terms",))) + with pytest.raises(AgentCommandError) as error: + await session.grant(GrantOptions(grant_type="api-key")) + assert error.value.problem and error.value.problem.required_document_ids == ("terms",) + assert len(commands.requests) == 2 + + +@pytest.mark.asyncio +async def test_agent_and_service_document_reenrollment_through_asgi() -> None: + key = Ed25519PrivateKey.generate() + store = MemoryEnrollmentStore() + handler = GrantHandler() + handler.response = json.dumps( + { + "credential_id": "document-credential", + "api_key": "secret", + "header": "X-Key", + "expires_at": (NOW + timedelta(hours=1)).isoformat(), + } + ).encode() + service = Service( + ServiceOptions( + service_did="did:web:service.example", + identity_methods=("did:web",), + clock=lambda: NOW, + verifier=Verifier(key), + enrollment_store=store, + extensions=(AEP_SERVICE_DOCUMENTS_EXTENSION,), + service_documents=(descriptor(), descriptor("privacy")), + claims=InspectClaims(required=("contact.email",)), + grant_types=(GrantTypeDefinition(grant_type="api-key", handler=handler),), + ) + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=AepAsgiApplication(service)) + ) as client: + transport = HttpxTransport(client) + async with Agent( + AgentOptions( + identity_provider=LocalIdentityProvider(key), + clock=lambda: NOW, + command_transport=transport, + inspect_transport=transport, + ) + ) as agent: + session = agent.service("https://service.example") + inspection = await session.inspect() + assert inspection.document.service_documents == (descriptor(), descriptor("privacy")) + with pytest.raises(AgentCommandError) as unmet: + await session.enroll() + assert unmet.value.problem and unmet.value.problem.required_document_ids == ( + "terms", + "privacy", + ) + accepted = await session.enroll( + EnrollOptions( + claims=ClaimValues.model_validate({"contact.email": "owner@example.com"}), + acknowledged_document_ids=("terms",), + ) + ) + assert accepted.body.acknowledged_document_ids == ("terms",) + original = await store.find(AGENT_DID) + assert original + with pytest.raises(AgentCommandError) as missing: + await session.grant(GrantOptions(grant_type="api-key")) + assert missing.value.problem and missing.value.problem.required_document_ids == ( + "privacy", + ) + assert handler.grants == [] + accepted = await session.enroll(EnrollOptions(acknowledged_document_ids=("privacy",))) + assert accepted.body.acknowledged_document_ids == ("privacy",) + record = await store.find(AGENT_DID) + assert record and record.enrollment_id == original.enrollment_id + assert record.claims == original.claims + assert record.acknowledged_document_ids == ("terms", "privacy") + assert (await session.grant(GrantOptions(grant_type="api-key"))).status == 200 + assert len(handler.grants) == 1