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
2 changes: 1 addition & 1 deletion docs/technical-stakeholders-functional-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ Producer/consumer split so requests never wait on Notify: `RequestNotificationSe

## 16. Hosting & deployment

- **Docker**: multi-stage Dockerfiles for Web and Worker (sdk:10.0 → aspnet:10.0, port 8080); pinned Playwright image for E2E; nginx maintenance page (`maintenance_page/`).
- **Docker**: multi-stage Dockerfiles for Web and Worker (sdk:10.0 → aspnet:10.0, port 8080); both runtime images run as the aspnet image's unprivileged `app` user (uid 1654, `USER $APP_UID`) and the AKS modules enforce `run_as_non_root` (#609); pinned Playwright image for E2E; nginx maintenance page (`maintenance_page/`).
- **docker-compose**: profiled services — `web`, `rules_engine`, `db` (Postgres 18.1-alpine), `azurite` (+ one-shot `azurite_init` rules seeder), `pgadmin`; `docker-compose.e2e.yml` adds the one-shot `e2e-tests` runner (kept separate so the VS `docker-compose.dcproj` orchestration ignores it). Never add a `docker-compose.override.yml` (breaks `make test-e2e`).
- **CI/CD** (`.github/workflows/build-and-deploy.yml`): build + push both images to GHCR + Snyk scan → PR review apps (`deploy` label) → E2E (non-visual) against the review app → sequential deploy matrix **development → qa → preproduction → production** on push to `main`, via shared `DFE-Digital/github-actions` actions. Also: DB backup/PTR/restore workflows, maintenance toggle, Terraform validation, review-app teardown.
- **Terraform** (`terraform/application/`): AKS web app + worker (separate replica counts, exec health probes), Azure Postgres **Flexible Server v17** (note: local compose runs 18.1), two storage accounts (app: `files`, `rules-config`; private LDS ingress account), two Key Vaults, StatusCake, GCP WIF for analytics; `terraform/domains/` for DNS + Front Door. Environments: development, qa, review (per-PR), preproduction, production (`config/*.tfvars.json`).
Expand Down
12 changes: 12 additions & 0 deletions src/DfE.CheckPerformanceData.Web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
EXPOSE 8080
# Run as the image's unprivileged `app` user (least privilege, #609), as the worker image does.
# $APP_UID is the numeric uid 1654 the aspnet image defines; it must stay numeric because
# Kubernetes' runAsNonRoot check reads the image's USER and refuses a name. The app writes
# nothing to disk at run time: data-protection keys persist to blob storage, and the multipart
# buffering ASP.NET does for large uploads goes to /tmp, which is world-writable.
USER $APP_UID

# Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
Expand Down Expand Up @@ -57,6 +63,9 @@ RUN dotnet publish DfE.CheckPerformanceData.Web/DfE.CheckPerformanceData.Web.csp
# Derives from `base` so it carries the same OS security updates.
FROM base AS debug-runtime

# `base` runs as the app user; installing the debugger needs root. Handed back below.
USER root

# Install vsdbg — the .NET debugger used by VS Code Docker/C# Dev Kit
RUN apt-get update && apt-get install -y --no-install-recommends unzip wget && \
rm -rf /var/lib/apt/lists/* && \
Expand All @@ -65,6 +74,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends unzip wget && \

COPY --from=vscode-debug /app/publish .

# Back to the unprivileged user so the debug image runs the way the deployed one does.
USER $APP_UID

# Disable debug wait so the app starts immediately; the debugger attaches on demand
ENV COMPlus_DebugWait=0

Expand Down
7 changes: 7 additions & 0 deletions terraform/application/application.tf
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ module "web_application" {
enable_logit = var.enable_logit
web_port = 8080

# The image runs as the numeric app user (USER $APP_UID in its Dockerfile); have Kubernetes
# refuse to start it as anything else (#609, OWASP least privilege).
run_as_non_root = true

send_traffic_to_maintenance_page = var.send_traffic_to_maintenance_page

enable_gcp_wif = var.enable_dfe_analytics_federated_auth
Expand Down Expand Up @@ -82,6 +86,9 @@ module "rules_engine_worker" {
docker_image = var.worker_docker_image
enable_logit = var.enable_logit

# The worker image has run as the numeric app user all along; enforce it too (#609).
run_as_non_root = true

# Gate startup and liveness on readiness so a worker that cannot reach its database
# or queue is detected and restarted rather than left silently stuck. The liveness
# path is the lighter process-up signal exposed for orchestrators that probe it.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;

namespace DfE.CheckPerformanceData.Application.UnitTests.Web;

// Both runtime images must run as the aspnet image's unprivileged `app` user, and the AKS modules
// must enforce it (#609, OWASP least privilege). The user is written as `USER $APP_UID` — uid 1654,
// numeric — because Kubernetes' runAsNonRoot check reads the image's USER and refuses a name.
// Locally nothing notices a dropped line: the image builds and compose runs it as root. On AKS the
// pod is refused at deploy time, far from the cause.
public sealed class ContainerUserSourceTests
{
[Theory]
[InlineData("DfE.CheckPerformanceData.Web")]
[InlineData("DfE.CheckPerformanceData.RulesEngineWorker")]
public void BaseStage_DeclaresTheAppUser(string project)
{
var dockerfile = File.ReadAllText(DockerfilePath(project));

Assert.Contains("$APP_UID", UserDirectives(StageNamed(dockerfile, "base")));
}

// The effective user is the last USER directive across the stages the shipped one builds on, so a
// later `USER root` in the shipped stage (or in base) must fail even though base still names the app user.
[Theory]
[InlineData("DfE.CheckPerformanceData.Web", "runtime")]
[InlineData("DfE.CheckPerformanceData.RulesEngineWorker", "final")]
public void ShippedStage_RunsAsTheAppUser(string project, string shippedStage)
{
var dockerfile = File.ReadAllText(DockerfilePath(project));
var shipped = StageNamed(dockerfile, shippedStage);

Assert.StartsWith("FROM base AS", shipped);

var users = UserDirectives(StageNamed(dockerfile, "base")).Concat(UserDirectives(shipped)).ToList();

Assert.NotEmpty(users);
Assert.Equal("$APP_UID", users[^1]);
}

[Fact]
public void WebDebugStage_HandsBackToTheAppUser_AfterInstallingTheDebugger()
{
var dockerfile = File.ReadAllText(DockerfilePath("DfE.CheckPerformanceData.Web"));
var stage = StageNamed(dockerfile, "debug-runtime");

var users = UserMatches(stage);
var install = stage.IndexOf("apt-get install", StringComparison.Ordinal);
var root = users.FirstOrDefault(m => m.Groups[1].Value == "root");
var app = users.LastOrDefault(m => m.Groups[1].Value == "$APP_UID");

Assert.True(install >= 0, "the debug stage installs the debugger with apt-get");
Assert.True(root is not null && root.Index < install, "the apt-get install needs root, so USER root must come before it");
Assert.True(app is not null && app.Index > install, "the stage must hand back to the app user after the install");
}

[Theory]
[InlineData("web_application")]
[InlineData("rules_engine_worker")]
public void AksModule_EnforcesNonRoot(string module)
{
var tf = File.ReadAllText(Path.Combine(RepoRoot, "terraform", "application", "application.tf"));

Assert.Matches(new Regex(@"^[ \t]*run_as_non_root[ \t]*=[ \t]*true\b", RegexOptions.Multiline), ModuleBlock(tf, module));
}

// The text of one build stage: its `FROM … AS <name>` line through to the next `FROM` or the end.
private static string StageNamed(string dockerfile, string name)
{
var match = Regex.Match(
dockerfile,
$@"^FROM [^\r\n]* AS {Regex.Escape(name)}[ \t]*\r?$.*?(?=^FROM |\z)",
RegexOptions.Multiline | RegexOptions.Singleline);

Assert.True(match.Success, $"no stage named {name}");
return match.Value;
}

// Real USER directives only (anchored at line start), so a comment naming one never counts.
private static MatchCollection UserMatches(string stageText) =>
Regex.Matches(stageText, @"^[ \t]*USER[ \t]+(\S+)[ \t]*\r?$", RegexOptions.Multiline);

private static List<string> UserDirectives(string stageText) =>
UserMatches(stageText).Select(m => m.Groups[1].Value).ToList();

private static string DockerfilePath(string project) => Path.Combine(RepoRoot, "src", project, "Dockerfile");

// The text of one `module "<name>" { … }` block, up to its closing brace at column 0.
private static string ModuleBlock(string tf, string name)
{
var start = tf.IndexOf($"module \"{name}\" {{", StringComparison.Ordinal);
Assert.True(start >= 0, $"no module {name}");

var end = tf.IndexOf("\n}", start, StringComparison.Ordinal);
Assert.True(end > start, $"module {name} never closes");
return tf[start..end];
}

private static string RepoRoot
{
get
{
var dir = new DirectoryInfo(Path.GetDirectoryName(ThisFilePath())!);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Makefile")))
{
dir = dir.Parent;
}

return dir?.FullName
?? throw new InvalidOperationException("Could not locate the repository root from the test file path.");
}
}

private static string ThisFilePath([CallerFilePath] string path = "") => path;
}
Loading