diff --git a/control_plane/http_app.py b/control_plane/http_app.py index 7ec60cdf..03169d6d 100644 --- a/control_plane/http_app.py +++ b/control_plane/http_app.py @@ -1,5 +1,6 @@ import hashlib import json +import secrets from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path as FilePath @@ -30,6 +31,7 @@ ) from control_plane.contracts.product_environment_read_model import ( ProductEnvironmentConfigStatus, + ProductReadModelStore, ) from control_plane.contracts.product_profile_record import LaunchplaneProductProfileRecord from control_plane.contracts.preview_evidence import ( @@ -219,6 +221,23 @@ class RecentOperationsResponse(BaseModel): recent_previews: tuple[PreviewRecord, ...] +class ProductProfileListResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: Literal["ok"] = "ok" + trace_id: str + driver_id: str + profiles: tuple[LaunchplaneProductProfileRecord, ...] + + +class ProductProfileResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: Literal["ok"] = "ok" + trace_id: str + profile: LaunchplaneProductProfileRecord + + class SecretStatusBinding(BaseModel): model_config = ConfigDict(extra="forbid") @@ -698,6 +717,26 @@ def require_recent_operations_read_store(record_store: object) -> _RecentOperati return cast(_RecentOperationsReadStore, record_store) +def require_product_profile_list_store(record_store: object) -> ProductReadModelStore: + list_records = getattr(record_store, "list_product_profile_records", None) + if not callable(list_records): + raise TypeError( + "Launchplane record store does not support product profile list reads: " + "list_product_profile_records" + ) + return cast(ProductReadModelStore, record_store) + + +def require_product_profile_read_store(record_store: object) -> ProductReadModelStore: + read_record = getattr(record_store, "read_product_profile_record", None) + if not callable(read_record): + raise TypeError( + "Launchplane record store does not support product profile reads: " + "read_product_profile_record" + ) + return cast(ProductReadModelStore, record_store) + + def require_secret_status_read_store(record_store: object) -> control_plane_secrets.SecretReadStore: required_methods = ( "read_secret_record", @@ -981,6 +1020,34 @@ def read_identity( return human_identity raise _authentication_required_error("Authorization header is required.") + def every_code_worker_token_authorized(authorization: str) -> bool: + if bearer_identity_config is None: + return False + expected_token = bearer_identity_config.every_code_worker_token.strip() + if not expected_token: + return False + header = authorization.strip() + scheme, _, token = header.partition(" ") + bearer_token = token.strip() + if scheme.lower() != "bearer" or not bearer_token: + return False + return secrets.compare_digest(bearer_token, expected_token) + + def read_product_profile_list_identity( + request: Request, + response: Response, + authorization: Annotated[str, Header(alias="Authorization")] = "", + cookie: Annotated[str, Header(alias="Cookie")] = "", + ) -> LaunchplaneIdentity | None: + if every_code_worker_token_authorized(authorization): + return None + return read_identity( + request=request, + response=response, + authorization=authorization, + cookie=cookie, + ) + def read_write_identity( authorization: Annotated[str, Header(alias="Authorization")] = "", ) -> LaunchplaneIdentity: @@ -1491,6 +1558,89 @@ def read_recent_operations( recent_previews=recent_previews, ) + def list_product_profiles( + identity: Annotated[ + LaunchplaneIdentity | None, Depends(read_product_profile_list_identity) + ], + record_store: Annotated[object, Depends(get_record_store)], + driver_id: Annotated[str, Query()] = "", + ) -> ProductProfileListResponse: + trace_id = next_trace_id() + if identity is not None and not resolved_authz_policy_runtime.policy.allows( + identity=identity, + action="product_profile.read", + product="launchplane", + context=_LAUNCHPLANE_SERVICE_CONTEXT, + ): + raise _launchplane_http_error( + status_code=403, + trace_id=trace_id, + code="authorization_denied", + message="Workflow cannot list Launchplane product profiles.", + ) + normalized_driver_id = driver_id.strip() + try: + profile_store = require_product_profile_list_store(record_store) + product_profile_payload = ( + control_plane_product_read_service.build_product_profile_list_service_payload( + record_store=profile_store, + driver_id=normalized_driver_id, + ) + ) + except TypeError as error: + raise _launchplane_http_error( + status_code=503, + trace_id=trace_id, + code="database_storage_required", + message=str(error), + ) from error + payload_profiles = cast(list[object], product_profile_payload["profiles"]) + profiles = tuple( + LaunchplaneProductProfileRecord.model_validate(profile) for profile in payload_profiles + ) + return ProductProfileListResponse( + trace_id=trace_id, + driver_id=str(product_profile_payload["driver_id"]), + profiles=profiles, + ) + + def read_product_profile( + product: Annotated[str, Path(min_length=1, pattern=r"^\S+$")], + identity: Annotated[LaunchplaneIdentity, Depends(read_identity)], + record_store: Annotated[object, Depends(get_record_store)], + ) -> ProductProfileResponse: + trace_id = next_trace_id() + try: + profile_store = require_product_profile_read_store(record_store) + profile = profile_store.read_product_profile_record(product) + except TypeError as error: + raise _launchplane_http_error( + status_code=503, + trace_id=trace_id, + code="database_storage_required", + message=str(error), + ) from error + except FileNotFoundError as error: + raise _launchplane_http_error( + status_code=404, + trace_id=trace_id, + code="not_found", + message=str(error), + ) from error + if not resolved_authz_policy_runtime.policy.allows( + identity=identity, + action="product_profile.read", + product=profile.product, + context=_LAUNCHPLANE_SERVICE_CONTEXT, + ): + raise _launchplane_http_error( + status_code=403, + trace_id=trace_id, + code="authorization_denied", + message="Workflow cannot read the requested product profile.", + ) + return ProductProfileResponse(trace_id=trace_id, profile=profile) + def read_product_context_cutover_audit( product: Annotated[str, Path(min_length=1, pattern=r"^\S+$")], identity: Annotated[LaunchplaneIdentity, Depends(read_identity)], @@ -2556,6 +2706,35 @@ def preserve_renewed_session_cookie(request: Request, response: JSONResponse) -> }, ) + app.add_api_route( + "/v1/product-profiles", + list_product_profiles, + methods=["GET"], + response_model=ProductProfileListResponse, + operation_id="list_product_profiles", + summary="List Launchplane product profiles", + responses={ + 401: {"model": LaunchplaneErrorResponse}, + 403: {"model": LaunchplaneErrorResponse}, + 503: {"model": LaunchplaneErrorResponse}, + }, + ) + + app.add_api_route( + "/v1/product-profiles/{product}", + read_product_profile, + methods=["GET"], + response_model=ProductProfileResponse, + operation_id="read_product_profile", + summary="Read a Launchplane product profile", + responses={ + 401: {"model": LaunchplaneErrorResponse}, + 403: {"model": LaunchplaneErrorResponse}, + 404: {"model": LaunchplaneErrorResponse}, + 503: {"model": LaunchplaneErrorResponse}, + }, + ) + app.add_api_route( "/v1/product-profiles/{product}/context-cutover-audit", read_product_context_cutover_audit, diff --git a/control_plane/service.py b/control_plane/service.py index a029a068..235c62ed 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -181,7 +181,6 @@ from control_plane.contracts.preview_readiness_read_model import ( build_preview_readiness_read_model, ) -from control_plane.contracts.product_environment_read_model import ProductReadModelStore from control_plane.contracts.product_profile_record import ( LaunchplaneProductProfileRecord, ProductLaneProfile, @@ -4553,10 +4552,6 @@ def _match_read_route(path: str) -> tuple[str, dict[str, str]] | None: return "work_graph.issue_inbox", {} if len(segments) == 2 and segments == ["v1", "repo-product-mapping"]: return "product_environment.read", {"repo_product_mapping": "true"} - if len(segments) == 2 and segments == ["v1", "product-profiles"]: - return "product_profile.read", {} - if len(segments) == 3 and segments[:2] == ["v1", "product-profiles"]: - return "product_profile.read", {"product": segments[2]} if len(segments) == 2 and segments == ["v1", "products"]: return "product_environment.read", {} if len(segments) == 4 and segments == [ @@ -8415,6 +8410,7 @@ def _local_admin_token_label_from_env() -> str: def _bearer_identity_config_from_env() -> BearerIdentityConfig: return BearerIdentityConfig( + every_code_worker_token=_every_code_worker_token_from_env(), local_admin_token=_local_admin_token_from_env(), local_admin_subject=_local_admin_subject_from_env(), local_admin_token_label=_local_admin_token_label_from_env(), @@ -8439,8 +8435,6 @@ def _owner_agent_identity_from_bearer(environ: dict[str, object]) -> Launchplane def _is_every_code_worker_route(*, method: str, path: str) -> bool: - if method == "GET" and path == "/v1/product-profiles": - return True if method == "GET" and path == "/v1/previews/readiness": return True if method == "GET" and path == "/v1/every-code/summary": @@ -8494,12 +8488,6 @@ def _every_code_read_payload( query: dict[str, list[str]], ) -> dict[str, object]: segments = [segment for segment in path.split("/") if segment] - if path == "/v1/product-profiles": - driver_id_filter = str((query.get("driver_id") or [""])[0] or "").strip() - return control_plane_product_read_service.build_product_profile_list_service_payload( - record_store=cast(ProductReadModelStore, record_store), - driver_id=driver_id_filter, - ) every_code_store = _every_code_work_request_store(record_store) if path == "/v1/previews/readiness": repository_filter = str((query.get("repository") or [""])[0] or "").strip() @@ -11347,78 +11335,6 @@ def start_response_with_session_cookie(status: str, headers: list[tuple[str, str "targets": targets, }, ) - if action == "product_profile.read": - if "product" in params: - profile = record_store.read_product_profile_record(params["product"]) - if not authz_policy.allows( - identity=identity, - action=action, - product=profile.product, - context=_LAUNCHPLANE_SERVICE_CONTEXT, - ): - return _json_response( - start_response=start_response, - status_code=403, - payload={ - "status": "rejected", - "trace_id": request_trace_id, - "error": { - "code": "authorization_denied", - "message": "Workflow cannot read the requested product profile.", - }, - "authz": _authz_diagnostic_payload( - identity=identity, - authz_policy_sha256_value=resolved_authz_policy_sha256, - authz_policy_source=resolved_authz_policy_source, - ), - }, - ) - return _json_response( - start_response=start_response, - status_code=200, - payload={ - "status": "ok", - "trace_id": request_trace_id, - "profile": profile.model_dump(mode="json"), - }, - ) - if not authz_policy.allows( - identity=identity, - action=action, - product="launchplane", - context=_LAUNCHPLANE_SERVICE_CONTEXT, - ): - return _json_response( - start_response=start_response, - status_code=403, - payload={ - "status": "rejected", - "trace_id": request_trace_id, - "error": { - "code": "authorization_denied", - "message": "Workflow cannot list Launchplane product profiles.", - }, - "authz": _authz_diagnostic_payload( - identity=identity, - authz_policy_sha256_value=resolved_authz_policy_sha256, - authz_policy_source=resolved_authz_policy_source, - ), - }, - ) - driver_id_filter = str((query.get("driver_id") or [""])[0] or "").strip() - product_profile_payload = control_plane_product_read_service.build_product_profile_list_service_payload( - record_store=record_store, - driver_id=driver_id_filter, - ) - return _json_response( - start_response=start_response, - status_code=200, - payload={ - "status": "ok", - "trace_id": request_trace_id, - **product_profile_payload, - }, - ) if action == "product_environment.read": def product_action_allowed( diff --git a/control_plane/service_auth.py b/control_plane/service_auth.py index f6f320c2..17fad8d6 100644 --- a/control_plane/service_auth.py +++ b/control_plane/service_auth.py @@ -94,6 +94,7 @@ def verify(self, token: str) -> GitHubActionsIdentity: ... class BearerIdentityConfig(BaseModel): model_config = ConfigDict(extra="forbid") + every_code_worker_token: str = "" local_admin_token: str = "" local_admin_subject: str = "" local_admin_token_label: str = "" diff --git a/docs/compatibility-retirement.md b/docs/compatibility-retirement.md index 66d353ce..c76a9ca3 100644 --- a/docs/compatibility-retirement.md +++ b/docs/compatibility-retirement.md @@ -61,10 +61,11 @@ Keep a compatibility surface only when it is one of these: WSGI branches are deleted; cleanup callers use the service route instead of a second production implementation. - Deployment, promotion, preview, inventory, recent-operations, - managed-secret status, and product context cutover audit reads use native - FastAPI routes for bearer-token and human-session callers. Their legacy WSGI - branches are deleted; direct fallback calls fail closed while the mounted - fallback remains for retained non-native routes. + managed-secret status, product-profile, and product context cutover audit + reads use native FastAPI routes for bearer-token and human-session callers. + The product-profile collection also preserves the dedicated Every Code worker + token. Their legacy WSGI branches are deleted; direct fallback calls fail + closed while the mounted fallback remains for retained non-native routes. - Deployment, backup-gate, promotion, preview generation, preview destroyed, runner-host hygiene audit, and runner-lane registration audit evidence ingestion use native FastAPI routes for bearer-token callers and preserve the diff --git a/docs/service-boundary.md b/docs/service-boundary.md index e517e0ee..0ab94992 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -93,8 +93,10 @@ VeriReel product paths: bearer-token callers, with Pydantic/OpenAPI contract coverage, idempotency replay preservation, and runner-lane registration audit storage) - product profile routes: - - `GET /v1/product-profiles` - - `GET /v1/product-profiles/{product}` + - `GET /v1/product-profiles` (native FastAPI for bearer-token, + human-session, and Every Code worker-token callers) + - `GET /v1/product-profiles/{product}` (native FastAPI for bearer-token and + human-session callers) - `POST /v1/product-profiles` - product config write route: - `POST /v1/product-config/apply` @@ -1280,6 +1282,10 @@ only after a passing plan and a matching stored preview record are present. human-session callers) - `GET /v1/contexts/{context}/operations/recent` (native FastAPI for bearer-token and human-session callers) +- `GET /v1/product-profiles` (native FastAPI for bearer-token, + human-session, and Every Code worker-token callers) +- `GET /v1/product-profiles/{product}` (native FastAPI for bearer-token and + human-session callers) - `GET /v1/product-profiles/{product}/context-cutover-audit` (native FastAPI for bearer-token and human-session callers) @@ -1305,13 +1311,19 @@ envelope. Managed-secret status list reads authorize `secret.list` against the path context before store access. Single managed-secret status reads load the metadata-only secret status to discover the stored secret context, then check `secret.read` against product `launchplane` and that stored context before -returning the typed status envelope. Product context cutover audit reads load -the product profile from DB-backed records, check `product_profile.read` against -the stored profile product and Launchplane service context, and require the -requested source, target, and optional preview contexts to belong to that -profile before returning the typed audit envelope. Their legacy WSGI branches -are deleted; direct fallback calls fail closed while the mounted fallback -remains for retained non-native routes. +returning the typed status envelope. Product profile list reads check +`product_profile.read` against product `launchplane` in the Launchplane service +context, preserve the `driver_id` filter, and continue accepting the dedicated +Every Code worker token for the collection route only. Product profile show +reads load the stored profile first, check `product_profile.read` against the +stored profile product and Launchplane service context, and return the typed +profile envelope. Product context cutover audit reads load the product profile +from DB-backed records, check `product_profile.read` against the stored profile +product and Launchplane service context, and require the requested source, +target, and optional preview contexts to belong to that profile before returning +the typed audit envelope. Their legacy WSGI branches are deleted; direct +fallback calls fail closed while the mounted fallback remains for retained +non-native routes. Product/site reads use action `product_environment.read`. They compose Launchplane-owned product profiles, driver descriptors, stable lane records, diff --git a/tests/test_http_app.py b/tests/test_http_app.py index b01e5dd1..efe980c8 100644 --- a/tests/test_http_app.py +++ b/tests/test_http_app.py @@ -67,6 +67,7 @@ from tests.test_service import ( _generic_site_profile_payload, _identity, + _product_profile_payload, _product_profile_payload_with_prod, _sqlite_database_url, ) @@ -937,6 +938,273 @@ async def test_fastapi_app_can_mount_legacy_wsgi_fallback(self) -> None: self.assertIn("trace_id", health_payload) +class FastApiProductProfileReadTests(unittest.IsolatedAsyncioTestCase): + async def test_list_product_profiles_returns_profiles_for_driver(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + record_store = FilesystemRecordStore(state_dir=Path(temporary_directory_name) / "state") + record_store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_product_profile_payload()) + ) + record_store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate( + {**_product_profile_payload("verireel"), "driver_id": "other-driver"} + ) + ) + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=_product_profile_read_policy(product="launchplane"), + record_store_factory=lambda: record_store, + ) + + response = await _get_product_profiles(app, driver_id="generic-web") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(set(payload), {"status", "trace_id", "driver_id", "profiles"}) + self.assertEqual(payload["status"], "ok") + self.assertTrue(payload["trace_id"].startswith("launchplane_req_")) + self.assertEqual(payload["driver_id"], "generic-web") + self.assertEqual( + [profile["product"] for profile in payload["profiles"]], + ["sellyouroutboard"], + ) + + async def test_show_product_profile_returns_profile(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + record_store = FilesystemRecordStore(state_dir=Path(temporary_directory_name) / "state") + record_store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_product_profile_payload()) + ) + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=_product_profile_read_policy(product="sellyouroutboard"), + record_store_factory=lambda: record_store, + ) + + response = await _get_product_profile(app) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(set(payload), {"status", "trace_id", "profile"}) + self.assertEqual(payload["profile"]["product"], "sellyouroutboard") + self.assertEqual(payload["profile"]["driver_id"], "generic-web") + self.assertEqual(payload["profile"]["preview"]["slug_template"], "pr-{number}") + + async def test_list_product_profiles_accepts_every_code_worker_token(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + record_store = FilesystemRecordStore(state_dir=Path(temporary_directory_name) / "state") + record_store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_product_profile_payload()) + ) + app = create_launchplane_fastapi_app( + verifier=_RejectingVerifier(), + authz_policy=LaunchplaneAuthzPolicy.model_validate({}), + record_store_factory=lambda: record_store, + bearer_identity_config=BearerIdentityConfig(every_code_worker_token="worker-token"), + ) + + list_response = await _get_product_profiles( + app, + driver_id="generic-web", + authorization="Bearer worker-token", + ) + show_response = await _get_product_profile( + app, + authorization="Bearer worker-token", + ) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.json()["profiles"][0]["product"], "sellyouroutboard") + self.assertEqual(show_response.status_code, 401) + self.assertEqual(show_response.json()["error"]["code"], "authentication_required") + + async def test_product_profile_reads_require_identity(self) -> None: + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=_product_profile_read_policy(product="launchplane"), + record_store_factory=lambda: _MissingProductReadStore(), + ) + + list_response = await _get_product_profiles(app, authorization="") + show_response = await _get_product_profile(app, authorization="") + + self.assertEqual(list_response.status_code, 401) + self.assertEqual(show_response.status_code, 401) + self.assertEqual(list_response.json()["error"]["code"], "authentication_required") + self.assertEqual(show_response.json()["error"]["code"], "authentication_required") + + async def test_list_product_profiles_rejects_unauthorized_caller(self) -> None: + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=_product_profile_read_policy(product="sellyouroutboard"), + record_store_factory=lambda: _MissingProductReadStore(), + ) + + response = await _get_product_profiles(app) + + self.assertEqual(response.status_code, 403) + payload = response.json() + self.assertEqual(payload["status"], "rejected") + self.assertEqual(payload["error"]["code"], "authorization_denied") + self.assertNotIn("authz", payload) + + async def test_show_product_profile_rejects_unauthorized_caller(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + record_store = FilesystemRecordStore(state_dir=Path(temporary_directory_name) / "state") + record_store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_product_profile_payload()) + ) + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=_product_profile_read_policy(product="verireel"), + record_store_factory=lambda: record_store, + ) + + response = await _get_product_profile(app) + + self.assertEqual(response.status_code, 403) + payload = response.json() + self.assertEqual(payload["status"], "rejected") + self.assertEqual(payload["error"]["code"], "authorization_denied") + self.assertNotIn("authz", payload) + + async def test_local_operator_token_cannot_read_product_profiles_without_grant(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + record_store = FilesystemRecordStore(state_dir=Path(temporary_directory_name) / "state") + record_store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_product_profile_payload()) + ) + app = create_launchplane_fastapi_app( + verifier=_RejectingVerifier(), + authz_policy=LaunchplaneAuthzPolicy.model_validate({}), + record_store_factory=lambda: record_store, + bearer_identity_config=_local_operator_bearer_config(), + ) + + list_response = await _get_product_profiles( + app, + authorization="Bearer local-operator-token", + ) + show_response = await _get_product_profile( + app, + authorization="Bearer local-operator-token", + ) + + self.assertEqual(list_response.status_code, 403) + self.assertEqual(show_response.status_code, 403) + self.assertEqual(list_response.json()["error"]["code"], "authorization_denied") + self.assertEqual(show_response.json()["error"]["code"], "authorization_denied") + self.assertNotIn("authz", list_response.json()) + self.assertNotIn("authz", show_response.json()) + + async def test_show_product_profile_returns_404_for_unknown_product(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + record_store = FilesystemRecordStore(state_dir=Path(temporary_directory_name) / "state") + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=_product_profile_read_policy(product="sellyouroutboard"), + record_store_factory=lambda: record_store, + ) + + response = await _get_product_profile(app) + + self.assertEqual(response.status_code, 404) + payload = response.json() + self.assertEqual(payload["status"], "rejected") + self.assertEqual(payload["error"]["code"], "not_found") + + async def test_product_profile_reads_require_matching_store_methods(self) -> None: + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=_product_profile_read_policy(product="launchplane"), + record_store_factory=lambda: _MissingProductReadStore(), + ) + + list_response = await _get_product_profiles(app) + show_response = await _get_product_profile(app) + + self.assertEqual(list_response.status_code, 503) + self.assertEqual(show_response.status_code, 503) + self.assertIn("list_product_profile_records", list_response.json()["error"]["message"]) + self.assertIn("read_product_profile_record", show_response.json()["error"]["message"]) + + async def test_openapi_includes_product_profile_read_contracts(self) -> None: + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=_product_profile_read_policy(product="launchplane"), + record_store_factory=lambda: _MissingProductReadStore(), + ) + + response = await _asgi_get(app, "/openapi.json") + + self.assertEqual(response.status_code, 200) + openapi = response.json() + list_route = openapi["paths"]["/v1/product-profiles"]["get"] + show_route = openapi["paths"]["/v1/product-profiles/{product}"]["get"] + self.assertEqual(list_route["operationId"], "list_product_profiles") + self.assertEqual(show_route["operationId"], "read_product_profile") + self.assertEqual( + list_route["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/ProductProfileListResponse", + ) + self.assertEqual( + show_route["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/ProductProfileResponse", + ) + self.assertEqual( + openapi["components"]["schemas"]["ProductProfileListResponse"]["additionalProperties"], + False, + ) + self.assertEqual( + openapi["components"]["schemas"]["ProductProfileResponse"]["additionalProperties"], + False, + ) + + async def test_fastapi_product_profile_reads_precede_legacy_wsgi_fallback(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + record_store = FilesystemRecordStore(state_dir=root / "state") + record_store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_product_profile_payload()) + ) + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "every/verireel", + "workflow_refs": [ + "every/verireel/.github/workflows/preview-control-plane.yml@refs/heads/main" + ], + "event_names": ["pull_request"], + "products": ["launchplane", "sellyouroutboard"], + "contexts": ["launchplane"], + "actions": ["product_profile.read"], + } + ] + } + ) + app = create_launchplane_fastapi_app( + verifier=_StubVerifier(_identity()), + authz_policy=policy, + record_store_factory=lambda: record_store, + ) + legacy_app = create_launchplane_service_app( + state_dir=root / "legacy-state", + verifier=_StubVerifier(_identity()), + authz_policy=LaunchplaneAuthzPolicy.model_validate({}), + control_plane_root_path=root, + ) + app.mount("/", cast(ASGIApp, WSGIMiddleware(cast(Any, legacy_app)))) + + list_response = await _get_product_profiles(app, driver_id="generic-web") + show_response = await _get_product_profile(app) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(show_response.status_code, 200) + self.assertEqual(list_response.json()["profiles"][0]["product"], "sellyouroutboard") + self.assertEqual(show_response.json()["profile"]["product"], "sellyouroutboard") + + class FastApiProductContextCutoverAuditReadTests(unittest.IsolatedAsyncioTestCase): async def test_context_cutover_audit_returns_redacted_metadata(self) -> None: with TemporaryDirectory() as temporary_directory_name: @@ -6442,6 +6710,37 @@ async def _get_recent_operations( ) +async def _get_product_profiles( + app: FastAPI, + *, + driver_id: str = "", + authorization: str = "Bearer valid-token", + headers: dict[str, str] | None = None, +) -> _AsgiResponse: + request_headers = dict(headers or {}) + if authorization: + request_headers["Authorization"] = authorization + suffix = f"?{urlencode({'driver_id': driver_id})}" if driver_id else "" + return await _asgi_get(app, f"/v1/product-profiles{suffix}", headers=request_headers) + + +async def _get_product_profile( + app: FastAPI, + product: str = "sellyouroutboard", + *, + authorization: str = "Bearer valid-token", + headers: dict[str, str] | None = None, +) -> _AsgiResponse: + request_headers = dict(headers or {}) + if authorization: + request_headers["Authorization"] = authorization + return await _asgi_get( + app, + f"/v1/product-profiles/{product}", + headers=request_headers, + ) + + async def _get_context_cutover_audit( app: FastAPI, *, diff --git a/tests/test_service.py b/tests/test_service.py index dd9cec90..6f36078a 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -12197,28 +12197,7 @@ def test_every_code_worker_token_can_read_claim_and_update_requests(self) -> Non self.assertEqual(status_status, 202) self.assertEqual(status_payload["result"]["request"]["state"], "done") - def test_every_code_worker_token_can_list_product_profiles_read_only(self) -> None: - policy = LaunchplaneAuthzPolicy.model_validate( - { - "github_actions": [ - { - "repository": "cbusillo/launchplane", - "workflow_refs": [ - "cbusillo/launchplane/.github/workflows/every-code-worker.yml@refs/heads/main" - ], - "event_names": ["workflow_dispatch"], - "products": ["launchplane"], - "contexts": ["launchplane"], - "actions": ["product_profile.write"], - } - ] - } - ) - identity = _identity( - repository="cbusillo/launchplane", - workflow_ref="cbusillo/launchplane/.github/workflows/every-code-worker.yml@refs/heads/main", - event_name="workflow_dispatch", - ) + def test_product_profile_reads_are_retired_from_legacy_wsgi_app(self) -> None: with ( TemporaryDirectory() as temporary_directory_name, patch.dict( @@ -12232,8 +12211,8 @@ def test_every_code_worker_token_can_list_product_profiles_read_only(self) -> No ) app = create_launchplane_service_app( state_dir=state_dir, - verifier=_StubVerifier(identity), - authz_policy=policy, + verifier=_StubVerifier(_identity()), + authz_policy=LaunchplaneAuthzPolicy(), control_plane_root_path=Path(temporary_directory_name), ) list_status, list_payload = _invoke_app( @@ -12249,76 +12228,11 @@ def test_every_code_worker_token_can_list_product_profiles_read_only(self) -> No path="/v1/product-profiles/sellyouroutboard", authorization="Bearer worker-token", ) - write_with_worker_status, write_with_worker_payload = _invoke_app( - app, - method="POST", - path="/v1/product-profiles", - payload=_product_profile_payload("verireel"), - authorization="Bearer worker-token", - ) - unrelated_status, unrelated_payload = _invoke_app( - app, - method="GET", - path="/v1/products", - authorization="Bearer worker-token", - ) - - self.assertEqual(list_status, 200) - self.assertEqual(list_payload["driver_id"], "generic-web") - self.assertEqual( - [profile["product"] for profile in list_payload["profiles"]], - ["sellyouroutboard"], - ) - self.assertEqual(show_status, 401) - self.assertEqual(show_payload["error"]["code"], "authentication_required") - self.assertEqual(write_with_worker_status, 400) - self.assertEqual( - write_with_worker_payload["error"]["code"], - "invalid_request", - ) - self.assertEqual(unrelated_status, 401) - self.assertEqual(unrelated_payload["error"]["code"], "authentication_required") - - def test_local_operator_token_cannot_read_product_profiles(self) -> None: - with ( - TemporaryDirectory() as temporary_directory_name, - patch.dict( - os.environ, - { - "LAUNCHPLANE_LOCAL_OPERATOR_TOKEN": "local-operator-token", - "LAUNCHPLANE_LOCAL_OPERATOR_SUBJECT": "local-owner-agent", - "LAUNCHPLANE_LOCAL_OPERATOR_TOKEN_LABEL": "local-owner-write", - }, - clear=True, - ), - ): - state_dir = Path(temporary_directory_name) / "state" - FilesystemRecordStore(state_dir=state_dir).write_product_profile_record( - LaunchplaneProductProfileRecord.model_validate(_product_profile_payload()) - ) - app = create_launchplane_service_app( - state_dir=state_dir, - verifier=_StubVerifier(_identity()), - authz_policy=LaunchplaneAuthzPolicy(), - control_plane_root_path=Path(temporary_directory_name), - ) - list_status, list_payload = _invoke_app( - app, - method="GET", - path="/v1/product-profiles", - authorization="Bearer local-operator-token", - ) - show_status, show_payload = _invoke_app( - app, - method="GET", - path="/v1/product-profiles/sellyouroutboard", - authorization="Bearer local-operator-token", - ) - self.assertEqual(list_status, 403) - self.assertEqual(list_payload["error"]["code"], "authorization_denied") - self.assertEqual(show_status, 403) - self.assertEqual(show_payload["error"]["code"], "authorization_denied") + self.assertEqual(list_status, 405) + self.assertEqual(list_payload["error"]["code"], "method_not_allowed") + self.assertEqual(show_status, 404) + self.assertEqual(show_payload["error"]["code"], "not_found") def test_every_code_worker_token_can_rerun_terminal_request(self) -> None: policy = LaunchplaneAuthzPolicy.model_validate( @@ -15087,7 +15001,7 @@ def test_tracked_target_logs_endpoint_requires_db_backed_storage(self) -> None: self.assertEqual(status_code, 503) self.assertEqual(payload["error"]["code"], "database_required") - def test_product_profile_endpoints_round_trip_authorized_record(self) -> None: + def test_product_profile_write_endpoint_persists_authorized_record(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name) policy = LaunchplaneAuthzPolicy.model_validate( @@ -15120,31 +15034,14 @@ def test_product_profile_endpoints_round_trip_authorized_record(self) -> None: payload=_product_profile_payload(), headers={"Idempotency-Key": "profile-sellyouroutboard"}, ) - show_status_code, show_payload = _invoke_app( - app, - method="GET", - path="/v1/product-profiles/sellyouroutboard", - ) - list_status_code, _, list_body = _invoke_raw_app( - app, - method="GET", - path="/v1/product-profiles", - authorization="Bearer valid-token", - query_string="driver_id=generic-web", - ) - list_payload = json.loads(list_body.decode("utf-8")) + stored_profile = FilesystemRecordStore( + state_dir=root / "state" + ).read_product_profile_record("sellyouroutboard") self.assertEqual(write_status_code, 202) self.assertEqual(write_payload["records"], {"product_profile": "sellyouroutboard"}) - self.assertEqual(show_status_code, 200) - self.assertEqual(show_payload["profile"]["driver_id"], "generic-web") - self.assertEqual(show_payload["profile"]["preview"]["slug_template"], "pr-{number}") - self.assertEqual(list_status_code, 200) - self.assertEqual(list_payload["driver_id"], "generic-web") - self.assertEqual( - [profile["product"] for profile in list_payload["profiles"]], - ["sellyouroutboard"], - ) + self.assertEqual(stored_profile.driver_id, "generic-web") + self.assertEqual(stored_profile.preview.slug_template, "pr-{number}") def test_product_profile_endpoint_rejects_inert_health_monitoring( self, @@ -17928,11 +17825,11 @@ def test_product_context_cutover_endpoint_updates_profile_for_authorized_workflo }, headers={"Idempotency-Key": "profile-context-cutover"}, ) - show_status_code, show_payload = _invoke_app( - app, - method="GET", - path="/v1/product-profiles/sellyouroutboard", - ) + store = PostgresRecordStore(database_url=database_url) + try: + stored_profile = store.read_product_profile_record("sellyouroutboard") + finally: + store.close() self.assertEqual(status_code, 202) self.assertEqual(payload["records"], {"product_profile": "sellyouroutboard"}) @@ -17940,13 +17837,12 @@ def test_product_context_cutover_endpoint_updates_profile_for_authorized_workflo self.assertEqual(replay_payload["records"], {"product_profile": "sellyouroutboard"}) self.assertEqual(replay_payload["result"], payload["result"]) self.assertEqual(payload["result"]["profile"]["display_name"], "SellYourOutboard") - self.assertEqual(show_status_code, 200) - self.assertEqual(show_payload["profile"]["display_name"], "SellYourOutboard") + self.assertEqual(stored_profile.display_name, "SellYourOutboard") self.assertEqual( - {lane["context"] for lane in show_payload["profile"]["lanes"]}, + {lane.context for lane in stored_profile.lanes}, {"sellyouroutboard"}, ) - self.assertEqual(show_payload["profile"]["preview"]["context"], "sellyouroutboard") + self.assertEqual(stored_profile.preview.context, "sellyouroutboard") def test_product_context_cutover_endpoint_rejects_contexts_outside_product_boundary( self, @@ -18117,43 +18013,6 @@ def test_context_cutover_audit_route_is_retired_from_legacy_wsgi_app(self) -> No self.assertEqual(status_code, 404) self.assertEqual(payload["error"]["code"], "not_found") - def test_product_profile_list_denial_includes_authz_diagnostics(self) -> None: - with TemporaryDirectory() as temporary_directory_name: - root = Path(temporary_directory_name) - policy = LaunchplaneAuthzPolicy.model_validate( - { - "github_actions": [ - { - "repository": "every/verireel", - "workflow_refs": [ - "every/verireel/.github/workflows/preview-control-plane.yml@refs/heads/main" - ], - "event_names": ["pull_request"], - "products": ["sellyouroutboard"], - "contexts": ["launchplane"], - "actions": ["product_profile.write"], - } - ] - } - ) - app = create_launchplane_service_app( - state_dir=root / "state", - verifier=_StubVerifier(_identity()), - authz_policy=policy, - control_plane_root_path=root, - ) - - status_code, payload = _invoke_app( - app, - method="GET", - path="/v1/product-profiles", - ) - - self.assertEqual(status_code, 403) - self.assertEqual(payload["error"]["code"], "authorization_denied") - self.assertEqual(payload["authz"]["identity"]["repository"], "every/verireel") - self.assertEqual(payload["authz"]["policy_source"], "bootstrap_seeded_store") - def test_agent_write_intent_evaluate_returns_allowed_dry_run_without_execution(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name) @@ -25278,18 +25137,8 @@ def test_authz_policy_grant_endpoint_writes_db_record_and_updates_runtime(self) store = PostgresRecordStore(database_url=database_url) try: active_policy = store.list_authz_policy_records(status="active", limit=1)[0] - store.write_product_profile_record( - LaunchplaneProductProfileRecord.model_validate( - _product_profile_payload_with_prod() - ) - ) finally: store.close() - profile_status_code, profile_payload = _invoke_app( - app, - method="GET", - path="/v1/product-profiles/sellyouroutboard", - ) repeat_status_code, repeat_payload = _invoke_app( app, method="POST", @@ -25330,8 +25179,20 @@ def test_authz_policy_grant_endpoint_writes_db_record_and_updates_runtime(self) assert isinstance(actions_operator, dict) self.assertEqual(actions_operator["type"], "github_actions") self.assertNotIn("workflow_refs", json.dumps(payload, sort_keys=True)) - self.assertEqual(profile_status_code, 200) - self.assertEqual(profile_payload["profile"]["product"], "sellyouroutboard") + self.assertTrue( + active_policy.policy.allows( + identity=_identity( + repository="cbusillo/launchplane", + workflow_ref=( + "cbusillo/launchplane/.github/workflows/deploy-launchplane.yml@refs/heads/main" + ), + event_name="workflow_dispatch", + ), + action="product_profile.read", + product="sellyouroutboard", + context="launchplane", + ) + ) self.assertEqual(repeat_status_code, 202) self.assertEqual( repeat_payload["records"]["authz_policy_record_id"], active_policy.record_id @@ -25514,18 +25375,8 @@ def test_authz_policy_grant_endpoint_dry_run_does_not_write_or_reload(self) -> N route_path="/v1/authz-policies/github-actions/grants", idempotency_key="authz-grant:dry-run", ) - store.write_product_profile_record( - LaunchplaneProductProfileRecord.model_validate( - _product_profile_payload_with_prod() - ) - ) finally: store.close() - profile_status_code, profile_payload = _invoke_app( - app, - method="GET", - path="/v1/product-profiles/sellyouroutboard", - ) self.assertEqual(status_code, 202) self.assertEqual(payload["result"]["mode"], "dry_run") @@ -25539,8 +25390,20 @@ def test_authz_policy_grant_endpoint_dry_run_does_not_write_or_reload(self) -> N ) self.assertEqual(len(active_records), 1) self.assertIsNone(dry_run_idempotency_record) - self.assertEqual(profile_status_code, 403) - self.assertEqual(profile_payload["error"]["code"], "authorization_denied") + self.assertFalse( + active_records[0].policy.allows( + identity=_identity( + repository="cbusillo/launchplane", + workflow_ref=( + "cbusillo/launchplane/.github/workflows/deploy-launchplane.yml@refs/heads/main" + ), + event_name="workflow_dispatch", + ), + action="product_profile.read", + product="sellyouroutboard", + context="launchplane", + ) + ) def test_authz_policy_removal_endpoint_dry_run_does_not_write_or_reload(self) -> None: with TemporaryDirectory() as temporary_directory_name: