Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/scripts/deploy-cloud-run-simulation-entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$(
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/simulation-deploy.reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
8 changes: 6 additions & 2 deletions docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 32 | 1 | 0 | 14 | 47 |
| mechanical | 33 | 1 | 0 | 14 | 48 |
| metric | 0 | 0 | 0 | 0 | 0 |
| judgment | 0 | 0 | 0 | 0 | 0 |

**Blocking:** 33 of 47 rules.
**Blocking:** 34 of 48 rules.

## Gaps

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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** |
Expand All @@ -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)")
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/migration/stage-12-modal-worker-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions projects/policyengine-simulation-entry/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
38 changes: 38 additions & 0 deletions projects/policyengine-simulation-entry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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:
Expand All @@ -27,6 +30,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("/")
Expand Down Expand Up @@ -83,3 +100,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
Loading
Loading