fix(913): scripted playout callbacks authenticate with a build-scoped token #939

Open
timothy wants to merge 1 commits from 913-scripted-playout-401 into main
Owner

fixes #913

Root cause

ScriptedScheduleController (/api/v1/scripted/playout/build/{buildId}) sits under the global ApiAuthorizationFilter (mutating verbs always need a key; reads too since Api:RequireKeyForReads defaults true). ScriptedPlayoutBuilder launched the user's program with host, build id and mode only, and entrypoint.py built its client with no credential, so every callback was a 401 and a scripted build produced nothing. #563 characterises scripted playout in-process with no filter, which is why nothing caught it.

Fix: a build-scoped credential, not [SkipApiAuthorization]

  • StartSession mints a 256-bit CSPRNG token per build (independent of the build id). ScriptedPlayoutBuilder passes it in ETV_SCRIPTED_BUILD_TOKEN (never argv: the arguments are logged and visible in the process table; the positional contract is unchanged). The script sends it as X-Scripted-Build-Token.
  • The controller carries [AcceptsScriptedBuildToken]; the filter accepts the token on that controller only, constant-time compared, valid only for the build named by the route buildId and only until EndSession. Machine key and session still work.
  • Rejected: [SkipApiAuthorization] on the whole controller. It would widen a mutating surface to any local process (and anything that can reach localhost:UiPort), for all builds, forever; a scoped credential costs one header and keeps the surface fail-closed. Decision record: api.scripted-build-token.
  • entrypoint.py reads the env var and sets the header; docs/api-conventions.md section 9 and docs/testing.md updated; testing.scripted-engine-in-process-net 401 clause updated.

Tests

  • ScriptedScheduleAuthorizationTests drives the real filter over the endpoint metadata MVC builds from the production Startup for every action: no credential 401, running build's token accepted, other build / ended build / wrong / empty token and token on an unmarked endpoint 401, machine key still works.
  • ScriptedBuildTokenTests covers minting, validation and the launch environment.

Measured (2026-10-30, rebased on origin/main)

  • ErsatzTV.Tests 2154 passed / 6 skipped (pre-existing); Core.Tests 723 / 1 skipped; Scanner.Tests 1504; FFmpeg.Tests 83; Architecture.Tests 7; 0 failed. dotnet format whitespace clean, no BOMs.
  • web: check:api, lint, typecheck, build OK; vitest 1371 passed. scripts/tests 1615 passed, 3 skipped. ruff clean. check-doc-narrative 0 warnings.
  • Live E2E (scripts/e2e-local.sh, port 8510, fresh config, Scripted playout whose program is a curl probe): the program received 3 args plus the env token; callback with no credential 401, with the build token 200, the same token against another build id 401; machine key on an unknown build 404, bogus token 401; logged launch arguments contain no token. The pre-fix state (no credential sent) is the "no credential 401" row.

Review history

<<REVIEW HISTORY
round 1: 3 lens(es); 0 blocking, 0 should-fix, 12 nit; clean — loop ended
REVIEW HISTORY>>

No fix commit exists because round one was clean.

Cross-family review: codex could not run in round 1 (no VERDICT line); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window

Deliberately left

The 12 round-one nits are not blocking and are not separately tracked; the Cli.Wrap launch hop itself stays outside the automated suite (testing.scripted-engine-in-process-net), held by ScriptedBuildTokenTests on CreateCommand and the live run above.

🤖 Generated with Claude Code

