Skip to content

#609 Run the web container as a non-root user - #639

Merged
ajs-uk-dev merged 7 commits into
mainfrom
feature/609-non_root_web_container
Oct 19, 2026
Merged

ajs-uk-dev merged 7 commits into
mainfrom
feature/609-non_root_web_container

Conversation

@ajs-uk-dev

@ajs-uk-dev ajs-uk-dev commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #609. Part of #607.

Why: least privilege. The worker image already ran as the aspnet image's app user; the web image had no USER line, so it ran as root.

What

  • USER $APP_UID in the Web Dockerfile's base stage. That is uid 1654, numeric so that Kubernetes' runAsNonRoot check can read it. The VS Code debug-runtime stage switches to root for its apt-get install and back to the app user afterwards.

  • run_as_non_root = true on module "web_application" and module "rules_engine_worker". The vendored aks/application module has this variable on main, testing and stable (checked with gh api: variables.tf:185, wired into the container security_context at resources.tf:188).

  • ContainerUserSourceTests (unit, source-assertion), which checks that:

    • the last real USER directive in each shipped stage (Web runtime, worker final, both FROM base) is $APP_UID;
    • each base stage declares it;
    • the debug stage takes root for apt-get and hands back afterwards;
    • both AKS modules set run_as_non_root = true (on an uncommented line).

    Mutation-checked. Each of these fails the test: a USER root added to runtime, commenting out the base directive, and commenting out the Terraform flag.

  • One sentence in docs/technical-stakeholders-functional-overview.md.

  • Also: a one-line E2E locator fix for ContentStagingImportProblemsTests. That test comes from 501 Obey the URL segment rules when editing and importing #629, and #380 File uploads use the GOV.UK enhanced control so Dragon users can choose a file #632's enhanced file upload moved the bundle id onto a button, so #bundle becomes input[name='bundle']. It has been the one red test on main's whole-suite runs since 16 Oct. It is a separate commit. Refs CMS - segment editing should obey the segment formatting rules #501.

Nothing writes to disk at run time:

  • data-protection keys persist to blob storage (PersistKeysToAzureBlobStorage; the framework fallback /home/app is owned by app);
  • ASP.NET's multipart buffering for large uploads goes to /tmp (1777, TMPDIR unset);
  • every ContentRootPath use is a read;
  • Serilog writes to the console and Postgres.

/app is root-owned 755 and nothing writes there.

Proven locally

  • docker run --rm --entrypoint id cypd_web:latest gives uid=1654(app) gid=1654(app) groups=1654(app), and Config.User is 1654.
  • On the compose stack, docker compose exec web id gives the same uid, /healthcheck returns 200, and the web log has no permission errors.
  • The E2E smoke set, run in the compose e2e-tests container against the non-root web container: Passed 57, Failed 0.
  • ContentStagingImportProblemsTests failed before the fix ("Node is not an HTMLInputElement") and passes after it.
  • The VS Code debug image still builds.
  • terraform fmt -check (1.14.5) is clean.
  • Release build: 0 errors. Unit 7,477 / 0 failed. Integration 1,099 / 0 failed.

Rollback note: after this merges, any web image built before it has no USER, so redeploying one with the new Terraform fails with CreateContainerConfigError: container has runAsNonRoot and image will run as root. The rolling update keeps the old pods, so this is not an outage. Rolling the web image back past this PR needs run_as_non_root reverted in the same deploy.

After merge (development): review apps don't enable DfE Analytics federated auth, so the first place the web pod reads the workload-identity token as uid 1654 is development. Once development has deployed, check that BigQuery web_request rows still arrive, or that the pod logs show no analytics or credential errors. A failure there would be silent: events dropped, but no outage.

Done when (from the issue):

  • id in the review app's web pod shows uid 1654 (see the comment below once the review app is up);
  • the pods start with run_as_non_root = true;
  • the smoke tests pass on the review app.

Andy Smith and others added 6 commits October 19, 2026 11:20
PR #632 made every file input the GOV.UK enhanced File upload, whose
script moves the input's id onto its button, so Page.Locator("#bundle")
resolved to a <button> and SetInputFilesAsync failed with "Node is not an
HTMLInputElement". Every whole-suite run on main since 16 Oct has
reported this one failure. Target the native input by name, as the rest
of the suite does.

Refs #501, #380

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Red on purpose: the Web Dockerfile has no USER line and neither AKS
module sets run_as_non_root. The next commits turn it green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
USER $APP_UID in the base stage, as the worker image already does, so
the runtime stage CI deploys runs as uid 1654 instead of root. The VS
Code debug stage takes root back for its apt-get install and hands it
over again after the copy. Nothing the app does at run time writes
outside what that user can write: data-protection keys go to blob
storage and upload buffering goes to /tmp.

Proven locally: id inside the built image is uid=1654(app), /healthcheck
answers 200 and the E2E smoke set passes against the container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both images now carry a numeric USER, so Kubernetes can refuse to start
either pod as root. The shared aks/application module exposes the flag
on every branch the environments pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test only looked for "USER $APP_UID" inside base, so a later USER root
in base, Web runtime or worker final, or a comment naming the directive,
still passed. It now takes the last real USER directive across base plus the
shipped stage, matches directives (not comments) in the debug-stage and
stage-header regexes, anchors the run_as_non_root check, and the class
comment no longer claims a root image starts fine on AKS (it is refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Review app for PR 639 was deleted

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

E2E tests passed

358 passed, 0 failed, 2 skipped in 13 min 31 s

Commit dedcbbc against https://check-performance-data-pr-639.test.teacherservices.cloud (run)

@ajs-uk-dev

Copy link
Copy Markdown
Contributor Author

Review-app proof so far (commit c79b88a):

  • Deployment To Review passed. The Terraform plan in that job shows run_as_non_root = true on the containers' security_context, and the module waits for rollout (wait_for_rollout = true). So the pods started with Kubernetes enforcing non-root: a root image would have stopped the rollout with CreateContainerConfigError.
  • E2E smoke tests on the review app: Passed! - Failed: 0, Passed: 57, Skipped: 0, Total: 57.

I don't have cluster access from this machine. @davidgouge, could you run these two commands and paste the output here? They are the direct id proof the issue asks for:

make review get-cluster-credentials PR_NUMBER=639
kubectl -n schoolstandards-development exec deployment/check-performance-data-pr-639 -- id
kubectl -n schoolstandards-development get pod -l app=check-performance-data-pr-639 -o jsonpath='{.items[0].spec.containers[0].securityContext}'

Expected output: uid=1654(app) gid=1654(app) groups=1654(app), and a securityContext that contains "runAsNonRoot":true.

# Conflicts:
#	tests/DfE.CheckPerformanceData.E2ETests/Admin/ContentStagingImportProblemsTests.cs
@ajs-uk-dev
ajs-uk-dev merged commit 6d369f9 into main Oct 19, 2026
9 checks passed

This branch was successfully deployed

1 active deployment
review — dedcbbc6 Deployed Oct 19, 2026 by ajs-uk-dev via Delete Review App 639 #341
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run the web container as a non-root user

2 participants