diff --git a/api/openapi.json b/api/openapi.json index d0badfb..65198de 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1427,8 +1427,8 @@ } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { @@ -1437,8 +1437,8 @@ } } }, - "404": { - "description": "Not Found", + "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { @@ -1447,8 +1447,8 @@ } } }, - "409": { - "description": "Conflict", + "422": { + "description": "Unprocessable Content", "content": { "application/problem+json": { "schema": { @@ -1457,12 +1457,18 @@ } } }, - "422": { - "description": "Unprocessable Content", + "403": { + "description": "Forbidden. When the operation requires current MFA assurance that the presented token does not carry, `type` is `https://testing.nc3.lu/problems/step-up-required` and the remedy is a fresh authentication with an MFA method.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetail" + }, + "example": { + "type": "https://testing.nc3.lu/problems/step-up-required", + "title": "Step-up authentication required", + "status": 403, + "detail": "This operation requires a recent multi-factor authentication event." } } } @@ -4230,8 +4236,8 @@ } } }, - "403": { - "description": "Forbidden", + "422": { + "description": "Unprocessable Content", "content": { "application/problem+json": { "schema": { @@ -4240,12 +4246,18 @@ } } }, - "422": { - "description": "Unprocessable Content", + "403": { + "description": "Forbidden. When the operation requires current MFA assurance that the presented token does not carry, `type` is `https://testing.nc3.lu/problems/step-up-required` and the remedy is a fresh authentication with an MFA method.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetail" + }, + "example": { + "type": "https://testing.nc3.lu/problems/step-up-required", + "title": "Step-up authentication required", + "status": 403, + "detail": "This operation requires a recent multi-factor authentication event." } } } @@ -4309,8 +4321,8 @@ } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { @@ -4319,8 +4331,8 @@ } } }, - "404": { - "description": "Not Found", + "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { @@ -4329,12 +4341,18 @@ } } }, - "409": { - "description": "Conflict", + "403": { + "description": "Forbidden. When the operation requires current MFA assurance that the presented token does not carry, `type` is `https://testing.nc3.lu/problems/step-up-required` and the remedy is a fresh authentication with an MFA method.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetail" + }, + "example": { + "type": "https://testing.nc3.lu/problems/step-up-required", + "title": "Step-up authentication required", + "status": 403, + "detail": "This operation requires a recent multi-factor authentication event." } } } @@ -4396,15 +4414,23 @@ "summary": "Record an account-level response", "description": "Record acceptance of an account-level statement.\n\nRejects any statement that requires a context: a per-launch declaration is bound\nto the launch it belongs to and travels in the launch payload, so recording one\nhere would produce a receipt attached to nothing.", "operationId": "record_statement_response_api_v1_statement_responses_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatementResponseSubmission" } } - }, - "required": true + } }, "responses": { "201": { @@ -4457,7 +4483,15 @@ } } } - }, + } + }, + "get": { + "tags": [ + "statements" + ], + "summary": "List the caller's statement responses", + "description": "Receipts the caller has recorded, account-level and context-bound alike.\n\nThe readback behind the acceptance prompt: a statement in force whose current\nversion has no receipt here is one the caller has yet to answer.\n\nCursor-paginated from the start: account-level receipts stay countable on\none hand, but context-bound receipts arrive per launch, and turning a bare\narray into a page envelope later would break the contract.", + "operationId": "list_statement_responses_api_v1_statement_responses_get", "security": [ { "OpenIdConnect": [] @@ -4465,7 +4499,73 @@ { "ApiKey": [] } - ] + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_StatementResponseReceipt_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } } }, "/api/v1/admin/audit-events": { @@ -6391,6 +6491,34 @@ ], "title": "Page[Schedule]" }, + "Page_StatementResponseReceipt_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/StatementResponseReceipt" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[StatementResponseReceipt]" + }, "ProblemDetail": { "properties": { "type": { @@ -7964,6 +8092,16 @@ "format": "uuid7", "title": "Statement Id" }, + "statement_key": { + "type": "string", + "title": "Statement Key", + "description": "Statement identifier, restated so a receipt readback needs no join against retired statement versions." + }, + "version": { + "type": "string", + "title": "Version", + "description": "Exact version answered." + }, "responded_at": { "type": "string", "format": "date-time", @@ -7999,6 +8137,8 @@ "required": [ "id", "statement_id", + "statement_key", + "version", "responded_at" ], "title": "StatementResponseReceipt", diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index a03007d..6789385 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -11,7 +11,7 @@ - Inside the JSON media type, the access state selects the variant: authenticated requests carry `asset_id`, unauthenticated requests carry `target`. - Seven operations accept an anonymous caller: `POST /scans`, the three guest scan reads (§2.3), `GET /statements`, `GET /invitations/{token}`, `GET /feeds/{token}`. Every other operation requires an OpenID Connect token or a platform API key and answers `401` without one. `/healthz` and `/readyz` sit outside `/api/v1`. - `ScanJob` and `ScanTask` state comes from the API. SSE events are advisory; when an event and a read disagree, the read is correct. -- Errors use the RFC 9457 Problem Details envelope. +- Errors use the RFC 9457 Problem Details envelope. A problem `type` URI is minted only where a client must discriminate between remedies for the same status code (§5.1's step-up type); every other error carries `about:blank`. - Some collection endpoints use cursor pagination and stable ordering. Lists order newest first, keyed on the UUIDv7 `id`; the audit log orders by (`chain_id`, `sequence_number`) (§15). - Shapes fixed by the generated contract are versioned by the OpenAPI document itself. Three payloads live outside the document and carry their own `schema_version`: scan results, webhook payloads, and notification `data`. - The identity provider owns identity, credentials, authentication methods, sessions, MFA enrollment, current assurance, and platform-administrator claims. The application owns its `app_user` projection, organization membership and role, assets, and verification state. @@ -179,6 +179,7 @@ Rules: - `POST .../verification` requires current MFA assurance. - Current MFA assurance is read from the identity provider's session or token. An operation that requires it therefore declares only the OpenID Connect scheme: a platform API key carries no assurance. - Assurance is the `amr` claim carrying an MFA method, current while the authentication event is within the configured `max_age`. The platform reads `amr` rather than `acr`: `amr` values are an IANA-registered vocabulary (RFC 8176), while `acr` values are defined per identity provider, and the platform assumes no provider-specific vocabulary. +- An operation that requires assurance the presented token does not carry answers `403` with problem `type` `https://testing.nc3.lu/problems/step-up-required`. The type is the discriminator between step-up — remedied by a fresh authentication with an MFA method — and a plain forbidden, which no retry remedies. - A verified domain is rechecked before an intrusive task is queued. No v4.0 test is intrusive, so nothing rechecks automatically in the MVP. ### 5.2 Feeds @@ -350,12 +351,14 @@ POST /api/v1/api-keys/{key_id}/revoke ```http GET /api/v1/statements +GET /api/v1/statement-responses POST /api/v1/statement-responses ``` - `GET /statements` returns the statements currently in force — `effective_at` reached, not retired — each with its `id`, `statement_key`, `version`, `response_kind`, `required_context_type`, `content_hash`, `content_uri`, and `effective_at`. Unauthenticated. - A client must send the exact version it answered. This operation is the only way to learn which version is current. - `POST /statement-responses` records an account-level response, where `required_context_type` is null. It rejects a statement that requires a context; a per-launch declaration travels in the launch payload (§2.1). +- `GET /statement-responses` returns the caller's receipts, account-level and context-bound alike, each restating the `statement_key` and the exact `version` answered. A statement in force whose current version has no receipt is one the caller has yet to answer — the readback behind the acceptance prompt. Authenticated; cursor-paginated, because context-bound receipts arrive per launch. - No v4.0 executable test is classified as intrusive, so no v4.0 launch requires a per-launch declaration. ## 15. Audit log diff --git a/src/nc3_testing_platform/core/errors.py b/src/nc3_testing_platform/core/errors.py index 0552717..3231412 100644 --- a/src/nc3_testing_platform/core/errors.py +++ b/src/nc3_testing_platform/core/errors.py @@ -72,6 +72,43 @@ def problem_responses(*status_codes: int) -> dict[int | str, dict]: } +STEP_UP_PROBLEM_TYPE = "https://testing.nc3.lu/problems/step-up-required" + + +def step_up_forbidden() -> dict[int | str, dict]: + """The `403` entry for an operation gated on current MFA assurance. + + The problem `type` is the discriminator a client retry loop needs: step-up + is remedied by a fresh authentication with an MFA method, where a plain + forbidden is not remedied by any retry. Merge into a route's `responses` + alongside :func:`problem_responses`. + """ + return { + 403: { + "model": ProblemDetail, + "description": ( + "Forbidden. When the operation requires current MFA assurance " + "that the presented token does not carry, `type` is " + f"`{STEP_UP_PROBLEM_TYPE}` and the remedy is a fresh " + "authentication with an MFA method." + ), + "content": { + PROBLEM_MEDIA_TYPE: { + "example": { + "type": STEP_UP_PROBLEM_TYPE, + "title": "Step-up authentication required", + "status": 403, + "detail": ( + "This operation requires a recent multi-factor " + "authentication event." + ), + } + } + }, + } + } + + async def _http_exception_handler( request: Request, exc: StarletteHTTPException ) -> ProblemResponse: @@ -165,14 +202,19 @@ def _responses(schema: dict) -> Iterator[dict]: def _relabel_problem_media_type(schema: dict) -> None: - """In-place: move ProblemDetail error bodies from application/json to problem+json.""" + """In-place: move ProblemDetail error bodies from application/json to problem+json. + + Keys already declared under problem+json (an `example`, as in + :func:`step_up_forbidden`) win over the generated body. + """ for response in _responses(schema): content = response.get("content") if not content: continue json_body = content.get("application/json") if json_body and json_body.get("schema", {}).get("$ref") == _PROBLEM_REF: - content[PROBLEM_MEDIA_TYPE] = content.pop("application/json") + declared = content.get(PROBLEM_MEDIA_TYPE) or {} + content[PROBLEM_MEDIA_TYPE] = {**content.pop("application/json"), **declared} def _replace_default_validation_body(schema: dict) -> None: diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py index 9cf121f..b2b6156 100644 --- a/src/nc3_testing_platform/core/security.py +++ b/src/nc3_testing_platform/core/security.py @@ -104,7 +104,9 @@ def require_current_mfa_assurance(claims: dict[str, object]) -> None: """Rejects claims whose authentication event lacks current MFA assurance. Assurance is `amr` carrying an MFA method within the configured `max_age`. - A failure answers `401` with the RFC 9470 `insufficient_user_authentication` challenge. + A failure answers `403` with problem `type` :data:`~nc3_testing_platform.core.errors.STEP_UP_PROBLEM_TYPE`: + the caller is authenticated but under-assured, and the type distinguishes the remedy — a fresh authentication with an MFA method — from a plain forbidden, which no retry fixes. + RFC 9470's `WWW-Authenticate` challenge is deliberately not used: its parameters are `acr`-based, while the API design's §5.1 reads the `amr` claim (RFC 8176's registered vocabulary) precisely because `acr` values are provider-defined. """ raise NotImplementedError diff --git a/src/nc3_testing_platform/domains/api_keys/router.py b/src/nc3_testing_platform/domains/api_keys/router.py index 94c99e2..9e62b78 100644 --- a/src/nc3_testing_platform/domains/api_keys/router.py +++ b/src/nc3_testing_platform/domains/api_keys/router.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Response, status from nc3_testing_platform.core.enums import ApiKeyScope -from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.errors import problem_responses, step_up_forbidden from nc3_testing_platform.core.pagination import CursorPage, Page from nc3_testing_platform.core.schemas import ResourceId from nc3_testing_platform.core.security import ( @@ -72,7 +72,8 @@ async def list_api_keys(page: CursorPage) -> Page[ApiKey]: summary="Create an API key", responses={ 201: {"headers": NO_STORE_HEADERS}, - **problem_responses(401, 403, 422), + **problem_responses(401, 422), + **step_up_forbidden(), }, dependencies=[OidcRequired], ) @@ -92,7 +93,7 @@ async def create_api_key(body: ApiKeyCreate, response: Response) -> ApiKeyCreate @router.post( "/{key_id}/revoke", summary="Revoke an API key", - responses=problem_responses(401, 403, 404, 409), + responses={**problem_responses(401, 404, 409), **step_up_forbidden()}, dependencies=[OidcRequired], ) async def revoke_api_key(key_id: ResourceId, body: ApiKeyRevoke) -> ApiKey: diff --git a/src/nc3_testing_platform/domains/assets/router.py b/src/nc3_testing_platform/domains/assets/router.py index f944ba1..54000b5 100644 --- a/src/nc3_testing_platform/domains/assets/router.py +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Response, status from nc3_testing_platform.core.enums import VerificationStatus -from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.errors import problem_responses, step_up_forbidden from nc3_testing_platform.core.pagination import CursorPage, Page from nc3_testing_platform.core.schemas import ResourceId from nc3_testing_platform.core.security import ( @@ -133,7 +133,7 @@ async def get_verification(asset_id: ResourceId) -> DomainVerification: "/{asset_id}/verification", status_code=status.HTTP_201_CREATED, summary="Start a verification challenge", - responses=problem_responses(401, 403, 404, 409, 422), + responses={**problem_responses(401, 404, 409, 422), **step_up_forbidden()}, dependencies=[OidcRequired], ) async def create_verification( diff --git a/src/nc3_testing_platform/domains/statements/router.py b/src/nc3_testing_platform/domains/statements/router.py index 890333a..8aad211 100644 --- a/src/nc3_testing_platform/domains/statements/router.py +++ b/src/nc3_testing_platform/domains/statements/router.py @@ -3,10 +3,11 @@ from datetime import UTC, datetime from uuid import UUID -from fastapi import APIRouter, status +from fastapi import APIRouter, HTTPException, status from nc3_testing_platform.core.enums import StatementResponseKind from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page from nc3_testing_platform.core.security import CredentialRequired from nc3_testing_platform.domains.statements.schemas import ( Statement, @@ -20,6 +21,21 @@ _STATEMENT_ID = UUID("019ee1a2-0011-7c22-8d33-4e55f6a77b88") _RECEIPT_ID = UUID("019ee1a2-1122-7d33-9e44-5f66a7b88c99") +# The v4.0 statement keys whose statement carries a non-null +# required_context_type — code-owned, like the catalog itself (data model §5.1). +_CONTEXT_BOUND_KEYS = frozenset({"scan_target_permission", "intrusive_scan_risk_liability"}) + + +def _sample_receipt() -> StatementResponseReceipt: + """The caller's acceptance of the current Terms version.""" + return StatementResponseReceipt( + id=_RECEIPT_ID, + statement_id=_STATEMENT_ID, + statement_key="terms_and_conditions", + version="2026-01-15", + responded_at=datetime(2026, 7, 31, 9, 0, tzinfo=UTC), + ) + @router.get( "/statements", @@ -71,8 +87,39 @@ async def record_statement_response( to the launch it belongs to and travels in the launch payload, so recording one here would produce a receipt attached to nothing. """ + if body.statement_key in _CONTEXT_BOUND_KEYS: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "This statement requires a scan_job context; a per-launch " + "declaration travels in the launch payload." + ), + ) return StatementResponseReceipt( id=_RECEIPT_ID, statement_id=_STATEMENT_ID, + statement_key=body.statement_key, + version=body.version, responded_at=datetime(2026, 7, 31, 9, 0, tzinfo=UTC), ) + + +@router.get( + "/statement-responses", + summary="List the caller's statement responses", + responses=problem_responses(401), + dependencies=[CredentialRequired], +) +async def list_statement_responses( + page: CursorPage, +) -> Page[StatementResponseReceipt]: + """Receipts the caller has recorded, account-level and context-bound alike. + + The readback behind the acceptance prompt: a statement in force whose current + version has no receipt here is one the caller has yet to answer. + + Cursor-paginated from the start: account-level receipts stay countable on + one hand, but context-bound receipts arrive per launch, and turning a bare + array into a page envelope later would break the contract. + """ + return Page(items=[_sample_receipt()], next_cursor=None) diff --git a/src/nc3_testing_platform/domains/statements/schemas.py b/src/nc3_testing_platform/domains/statements/schemas.py index 34cc84a..e97d8bd 100644 --- a/src/nc3_testing_platform/domains/statements/schemas.py +++ b/src/nc3_testing_platform/domains/statements/schemas.py @@ -73,6 +73,13 @@ class StatementResponseReceipt(BaseSchema): id: ResourceId statement_id: ResourceId + statement_key: str = Field( + description=( + "Statement identifier, restated so a receipt readback needs no join " + "against retired statement versions." + ), + ) + version: str = Field(description="Exact version answered.") responded_at: Timestamp context_type: str | None = Field( default=None, description="Null for an account-level response." diff --git a/tests/test_smoke_surface.py b/tests/test_smoke_surface.py index 83c3bd1..b9d05a6 100644 --- a/tests/test_smoke_surface.py +++ b/tests/test_smoke_surface.py @@ -217,6 +217,7 @@ class Case: "/api/v1/statement-responses", json_body={"statement_key": "terms_and_conditions", "version": "1.0.0"}, ), + Case("get", "/api/v1/statement-responses"), Case("get", "/api/v1/admin/audit-events"), Case("get", "/healthz"), Case("get", "/readyz"),