diff --git a/.github/scripts/deploy-cloud-run-simulation-entry.sh b/.github/scripts/deploy-cloud-run-simulation-entry.sh index 5b208c1ac..4320514ff 100755 --- a/.github/scripts/deploy-cloud-run-simulation-entry.sh +++ b/.github/scripts/deploy-cloud-run-simulation-entry.sh @@ -109,6 +109,11 @@ esac || fail "ENTRYPOINT_MAX_INSTANCES must be a non-negative integer" ((10#${ENTRYPOINT_MAX_INSTANCES} >= 10#${ENTRYPOINT_MIN_INSTANCES})) \ || fail "ENTRYPOINT_MAX_INSTANCES must be at least ENTRYPOINT_MIN_INSTANCES" +# An unset GitHub variable arrives as an empty value. Default it to log-only +# permission checks; switching to 1 is a separate operator decision. +export SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE="${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE:-0}" +[[ "${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE}" == "0" || "${SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE}" == "1" ]] \ + || fail "SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE must be 0 or 1" runtime_secrets=( "OLD_GATEWAY_AUTH_CLIENT_SECRET=$( @@ -148,6 +153,7 @@ jq -n ' SIMULATION_ENTRYPOINT_AUTH_REQUIRED: "1", SIMULATION_ENTRYPOINT_AUTH_ISSUER: env.SIMULATION_ENTRYPOINT_AUTH_ISSUER_VALUE, SIMULATION_ENTRYPOINT_AUTH_AUDIENCE: env.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE_VALUE, + SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS: env.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE, OLD_GATEWAY_URL: env.OLD_GATEWAY_URL_VALUE, OLD_GATEWAY_AUTH_ISSUER: env.OLD_GATEWAY_AUTH_ISSUER_VALUE, OLD_GATEWAY_AUTH_AUDIENCE: env.OLD_GATEWAY_AUTH_AUDIENCE_VALUE, diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 11d47de71..377153b79 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -231,6 +231,7 @@ jobs: ENTRYPOINT_MAX_INSTANCES: ${{ inputs.entrypoint_max_instances }} SIMULATION_ENTRYPOINT_AUTH_ISSUER_VALUE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} SIMULATION_ENTRYPOINT_AUTH_AUDIENCE_VALUE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE: ${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }} OLD_GATEWAY_URL_VALUE: ${{ secrets.OLD_GATEWAY_URL }} OLD_GATEWAY_AUTH_ISSUER_VALUE: ${{ secrets.OLD_GATEWAY_AUTH_ISSUER }} OLD_GATEWAY_AUTH_AUDIENCE_VALUE: ${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }} @@ -799,6 +800,7 @@ jobs: SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS: ${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }} steps: - uses: actions/checkout@v6 @@ -917,6 +919,7 @@ jobs: SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS: ${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }} run: uv run pytest authenticated_tests/ -v - name: Verify configured custom hostname @@ -932,6 +935,7 @@ jobs: SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS: ${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }} run: uv run pytest authenticated_tests/ -v - name: Restore previous revision after failed verification diff --git a/AGENTS.md b/AGENTS.md index a4738d7a7..71799966a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. - `sim.deploy.image-smoke-covers-every-image-lib`: The image smoke path filter includes every library mounted into a Modal image, so a change to any of them runs the smoke. (doc-only, not checked: follow it yourself) - `sim.deploy.modal-image-smoke`: App entrypoints import inside the real Modal images before merge, whenever a library baked into an image changes. (blocking: CI job `image-smoke`) - `sim.deploy.numerical-sentinel-gate`: An existing-stack deployment computes the numerical release sentinel from the exact bundle it installs, and the candidate and stable services must reproduce it; a failing sentinel check fails its step. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) +- `sim.deploy.permission-enforcement-wired`: Simulation Entrypoint permission enforcement defaults to log-only, rejects any value other than 0 or 1, and reaches the candidate revision and every deployed authentication run. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) - `sim.deploy.promotion-order-explicit`: Full-stack promotion order is explicit, and the reusable deployment rejects unexpectedly skipped phases. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) - `sim.deploy.release-identity-smoke`: Deploy gates pass the whole release identity to the entrypoint smoke, which requires every expectation, refuses malformed ones, and rejects a release whose routes, latest pointers, bundle, UK model or UK dataset differ from that identity. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) - `sim.deploy.revision-tags-valid`: Cloud Run revision tags are valid on a workflow's first run and fit the existing service names. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 9882a1c96..3aec27f75 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 31 | 1 | 1 | 14 | 47 | +| mechanical | 32 | 1 | 1 | 14 | 48 | | metric | 0 | 0 | 0 | 0 | 0 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 32 of 47 rules. +**Blocking:** 33 of 48 rules. ## Gaps @@ -45,6 +45,7 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf - `sim.deploy.container-context-minimal` - `sim.deploy.gcloud-without-terraform` - `sim.deploy.numerical-sentinel-gate` +- `sim.deploy.permission-enforcement-wired` - `sim.deploy.promotion-order-explicit` - `sim.deploy.release-identity-smoke` - `sim.deploy.revision-tags-valid` @@ -132,6 +133,7 @@ flowchart LR | `sim.deploy.image-smoke-covers-every-image-lib` | The image smoke path filter includes every library mounted into a Modal image, so a change to any of them runs the smoke. | mechanical | doc-only | no | nothing | — | — | | `sim.deploy.modal-image-smoke` | App entrypoints import inside the real Modal images before merge, whenever a library baked into an image changes. | mechanical | enforced | yes | CI job `image-smoke` | `pr-image-smoke.yml:image-smoke` | — | | `sim.deploy.numerical-sentinel-gate` | An existing-stack deployment computes the numerical release sentinel from the exact bundle it installs, and the candidate and stable services must reproduce it; a failing sentinel check fails its step. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | +| `sim.deploy.permission-enforcement-wired` | Simulation Entrypoint permission enforcement defaults to log-only, rejects any value other than 0 or 1, and reaches the candidate revision and every deployed authentication run. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.promotion-order-explicit` | Full-stack promotion order is explicit, and the reusable deployment rejects unexpectedly skipped phases. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.release-identity-smoke` | Deploy gates pass the whole release identity to the entrypoint smoke, which requires every expectation, refuses malformed ones, and rejects a release whose routes, latest pointers, bundle, UK model or UK dataset differ from that identity. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | | `sim.deploy.revision-tags-valid` | Cloud Run revision tags are valid on a workflow's first run and fit the existing service names. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** | @@ -156,6 +158,7 @@ flowchart LR e_CI_job_image_smoke["CI job image-smoke"] j_CI_pr_image_smoke_yml_image_smoke["CI pr-image-smoke.yml:image-smoke"] r_sim_deploy_numerical_sentinel_gate("sim.deploy.numerical-sentinel-gate (enforced)") + r_sim_deploy_permission_enforcement_wired("sim.deploy.permission-enforcement-wired (enforced)") r_sim_deploy_promotion_order_explicit("sim.deploy.promotion-order-explicit (enforced)") r_sim_deploy_release_identity_smoke("sim.deploy.release-identity-smoke (enforced)") r_sim_deploy_revision_tags_valid("sim.deploy.revision-tags-valid (enforced)") @@ -173,6 +176,7 @@ flowchart LR r_sim_deploy_modal_image_smoke --> e_CI_job_image_smoke e_CI_job_image_smoke --> j_CI_pr_image_smoke_yml_image_smoke r_sim_deploy_numerical_sentinel_gate --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py + r_sim_deploy_permission_enforcement_wired --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py r_sim_deploy_promotion_order_explicit --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py r_sim_deploy_release_identity_smoke --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py r_sim_deploy_revision_tags_valid --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py diff --git a/docs/migration/stage-12-modal-worker-foundation.md b/docs/migration/stage-12-modal-worker-foundation.md index f5a802179..974e64689 100644 --- a/docs/migration/stage-12-modal-worker-foundation.md +++ b/docs/migration/stage-12-modal-worker-foundation.md @@ -270,7 +270,10 @@ The existing Cloud Run Simulation Entrypoint exposes these operator-only routes: - `GET /internal/stage12/reports/{evaluation_id}` reads its temporary parent and child execution metadata. -Both routes enforce the Simulation Entrypoint's existing bearer authentication. +Both routes enforce the Simulation Entrypoint's existing bearer authentication +and require the `stage12:operate` permission. Missing permissions are only +logged until `SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS=1`; see the +entrypoint's `DEPLOYMENT.md`. `POST /internal/stage12/reports` accepts new work only when `STAGE12_ENABLED=1`; with a missing or zero value it returns HTTP 503 without creating a parent record or invoking Modal. The status route remains available diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md index 9e430f834..9783ef1cc 100644 --- a/projects/policyengine-simulation-entry/DEPLOYMENT.md +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -74,3 +74,63 @@ Domain ownership, load-balancer creation, DNS publication, managed TLS activation, and infrastructure administration are one-time operator actions governed by private runbooks rather than committed bootstrap scripts in this public repository. Direct Cloud Run domain mapping is not the production edge. + +## Caller permissions + +The deployment workflow passes each environment's +`SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS` GitHub variable to Cloud Run. An +unset or empty variable deploys `0`, which records callers that lack a route's +permission but lets them through. `1` rejects those callers with 403. Any other +value fails the deployment before Cloud Run changes. A new value takes effect +at the next entrypoint deployment. In `prod` that deployment is promoted to +stable traffic. `beta` never promotes its candidate revision, so a `beta` value +applies only to the tagged candidate that the deployment tests call. The route +permissions are listed in `README.md`. + +### Auth0 prerequisites + +Complete these steps in an environment's Auth0 tenant before setting its +variable to `1`: + +1. On the Auth0 API whose identifier is the entrypoint's + `SIMULATION_ENTRYPOINT_AUTH_AUDIENCE`, define the permissions + `simulation:submit`, `simulation:read` and `stage12:operate`. Enable RBAC + and **Add Permissions in the Access Token**. +2. Grant permissions on that API to every client that calls the entrypoint: + - the API v1 machine client: `simulation:submit` and `simulation:read`; + - the deployed test client (`SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID`), + which the authenticated deployment tests and the beta integration suite + use: `simulation:submit` and `simulation:read`. Do not grant it + `stage12:operate`, because the enforced deployment check relies on its + absence; + - the Stage 12 operator client: `stage12:operate`. +3. API v1 requests one token for its `GATEWAY_AUTH_AUDIENCE` and presents it to + both the old Modal gateway and this entrypoint. The RBAC settings and grants + above therefore apply to the same Auth0 API the old gateway uses. Review + every existing client of that API, and grant permissions to each one that + calls the entrypoint. The old gateway does not check these permissions, and + the entrypoint's own old-gateway client (`OLD_GATEWAY_AUTH_CLIENT_ID`) needs + none of them. + +### Enabling enforcement + +1. Deploy with the variable unset or `0`, and complete the Auth0 setup. +2. Watch the `prod` service for `simulation_entry_permission_missing` events + with `enforced=false`. Each one names the missing permission and the + caller's `client_id`. Only `prod` serves real callers such as API v1: a + `beta` candidate receives only the deployment tests' own requests, so `beta` + events cannot show a missing API v1 grant. Access tokens issued before a + grant do not carry it, so wait at least one access-token lifetime after the + last grant with no new events. +3. Set the `beta` variable to `1` and deploy. With enforcement on, the + authenticated deployment tests also check that the test client's token is + rejected on `GET /internal/stage12/reports/{evaluation_id}`, which requires + `stage12:operate`. Those tests skip this check while the variable is `0`. +4. Set the `prod` variable to `1` and deploy. No deployment test sends API v1's + own token, so step 2 is the only check that API v1 holds its grants. + +To roll back, set the variable to `0` and deploy. The regular deployment +rebuilds and tests `beta` before `prod`. For `prod` alone, the **Deploy Stage +12 comparison path** workflow with `deployment_environment: production` (and the +`DEPLOY_STAGE12_PRODUCTION` confirmation) redeploys and promotes the production +entrypoint, and also redeploys the production Stage 12 resources. diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md index 00b406408..3a3f72815 100644 --- a/projects/policyengine-simulation-entry/README.md +++ b/projects/policyengine-simulation-entry/README.md @@ -23,3 +23,41 @@ deployment checks can prove which revision a tagged or stable URL serves. Live caller/backend authentication checks are isolated in `authenticated_tests`. They qualify tagged Cloud Run candidates before production traffic changes and verify the stable production endpoint immediately after promotion. + +## Caller authentication and permissions + +Protected routes accept Auth0 RS256 access tokens issued by +`SIMULATION_ENTRYPOINT_AUTH_ISSUER` for `SIMULATION_ENTRYPOINT_AUTH_AUDIENCE`. +A missing or invalid token returns 403. Each protected route also requires one +Auth0 permission: + +| Route | Permission | +|---|---| +| `POST /simulate/economy/comparison` | `simulation:submit` | +| `POST /simulate/economy/budget-window` | `simulation:submit` | +| `GET /jobs/{job_id}` | `simulation:read` | +| `GET /budget-window-jobs/{batch_job_id}` | `simulation:read` | +| `POST /internal/stage12/reports` | `stage12:operate` | +| `GET /internal/stage12/reports/{evaluation_id}` | `stage12:operate` | + +`GET /health`, `GET /ready`, `GET /versions`, `GET /versions/{kind}` and +`POST /ping` stay public. + +A caller holds a permission when it appears in the token's `permissions` claim +(Auth0 RBAC). For a client-credentials (machine) token, an entry in its +space-delimited `scope` claim also counts. A user token's `scope` never does, +because without RBAC Auth0 puts any requested API scope there. +`SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS` decides what happens when the +permission is missing: + +- `0`, or unset: log only. The request proceeds, and the service emits a + `simulation_entry_permission_missing` event with the `permission`, + `enforced=false`, and the token's `azp` as `client_id`. Tokens and other + claims are never recorded. +- `1`: the request is rejected with 403, and the same event is emitted with + `enforced=true`. + +Any other value stops the service from starting. When caller authentication is +disabled (`SIMULATION_ENTRYPOINT_AUTH_REQUIRED=0`, which production refuses), +there is no caller identity and every protected route is allowed. The Auth0 +setup required before enforcement is described in `DEPLOYMENT.md`. diff --git a/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py b/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py index e2c8accd3..2b3f545a0 100644 --- a/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py @@ -14,6 +14,9 @@ "scope": "macro", "reform": {}, } +# A read-only route guarded by stage12:operate, which the deployed test client +# must not hold. The nil identifier never names a real Stage 12 report. +PERMISSION_PROBE_PATH = "/internal/stage12/reports/00000000-0000-0000-0000-000000000000" def _required(name: str) -> str: @@ -28,6 +31,20 @@ def base_url() -> str: return _required("SIMULATION_ENTRYPOINT_TEST_BASE_URL").rstrip("/") +@pytest.fixture(scope="session") +def permission_enforcement() -> None: + value = ( + os.environ.get("SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS", "").strip() + or "0" + ) + if value not in {"0", "1"}: + raise RuntimeError( + "SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS must be 0 or 1." + ) + if value == "0": + pytest.skip("The target environment logs missing permissions only.") + + @pytest.fixture(scope="session") def access_token() -> str: issuer = _required("SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER").rstrip("/") @@ -84,3 +101,20 @@ def test_valid_token_reaches_authenticated_old_gateway( # submitted. Reaching this 400 proves both authentication hops succeeded. assert response.status_code == 400 assert response.headers["x-policyengine-simulation-backend"] == "old_gateway" + + +def test_enforced_permissions_reject_token_without_route_permission( + permission_enforcement: None, + base_url: str, + access_token: str, +): + response = httpx.get( + f"{base_url}{PERMISSION_PROBE_PATH}", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + + # The token is valid, so only the missing stage12:operate permission can + # produce this 403. Any other status means enforcement is off or the test + # client has been granted stage12:operate. + assert response.status_code == 403 diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index ba77098c9..1e2705fce 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -6,7 +6,6 @@ import logging import time import uuid -from collections.abc import Callable from contextlib import asynccontextmanager from typing import Any, Literal, Protocol from uuid import UUID @@ -46,7 +45,11 @@ from pydantic import BaseModel, TypeAdapter, ValidationError from starlette.datastructures import MutableHeaders -from policyengine_simulation_entry.auth import CallerAuthenticator +from policyengine_simulation_entry.auth import ( + AuthenticationDependency, + CallerAuthenticator, + CallerAuthorizer, +) from policyengine_simulation_entry.backend import ( BackendResponse, BackendTimeout, @@ -73,7 +76,6 @@ "X-PolicyEngine-Simulation-Backend": "old_gateway", } _json_object_adapter = TypeAdapter(JsonObject) -type AuthenticationDependency = Callable[[], CallerIdentity | None] type ResponseIdentifier = Literal["job_id", "batch_job_id"] STAGE12_MODAL_SUBMISSION_TIMEOUT_SECONDS = 5.0 @@ -227,6 +229,14 @@ def adopt_backend_observability_id( runtime_backend = backend or OldGatewayBackend(runtime_settings, runtime=runtime) authenticate = auth_dependency or CallerAuthenticator(runtime_settings, runtime) + authorizer = CallerAuthorizer( + authenticate=authenticate, + enforce=runtime_settings.enforce_permissions, + runtime=runtime, + ) + simulation_submitter = authorizer.require_permission("simulation:submit") + simulation_reader = authorizer.require_permission("simulation:read") + stage12_operator = authorizer.require_permission("stage12:operate") runtime_comparison = comparison_backend if runtime_comparison is None and runtime_settings.stage12_resources_configured: from policyengine_simulation_entry.stage12_backend import ( @@ -382,8 +392,6 @@ async def forward( }, ) - protected = [Depends(authenticate)] - def temporary_submission_payload( report: ComparisonReportRecord, ) -> TemporaryStage12SubmissionResponse: @@ -399,11 +407,11 @@ def temporary_submission_payload( @app.post( "/internal/stage12/reports", include_in_schema=False, - dependencies=protected, ) async def submit_temporary_stage12_report( body: SimulationRequest, request: Request, + caller: CallerIdentity | None = Depends(stage12_operator), ) -> Response: if not runtime_settings.stage12_enabled: return JSONResponse( @@ -478,11 +486,11 @@ async def submit_temporary_stage12_report( @app.get( "/internal/stage12/reports/{evaluation_id}", include_in_schema=False, - dependencies=protected, ) async def get_temporary_stage12_report( evaluation_id: UUID, request: Request, + caller: CallerIdentity | None = Depends(stage12_operator), ) -> Response: comparison = runtime_comparison if comparison is None: @@ -557,11 +565,11 @@ async def get_temporary_stage12_report( "model": SimulationErrorResponse, }, }, - dependencies=protected, ) async def submit_comparison( body: SimulationRequest, request: Request, + caller: CallerIdentity | None = Depends(simulation_submitter), ) -> Response: observability_id = start_workflow_observability(request) request_payload = _model_json(body) @@ -626,11 +634,11 @@ async def submit_comparison( "model": SimulationErrorResponse, }, }, - dependencies=protected, ) async def submit_budget_window( body: BudgetWindowBatchRequest, request: Request, + caller: CallerIdentity | None = Depends(simulation_submitter), ) -> Response: start_workflow_observability(request) return await forward( @@ -665,9 +673,12 @@ async def submit_budget_window( 404: {"description": "Job not found"}, 500: {"description": "Job failed"}, }, - dependencies=protected, ) - async def get_job(job_id: str, request: Request) -> Response: + async def get_job( + job_id: str, + request: Request, + caller: CallerIdentity | None = Depends(simulation_reader), + ) -> Response: return await forward( request, "GET", @@ -697,11 +708,11 @@ async def get_job(job_id: str, request: Request) -> Response: 404: {"description": "Batch job not found"}, 500: {"description": "Batch failed"}, }, - dependencies=protected, ) async def get_budget_window_job( batch_job_id: str, request: Request, + caller: CallerIdentity | None = Depends(simulation_reader), ) -> Response: return await forward( request, diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py index a3f332b99..9c942469e 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py @@ -1,9 +1,11 @@ -"""Inbound bearer authentication for protected Simulation Entrypoint routes.""" +"""Inbound bearer authentication and permission checks for protected routes.""" from __future__ import annotations +from collections.abc import Callable from functools import lru_cache import logging +from typing import Literal from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -17,6 +19,14 @@ _bearer = HTTPBearer(auto_error=False) logger = logging.getLogger(__name__) +type CallerPermission = Literal[ + "simulation:submit", + "simulation:read", + "stage12:operate", +] +type AuthenticationDependency = Callable[[], CallerIdentity | None] +type AuthorizationDependency = Callable[..., CallerIdentity | None] + class JWTDecoder: """Validate Auth0 RS256 access tokens without database-layer dependencies.""" @@ -92,3 +102,96 @@ def reset_decoder_cache() -> None: """Clear the decoder cache for tests and credential rotations.""" _decoder.cache_clear() + + +def is_client_credentials(identity: CallerIdentity) -> bool: + """Return whether the token was issued to a machine client.""" + + return "client-credentials" in identity.grant_types or ( + identity.subject is not None and identity.subject.endswith("@clients") + ) + + +def granted_permissions(identity: CallerIdentity) -> frozenset[str]: + """Return the permissions a token grants. + + The RBAC ``permissions`` claim counts for every token. The OAuth ``scope`` + claim counts only for client-credentials tokens, whose scopes come from the + client's grant. Unless RBAC is enabled on the API, Auth0 copies whatever + API scopes a user's application requested into a user token's ``scope``, + so there it proves nothing. + """ + + scopes = ( + identity.scope.split() + if identity.scope and is_client_credentials(identity) + else [] + ) + return frozenset([*(identity.permissions or []), *scopes]) + + +class CallerAuthorizer: + """Build FastAPI dependencies that check one Auth0 permission per route. + + With enforcement disabled, a caller that lacks the permission is recorded + and allowed, so missing grants can be found before enforcement begins. + """ + + def __init__( + self, + *, + authenticate: AuthenticationDependency, + enforce: bool, + runtime: ObservabilityRuntime, + ): + self.authenticate = authenticate + self.enforce = enforce + self.runtime = runtime + + def require_permission( + self, + permission: CallerPermission, + ) -> AuthorizationDependency: + """Return a dependency that yields the caller holding ``permission``.""" + + def authorized_caller( + identity: CallerIdentity | None = Depends(self.authenticate), + ) -> CallerIdentity | None: + return self.authorize(identity, permission) + + return authorized_caller + + def authorize( + self, + identity: CallerIdentity | None, + permission: CallerPermission, + ) -> CallerIdentity | None: + # No identity means caller authentication is disabled, which settings + # validation permits only outside production. There are no claims to + # check, so the request proceeds. + if identity is None or permission in granted_permissions(identity): + return identity + self._record_missing_permission(identity, permission) + if self.enforce: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return identity + + def _record_missing_permission( + self, + identity: CallerIdentity, + permission: CallerPermission, + ) -> None: + attributes: dict[str, str | bool] = { + "permission": permission, + "enforced": self.enforce, + } + if identity.authorized_party: + attributes["client_id"] = identity.authorized_party + try: + self.runtime.event( + "simulation_entry_permission_missing", + severity="WARNING", + attributes=attributes, + ) + except Exception: # noqa: BLE001, S110 - telemetry is non-fatal + pass diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py index 003b5c785..b29c768bb 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py @@ -26,12 +26,13 @@ def _truthy(value: str | None, *, default: bool = False) -> bool: return value.lower() in {"1", "true", "yes", "on"} -def _stage12_enabled(value: str | None) -> bool: +def _binary_flag(name: str) -> bool: + value = os.getenv(name) if value is None or value == "0": return False if value == "1": return True - raise ConfigurationError("STAGE12_ENABLED must be exactly 0 or 1.") + raise ConfigurationError(f"{name} must be exactly 0 or 1.") def _normalized_issuer(value: str) -> str: @@ -75,6 +76,9 @@ class Settings: old_gateway_auth_audience: str old_gateway_auth_client_id: str old_gateway_auth_client_secret: str + # False records callers that lack a route permission and lets them + # through; True rejects them with 403. + enforce_permissions: bool = False connect_timeout_seconds: float = 5.0 request_timeout_seconds: float = 25.0 stage12_enabled: bool = False @@ -107,13 +111,16 @@ def from_env(cls) -> Settings: old_gateway_auth_client_secret=os.getenv( "OLD_GATEWAY_AUTH_CLIENT_SECRET", "" ), + enforce_permissions=_binary_flag( + "SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS" + ), connect_timeout_seconds=float( os.getenv("OLD_GATEWAY_CONNECT_TIMEOUT_SECONDS", "5") ), request_timeout_seconds=float( os.getenv("OLD_GATEWAY_REQUEST_TIMEOUT_SECONDS", "25") ), - stage12_enabled=_stage12_enabled(os.getenv("STAGE12_ENABLED")), + stage12_enabled=_binary_flag("STAGE12_ENABLED"), stage12_v2_manifest_name=os.getenv( "STAGE12_V2_MANIFEST_NAME", V2_VERSION_MANIFEST_NAME, diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py index 8dd48f746..f416c59ca 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Literal, TypedDict +from typing import Any, Literal, TypedDict from uuid import UUID from policyengine_simulation_contract.stage12_execution import ( @@ -10,11 +10,16 @@ ComparisonRunLifecycleStatus, ComparisonSimulationRecord, ) -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class CallerIdentity(BaseModel): - """Validated identity claims from an inbound Auth0 access token.""" + """Validated identity claims from an inbound Auth0 access token. + + Aliased fields are read only by their registered JWT claim names, never by + field name: a token's ``grant_types`` or ``subject`` claim must not stand + in for ``gty`` or ``sub``, because those decide whether ``scope`` counts. + """ subject: str | None = Field(default=None, alias="sub") issuer: str = Field(alias="iss") @@ -24,8 +29,25 @@ class CallerIdentity(BaseModel): scope: str | None = None permissions: list[str] | None = None authorized_party: str | None = Field(default=None, alias="azp") + grant_types: tuple[str, ...] = Field(default=(), alias="gty") + + model_config = ConfigDict(extra="ignore") + + @field_validator("grant_types", mode="before") + @classmethod + def _grant_types_from_claim(cls, claim: Any) -> tuple[str, ...]: + """Read ``gty`` as a string or a list of strings. + + Auth0 may issue it as a list (for example a refresh-token grant). Any + other shape is ignored rather than rejecting an otherwise valid token, + because the claim only widens what ``scope`` may grant. + """ - model_config = ConfigDict(extra="ignore", populate_by_name=True) + if isinstance(claim, str): + return (claim,) + if isinstance(claim, list): + return tuple(item for item in claim if isinstance(item, str)) + return () class BackendServiceToken(BaseModel): diff --git a/projects/policyengine-simulation-entry/tests/test_auth.py b/projects/policyengine-simulation-entry/tests/test_auth.py index 1a1a552b9..a4831bdf9 100644 --- a/projects/policyengine-simulation-entry/tests/test_auth.py +++ b/projects/policyengine-simulation-entry/tests/test_auth.py @@ -23,20 +23,23 @@ def signing_keys(): return private_key, private_key.public_key() -def _token(private_key, **overrides) -> str: +def _token(private_key, *, omitted_claims: tuple[str, ...] = (), **overrides) -> str: now = datetime.now(timezone.utc) claims = { - "sub": "api-v1", + "sub": "api-v1@clients", + "gty": "client-credentials", "iss": "https://issuer.example/", "aud": "simulation-entry", "iat": now, "exp": now + timedelta(minutes=5), } claims.update(overrides) + for claim_name in omitted_claims: + claims.pop(claim_name) return jwt.encode(claims, private_key, algorithm="RS256") -def _use_static_signing_key(monkeypatch, public_key) -> None: +def _static_decoder(public_key) -> auth_module.JWTDecoder: decoder = auth_module.JWTDecoder( issuer="https://issuer.example/", audience="simulation-entry", @@ -44,6 +47,11 @@ def _use_static_signing_key(monkeypatch, public_key) -> None: decoder.jwks_client = SimpleNamespace( get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) ) + return decoder + + +def _use_static_signing_key(monkeypatch, public_key) -> None: + decoder = _static_decoder(public_key) monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: decoder) @@ -93,13 +101,7 @@ def test_valid_api_v1_token_can_poll(monkeypatch, signing_keys): def test_decoder_returns_only_the_declared_caller_identity(signing_keys): private_key, public_key = signing_keys - decoder = auth_module.JWTDecoder( - issuer="https://issuer.example/", - audience="simulation-entry", - ) - decoder.jwks_client = SimpleNamespace( - get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) - ) + decoder = _static_decoder(public_key) identity = decoder( HTTPAuthorizationCredentials( @@ -114,11 +116,38 @@ def test_decoder_returns_only_the_declared_caller_identity(signing_keys): ) assert isinstance(identity, CallerIdentity) - assert identity.subject == "api-v1" + assert identity.subject == "api-v1@clients" + assert identity.grant_types == ("client-credentials",) assert identity.scope == "simulate:read" assert "undocumented_claim" not in identity.model_dump() +def test_decoder_reads_claims_only_by_their_registered_names(signing_keys): + private_key, public_key = signing_keys + decoder = _static_decoder(public_key) + + identity = decoder( + HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=_token( + private_key, + omitted_claims=("sub", "gty"), + subject="api-v1@clients", + grant_types="client-credentials", + authorized_party="api-v1-client", + scope="simulation:read", + ), + ), + Mock(), + ) + + assert identity.subject is None + assert identity.grant_types == () + assert identity.authorized_party is None + assert not auth_module.is_client_credentials(identity) + assert auth_module.granted_permissions(identity) == frozenset() + + @pytest.mark.parametrize( "claim_overrides", [ @@ -160,3 +189,617 @@ def test_malformed_token_returns_403(monkeypatch, signing_keys): ) assert result.status_code == 403 + + +ALL_PERMISSIONS = ("simulation:submit", "simulation:read", "stage12:operate") +COMPARISON_PAYLOAD = {"country": "us", "scope": "macro", "reform": {}} +BUDGET_WINDOW_PAYLOAD = { + "country": "us", + "region": "us", + "scope": "macro", + "reform": {}, + "start_year": "2026", + "window_size": 1, +} +STAGE12_REPORT_PATH = "/internal/stage12/reports/00000000-0000-0000-0000-000000000001" + +# Each protected route, the permission it requires, and the status its handler +# returns once authorized. Stage 12 is disabled and unconfigured in these +# tests, so an authorized Stage 12 request reaches the handler's 503 response. +PROTECTED_ROUTES = [ + pytest.param( + "post", + "/simulate/economy/comparison", + COMPARISON_PAYLOAD, + "simulation:submit", + 200, + id="submit-comparison", + ), + pytest.param( + "post", + "/simulate/economy/budget-window", + BUDGET_WINDOW_PAYLOAD, + "simulation:submit", + 200, + id="submit-budget-window", + ), + pytest.param( + "get", + "/jobs/job-1", + None, + "simulation:read", + 200, + id="poll-job", + ), + pytest.param( + "get", + "/budget-window-jobs/batch-1", + None, + "simulation:read", + 200, + id="poll-budget-window", + ), + pytest.param( + "post", + "/internal/stage12/reports", + COMPARISON_PAYLOAD, + "stage12:operate", + 503, + id="stage12-submit", + ), + pytest.param( + "get", + STAGE12_REPORT_PATH, + None, + "stage12:operate", + 503, + id="stage12-status", + ), +] + + +def _identity( + *, + permissions: list[str] | None = None, + scope: str | None = None, + azp: str | None = "api-v1-client", + sub: str = "api-v1-client@clients", + gty: str | None = None, +) -> CallerIdentity: + return CallerIdentity.model_validate( + { + "sub": sub, + "iss": "https://issuer.example/", + "aud": "simulation-entry", + "exp": int((datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()), + "permissions": permissions, + "scope": scope, + "azp": azp, + "gty": gty, + } + ) + + +def _app_for( + identity: CallerIdentity | None, + *, + enforce: bool, + backend: FakeBackend | None = None, +): + return create_app( + settings=make_settings(enforce_permissions=enforce), + backend=backend or FakeBackend(), + auth_dependency=lambda: identity, + ) + + +def _send(client: TestClient, method: str, path: str, body): + kwargs = {"json": body} if body is not None else {} + return getattr(client, method)(path, **kwargs) + + +def _record_events(client: TestClient, monkeypatch) -> Mock: + runtime = client.app.state.policyengine_observability + event = Mock(wraps=runtime.event) + monkeypatch.setattr(runtime, "event", event) + return event + + +def _permission_events(event: Mock) -> list[dict[str, object]]: + return [ + call.kwargs["attributes"] + for call in event.call_args_list + if call.args == ("simulation_entry_permission_missing",) + ] + + +@pytest.mark.parametrize( + ("method", "path", "body", "permission", "authorized_status"), + PROTECTED_ROUTES, +) +def test_enforced_route_rejects_caller_without_its_permission( + method, + path, + body, + permission, + authorized_status, +): + backend = FakeBackend() + other_permissions = [item for item in ALL_PERMISSIONS if item != permission] + app = _app_for( + _identity( + permissions=other_permissions, + scope=" ".join(other_permissions), + ), + enforce=True, + backend=backend, + ) + + with TestClient(app) as client: + result = _send(client, method, path, body) + + assert result.status_code == 403 + assert backend.requests == [] + + +@pytest.mark.parametrize( + ("method", "path", "body", "permission", "authorized_status"), + PROTECTED_ROUTES, +) +def test_enforced_route_accepts_caller_with_only_its_permission( + method, + path, + body, + permission, + authorized_status, +): + app = _app_for(_identity(permissions=[permission]), enforce=True) + + with TestClient(app) as client: + result = _send(client, method, path, body) + + assert result.status_code == authorized_status + + +@pytest.mark.parametrize( + "claims", + [ + {"permissions": ["simulation:read"]}, + {"scope": "simulation:read"}, + {"scope": "openid simulation:submit simulation:read"}, + {"permissions": ["simulation:submit"], "scope": "simulation:read"}, + ], + ids=[ + "permissions-claim", + "scope-claim", + "space-delimited-scope", + "either-claim", + ], +) +def test_permission_may_be_granted_by_permissions_or_scope_claim(claims): + app = _app_for(_identity(**claims), enforce=True) + + with TestClient(app) as client: + result = client.get("/jobs/job-1") + + assert result.status_code == 200 + + +@pytest.mark.parametrize( + "claims", + [ + {}, + {"permissions": [], "scope": ""}, + {"permissions": ["simulation:read-all"]}, + {"scope": "simulation:reader simulation"}, + {"scope": "simulation:submit,simulation:read"}, + ], + ids=[ + "no-claims", + "empty-claims", + "permission-prefix", + "scope-prefix", + "comma-delimited-scope", + ], +) +def test_permission_requires_an_exact_claim_entry(claims): + app = _app_for(_identity(**claims), enforce=True) + + with TestClient(app) as client: + result = client.get("/jobs/job-1") + + assert result.status_code == 403 + + +@pytest.mark.parametrize( + ("claims", "expected_status"), + [ + ({"sub": "google-oauth2|123", "scope": "openid simulation:read"}, 403), + ({"sub": "google-oauth2|123", "permissions": ["simulation:read"]}, 200), + ( + { + "sub": "api-v1-client", + "gty": "client-credentials", + "scope": "simulation:read", + }, + 200, + ), + ], + ids=[ + "user-token-scope-ignored", + "user-token-permissions-claim", + "client-credentials-grant-scope", + ], +) +def test_scope_claim_counts_only_for_client_credentials_tokens( + claims, + expected_status, +): + app = _app_for(_identity(**claims), enforce=True) + + with TestClient(app) as client: + result = client.get("/jobs/job-1") + + assert result.status_code == expected_status + + +def test_enforced_missing_permission_records_permission_and_client(monkeypatch): + app = _app_for( + _identity(permissions=["simulation:read"], azp="api-v1-client"), + enforce=True, + ) + + with TestClient(app) as client: + event = _record_events(client, monkeypatch) + result = client.post("/simulate/economy/comparison", json=COMPARISON_PAYLOAD) + + assert result.status_code == 403 + assert _permission_events(event) == [ + { + "permission": "simulation:submit", + "enforced": True, + "client_id": "api-v1-client", + } + ] + + +@pytest.mark.parametrize( + ("method", "path", "body", "permission", "authorized_status"), + PROTECTED_ROUTES, +) +def test_log_only_mode_allows_and_records_missing_permission( + monkeypatch, + method, + path, + body, + permission, + authorized_status, +): + app = _app_for(_identity(permissions=[], azp="unmigrated-client"), enforce=False) + + with TestClient(app) as client: + event = _record_events(client, monkeypatch) + result = _send(client, method, path, body) + + assert result.status_code == authorized_status + assert _permission_events(event) == [ + { + "permission": permission, + "enforced": False, + "client_id": "unmigrated-client", + } + ] + + +def test_permission_event_omits_absent_client_identifier(monkeypatch): + app = _app_for(_identity(azp=None), enforce=False) + + with TestClient(app) as client: + event = _record_events(client, monkeypatch) + result = client.get("/jobs/job-1") + + assert result.status_code == 200 + assert _permission_events(event) == [ + {"permission": "simulation:read", "enforced": False} + ] + + +def test_granted_permission_records_no_permission_event(monkeypatch): + app = _app_for(_identity(permissions=["simulation:read"]), enforce=True) + + with TestClient(app) as client: + event = _record_events(client, monkeypatch) + result = client.get("/jobs/job-1") + + assert result.status_code == 200 + assert _permission_events(event) == [] + + +@pytest.mark.parametrize( + ("enforce", "expected_status"), + [(False, 200), (True, 403)], + ids=["log-only", "enforced"], +) +def test_permission_telemetry_failure_does_not_change_response( + monkeypatch, + enforce, + expected_status, +): + app = _app_for(_identity(permissions=[]), enforce=enforce) + + with TestClient(app) as client: + runtime = client.app.state.policyengine_observability + emit = runtime.event + + def fail_permission_event(name, **kwargs): + if name == "simulation_entry_permission_missing": + raise RuntimeError("telemetry unavailable") + return emit(name, **kwargs) + + failing_event = Mock(side_effect=fail_permission_event) + monkeypatch.setattr(runtime, "event", failing_event) + result = client.get("/jobs/job-1") + + assert result.status_code == expected_status + assert _permission_events(failing_event) == [ + { + "permission": "simulation:read", + "enforced": enforce, + "client_id": "api-v1-client", + } + ] + + +@pytest.mark.parametrize( + ("method", "path", "body", "permission", "authorized_status"), + PROTECTED_ROUTES, +) +def test_disabled_authentication_allows_enforced_routes( + monkeypatch, + method, + path, + body, + permission, + authorized_status, +): + class Decoder: + def __call__(self, token, runtime): + raise AssertionError("disabled authentication decoded a token") + + monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: Decoder()) + app = create_app( + settings=make_settings( + auth_required=False, + auth_issuer="", + auth_audience="", + enforce_permissions=True, + ), + backend=FakeBackend(), + ) + + with TestClient(app) as client: + event = _record_events(client, monkeypatch) + result = _send(client, method, path, body) + + assert result.status_code == authorized_status + assert _permission_events(event) == [] + + +def test_public_routes_stay_public_when_permissions_are_enforced(monkeypatch): + class Decoder: + def __call__(self, token, runtime): + raise AssertionError("public route attempted authentication") + + monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: Decoder()) + app = create_app( + settings=make_settings(enforce_permissions=True), + backend=FakeBackend(), + ) + + with TestClient(app) as client: + assert client.get("/health").status_code == 200 + assert client.get("/ready").status_code == 200 + assert client.get("/versions").status_code == 200 + assert client.get("/versions/us").status_code == 200 + assert client.post("/ping", json={"value": 1}).status_code == 200 + + +def test_enforced_stage12_routes_reject_simulation_permissions(): + app = _app_for( + _identity( + permissions=["simulation:submit", "simulation:read"], + scope="simulation:submit simulation:read", + ), + enforce=True, + ) + + with TestClient(app) as client: + submission = client.post( + "/internal/stage12/reports", + json=COMPARISON_PAYLOAD, + ) + status = client.get(STAGE12_REPORT_PATH) + + assert submission.status_code == 403 + assert status.status_code == 403 + + +@pytest.mark.parametrize( + ("claims", "expected_status"), + [ + ({}, 403), + ({"permissions": ["simulation:submit"]}, 403), + ({"permissions": ["simulation:read"]}, 200), + ({"scope": "simulation:read"}, 200), + ], + ids=["no-permission", "wrong-permission", "permissions-claim", "scope-claim"], +) +def test_enforced_permissions_apply_to_verified_api_v1_tokens( + monkeypatch, + signing_keys, + claims, + expected_status, +): + private_key, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app( + settings=make_settings(enforce_permissions=True), + backend=FakeBackend(), + ) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={"Authorization": f"Bearer {_token(private_key, **claims)}"}, + ) + + assert result.status_code == expected_status + + +@pytest.mark.parametrize( + ("claims", "expected_status"), + [ + ( + # A null gty replaces the default client-credentials grant. + {"sub": "google-oauth2|123", "gty": None, "scope": "simulation:read"}, + 403, + ), + ( + {"sub": "auth0|123", "gty": "password", "scope": "simulation:read"}, + 403, + ), + ( + { + "sub": "auth0|123", + "gty": ["refresh_token", "password"], + "scope": "simulation:read", + }, + 403, + ), + ( + { + "sub": "auth0|123", + "gty": ["refresh_token", "password"], + "permissions": ["simulation:read"], + }, + 200, + ), + ( + {"sub": "auth0|123", "gty": 7, "permissions": ["simulation:read"]}, + 200, + ), + ( + { + "sub": "api-v1-client", + "gty": ["client-credentials"], + "scope": "simulation:read", + }, + 200, + ), + ], + ids=[ + "user-token-scope-ignored", + "password-grant-scope-ignored", + "listed-user-grants-scope-ignored", + "listed-user-grants-permissions-claim", + "malformed-grant-type-ignored", + "listed-client-credentials-scope", + ], +) +def test_verified_tokens_count_scope_only_for_client_credentials( + monkeypatch, + signing_keys, + claims, + expected_status, +): + private_key, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app( + settings=make_settings(enforce_permissions=True), + backend=FakeBackend(), + ) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={"Authorization": f"Bearer {_token(private_key, **claims)}"}, + ) + + assert result.status_code == expected_status + + +@pytest.mark.parametrize( + ("omitted_claims", "claims", "expected_status"), + [ + ( + ("gty",), + { + "sub": "google-oauth2|123", + "grant_types": "client-credentials", + "scope": "simulation:read", + }, + 403, + ), + ( + ("sub", "gty"), + {"subject": "api-v1@clients", "scope": "simulation:read"}, + 403, + ), + ( + ("gty",), + {"sub": "api-v1@clients", "scope": "simulation:read"}, + 200, + ), + ], + ids=[ + "grant-types-claim-is-not-gty", + "subject-claim-is-not-sub", + "registered-sub-claim", + ], +) +def test_verified_tokens_read_grant_claims_only_by_registered_names( + monkeypatch, + signing_keys, + omitted_claims, + claims, + expected_status, +): + private_key, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app( + settings=make_settings(enforce_permissions=True), + backend=FakeBackend(), + ) + token = _token(private_key, omitted_claims=omitted_claims, **claims) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert result.status_code == expected_status + + +def test_permissions_are_logged_not_enforced_by_default(monkeypatch, signing_keys): + private_key, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + event = _record_events(client, monkeypatch) + result = client.get( + "/jobs/job-1", + headers={ + "Authorization": f"Bearer {_token(private_key, azp='api-v1-client')}" + }, + ) + + assert result.status_code == 200 + assert _permission_events(event) == [ + { + "permission": "simulation:read", + "enforced": False, + "client_id": "api-v1-client", + } + ] diff --git a/projects/policyengine-simulation-entry/tests/test_config.py b/projects/policyengine-simulation-entry/tests/test_config.py index 127d9dff1..f5ff87b22 100644 --- a/projects/policyengine-simulation-entry/tests/test_config.py +++ b/projects/policyengine-simulation-entry/tests/test_config.py @@ -77,6 +77,36 @@ def test_stage12_enabled_rejects_every_value_other_than_zero_or_one(monkeypatch, Settings.from_env() +def test_permission_enforcement_defaults_to_log_only(monkeypatch): + from policyengine_simulation_entry.config import Settings + + monkeypatch.delenv("SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS", raising=False) + assert Settings.from_env().enforce_permissions is False + + +@pytest.mark.parametrize(("value", "expected"), [("0", False), ("1", True)]) +def test_permission_enforcement_accepts_zero_or_one(monkeypatch, value, expected): + from policyengine_simulation_entry.config import Settings + + monkeypatch.setenv("SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS", value) + assert Settings.from_env().enforce_permissions is expected + + +@pytest.mark.parametrize("value", ["", "true", "yes", "2", " 1"]) +def test_permission_enforcement_rejects_every_value_other_than_zero_or_one( + monkeypatch, + value, +): + from policyengine_simulation_entry.config import Settings + + monkeypatch.setenv("SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS", value) + with pytest.raises( + ConfigurationError, + match="SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS must be exactly 0 or 1", + ): + Settings.from_env() + + def test_enabled_stage12_requires_independent_runtime_configuration(): with pytest.raises(ConfigurationError, match="STAGE12_ENABLED=1 requires"): make_settings(stage12_enabled=True).validate() diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index e2351c18e..ee12b908d 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -410,7 +410,9 @@ def test_cloud_run_deployment_does_not_interpolate_github_values_in_shell(): assert ":latest" not in deployment_script -def test_cloud_run_deployment_escapes_environment_and_pins_secret_versions(tmp_path): +def _cloud_run_deploy_environment(tmp_path: Path) -> dict[str, str]: + """Return a complete deployment environment backed by a fake gcloud.""" + fake_bin = tmp_path / "bin" fake_bin.mkdir() fake_gcloud = fake_bin / "gcloud" @@ -443,13 +445,16 @@ def test_cloud_run_deployment_escapes_environment_and_pins_secret_versions(tmp_p encoding="utf-8", ) fake_gcloud.chmod(0o755) - arguments_capture = tmp_path / "gcloud-arguments.txt" - env_capture = tmp_path / "runtime-environment.json" - command_environment = os.environ | { + environment = { + name: value + for name, value in os.environ.items() + if name != "SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE" + } + return environment | { "PATH": f"{fake_bin}:{os.environ['PATH']}", "RUNNER_TEMP": str(tmp_path), - "GCLOUD_ARGUMENTS_CAPTURE": str(arguments_capture), - "GCLOUD_ENV_FILE_CAPTURE": str(env_capture), + "GCLOUD_ARGUMENTS_CAPTURE": str(tmp_path / "gcloud-arguments.txt"), + "GCLOUD_ENV_FILE_CAPTURE": str(tmp_path / "runtime-environment.json"), "PROJECT_ID": "policyengine-staging", "REGION": "us-central1", "IMAGE": "us-central1-docker.pkg.dev/project/repository/image:sha", @@ -483,6 +488,12 @@ def test_cloud_run_deployment_escapes_environment_and_pins_secret_versions(tmp_p "MODAL_TOKEN_SECRET_SECRET_NAME": "modal-token-secret", } + +def test_cloud_run_deployment_escapes_environment_and_pins_secret_versions(tmp_path): + command_environment = _cloud_run_deploy_environment(tmp_path) + arguments_capture = Path(command_environment["GCLOUD_ARGUMENTS_CAPTURE"]) + env_capture = Path(command_environment["GCLOUD_ENV_FILE_CAPTURE"]) + subprocess.run( [CLOUD_RUN_DEPLOY_SCRIPT], check=True, @@ -520,6 +531,83 @@ def test_cloud_run_deployment_escapes_environment_and_pins_secret_versions(tmp_p assert ":latest" not in secrets_argument +@pytest.mark.parametrize( + ("configured", "expected"), + [(None, "0"), ("", "0"), ("0", "0"), ("1", "1")], + ids=["unset", "empty", "log-only", "enforced"], +) +def test_cloud_run_deployment_defaults_permission_enforcement_to_log_only( + tmp_path, + configured, + expected, +): + command_environment = _cloud_run_deploy_environment(tmp_path) + if configured is not None: + command_environment["SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE"] = ( + configured + ) + + subprocess.run( + [CLOUD_RUN_DEPLOY_SCRIPT], + check=True, + env=command_environment, + ) + + runtime_environment = json.loads( + Path(command_environment["GCLOUD_ENV_FILE_CAPTURE"]).read_text(encoding="utf-8") + ) + assert runtime_environment["SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS"] == expected + + +@pytest.mark.parametrize("configured", ["true", "yes", "2", " 1"]) +def test_cloud_run_deployment_rejects_invalid_permission_enforcement( + tmp_path, + configured, +): + command_environment = _cloud_run_deploy_environment(tmp_path) + command_environment["SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE"] = configured + + result = subprocess.run( + [CLOUD_RUN_DEPLOY_SCRIPT], + check=False, + env=command_environment, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert ( + "SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE must be 0 or 1" + in result.stderr + ) + assert not Path(command_environment["GCLOUD_ENV_FILE_CAPTURE"]).exists() + + +def test_permission_enforcement_reaches_candidate_and_authenticated_tests(): + reusable_workflow = REUSABLE_DEPLOY_WORKFLOW.read_text(encoding="utf-8") + + assert ( + reusable_workflow.count( + "SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS_VALUE: " + "${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }}" + ) + == 1 + ) + # Every deployed authentication run must know whether the target revision + # enforces permissions, so its enforcement check runs or skips correctly. + authenticated_runs = reusable_workflow.count( + "run: uv run pytest authenticated_tests/ -v" + ) + assert authenticated_runs == 3 + assert ( + reusable_workflow.count( + "SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS: " + "${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }}" + ) + == authenticated_runs + ) + + def test_cloud_run_revision_tags_are_valid_on_the_first_workflow_run(): deploy_workflow = DEPLOY_WORKFLOW.read_text(encoding="utf-8") reusable_workflow = REUSABLE_DEPLOY_WORKFLOW.read_text(encoding="utf-8") diff --git a/rules/registry.toml b/rules/registry.toml index 3cf4b55eb..4abb0dae3 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -360,6 +360,28 @@ workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run pytest tests/" +[[rule]] +id = "sim.deploy.permission-enforcement-wired" +category = "deployment" +statement = "Simulation Entrypoint permission enforcement defaults to log-only, rejects any value other than 0 or 1, and reaches the candidate revision and every deployed authentication run." +rationale = "docs/operations" +scope = [".github/workflows/*.yml", ".github/scripts/*.sh"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +project = "projects/policyengine-simulation-entry" +nodes = [ + "tests/test_deployment_assets.py::test_cloud_run_deployment_defaults_permission_enforcement_to_log_only", + "tests/test_deployment_assets.py::test_cloud_run_deployment_rejects_invalid_permission_enforcement", + "tests/test_deployment_assets.py::test_permission_enforcement_reaches_candidate_and_authenticated_tests", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "uv run pytest tests/" + [[rule]] id = "sim.deploy.traffic-changes-exact" category = "deployment"