fixes #913 ## Root cause `ScriptedScheduleController` (`/api/v1/scripted/playout/build/{buildId}`) sits under the global `ApiAuthorizationFilter` (mutating verbs always need a key; reads too since `Api:RequireKeyForReads` defaults true). `ScriptedPlayoutBuilder` launched the user's program with host, build id and mode only, and `entrypoint.py` built its client with no credential, so every callback was a 401 and a scripted build produced nothing. #563 characterises scripted playout in-process with no filter, which is why nothing caught it. ## Fix: a build-scoped credential, not `[SkipApiAuthorization]` - `StartSession` mints a 256-bit CSPRNG token per build (independent of the build id). `ScriptedPlayoutBuilder` passes it in `ETV_SCRIPTED_BUILD_TOKEN` (never argv: the arguments are logged and visible in the process table; the positional contract is unchanged). The script sends it as `X-Scripted-Build-Token`. - The controller carries `[AcceptsScriptedBuildToken]`; the filter accepts the token on that controller only, constant-time compared, valid only for the build named by the route `buildId` and only until `EndSession`. Machine key and session still work. - Rejected: `[SkipApiAuthorization]` on the whole controller. It would widen a mutating surface to any local process (and anything that can reach localhost:UiPort), for all builds, forever; a scoped credential costs one header and keeps the surface fail-closed. Decision record: `api.scripted-build-token`. - `entrypoint.py` reads the env var and sets the header; `docs/api-conventions.md` section 9 and `docs/testing.md` updated; `testing.scripted-engine-in-process-net` 401 clause updated. ## Tests - `ScriptedScheduleAuthorizationTests` drives the real filter over the endpoint metadata MVC builds from the production `Startup` for every action: no credential 401, running build's token accepted, other build / ended build / wrong / empty token and token on an unmarked endpoint 401, machine key still works. - `ScriptedBuildTokenTests` covers minting, validation and the launch environment. ## Measured (2026-10-30, rebased on origin/main) - ErsatzTV.Tests 2154 passed / 6 skipped (pre-existing); Core.Tests 723 / 1 skipped; Scanner.Tests 1504; FFmpeg.Tests 83; Architecture.Tests 7; 0 failed. dotnet format whitespace clean, no BOMs. - web: check:api, lint, typecheck, build OK; vitest 1371 passed. scripts/tests 1615 passed, 3 skipped. ruff clean. check-doc-narrative 0 warnings. - Live E2E (`scripts/e2e-local.sh`, port 8510, fresh config, Scripted playout whose program is a curl probe): the program received 3 args plus the env token; callback with no credential 401, with the build token 200, the same token against another build id 401; machine key on an unknown build 404, bogus token 401; logged launch arguments contain no token. The pre-fix state (no credential sent) is the "no credential 401" row. ## Review history <<REVIEW HISTORY round 1: 3 lens(es); 0 blocking, 0 should-fix, 12 nit; clean — loop ended REVIEW HISTORY>> No fix commit exists because round one was clean. Cross-family review: codex could not run in round 1 (no VERDICT line); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window ## Deliberately left The 12 round-one nits are not blocking and are not separately tracked; the `Cli.Wrap` launch hop itself stays outside the automated suite (`testing.scripted-engine-in-process-net`), held by `ScriptedBuildTokenTests` on `CreateCommand` and the live run above. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 1 commit 2026-10-30 00:50:29 +01:00
fix(913): scripted playout callbacks authenticate with a build-scoped token
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Failing after 10s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 324dbdb
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m52s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
324dbdbca0
A scripted playout's program calls back into ScriptedScheduleController,
which is under the fail-closed global ApiAuthorizationFilter, but was
handed no credential. Reproduced 2026-10-29 on a live local instance at
origin/main e919b925f (scripts/e2e-local.sh, port 8510): the program's
GET .../context and POST .../add_collection both returned 401, and the
playout's buildStatus still read success=true with no items, because a
program that exits 0 after the failure (entrypoint.py catches
ApiException and does) looks like a clean build.

Fix: ScriptedPlayoutBuilderService.StartSession mints a 256-bit token per
build, independent of the build id; ScriptedPlayoutBuilder passes it in
ETV_SCRIPTED_BUILD_TOKEN (environment, not argv: argv is logged at INF
and a fourth positional would shift the user's own arguments);
ApiAuthorizationFilter accepts it as X-Scripted-Build-Token on endpoints
carrying [AcceptsScriptedBuildToken] (ScriptedScheduleController only),
for the build named by the buildId route value, until EndSession.
entrypoint.py sends it as a default header. [SkipApiAuthorization] on the
controller was rejected: it widens the auth-exempt set by a mutating
controller whose only remaining gate would be a logged GUID, and the
Startup host check does not make the endpoint localhost-only.

Tests: ScriptedScheduleAuthorizationTests drives the real filter over the
endpoint metadata MVC builds from the production Startup for all 28
actions; ScriptedBuildTokenTests covers minting/validation and the
launch environment. Clause mutations, each rebuilt and run 2026-10-29:
  - drop [AcceptsScriptedBuildToken] from the controller -> red:
    Running_Builds_Own_Token_Is_Accepted..., Scripted_Controller_Is_Gated...
  - disarm the filter's token clause (no return) -> red: Running_Builds_Own_Token...
  - delete the filter's marker check -> red: Token_Opens_Nothing_Outside...
  - add [SkipApiAuthorization] to the controller -> red: No_Credential...,
    Scripted_Controller_Is_Gated..., Token_Of_Another..., Token_Is_A_401_Once...,
    Wrong_Or_Empty_Token (x2), ApiControllerSecurityTests.Only_Scanner_And_Auth...
  - token comparison always true -> red: Token_Of_Another..., Wrong_Or_Empty("not-the-token")
  - drop WithEnvironmentVariables from CreateCommand -> red: Launch_Hands_The_Token...
  - RouteValueName "buildId" -> "id" -> red: Token_Route_Value_Is_The_Controllers...

Live E2E on the fixed build (port 8510, fresh config): the real
entrypoint.py with the generated etv_client copied from the
ersatztv-test image built 3 playout items from a scanned collection;
as the program, own token -> 200 on GET context and POST add_collection;
no header, wrong token, another build's id, and the token on
GET /api/v1/channels and POST /api/v1/collections -> 401; the token
after the build ended -> 401; token absent from the server log.

Docs: new record api.scripted-build-token; testing.scripted-engine-in-
process-net's 401 clause now records the outcome; api-conventions §9 and
testing.md updated; catalog regenerated. OpenAPI regenerated: no drift.

Fixes #913

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
Some checks are pending
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Failing after 10s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 324dbdb
Required
Details
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m52s
Required
Details
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Required
Details
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Some required checks are missing.
You are not authorized to merge this pull request.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin 913-scripted-playout-401:913-scripted-playout-401
git checkout 913-scripted-playout-401
Sign in to join this conversation.