From 8ea49a8043de919a7052999171f45d70ddac7767 Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:30:28 +0300 Subject: [PATCH] feat: add Azure/Caddy staging infra, GitHub Pages portfolio, and hardened CI for Module 14 prep Adds the Azure Bicep template, staging Caddy gateway/compose stack, the devops evidence hub docs, GitHub Pages portfolio page, and CI hardening (CodeQL/pinned actions/azure-deploy/publish-gateway-image workflows). Ahead of Module 14; not required by Module 7. --- .github/workflows/azure-deploy.yml | 159 ++++++++++++ .github/workflows/ci.yml | 43 ++++ .github/workflows/deploy-pages.yml | 47 ++++ .github/workflows/publish-gateway-image.yml | 45 ++++ .gitignore | 4 + README.md | 10 + docs/devops/README.md | 47 ++++ docs/devops/azure-student-deployment.md | 228 ++++++++++++++++++ docs/devops/github-hardening-checklist.md | 93 +++++++ docs/devops/github-pages.md | 23 ++ docs/devops/release-evidence.md | 42 ++++ infra/azure/main.bicep | 253 ++++++++++++++++++++ ops/gateway/Caddyfile | 23 ++ ops/gateway/Dockerfile | 11 + ops/staging/.env.example | 47 ++++ ops/staging/Caddyfile | 31 +++ ops/staging/README.md | 83 +++++++ ops/staging/compose.yaml | 210 ++++++++++++++++ ops/staging/cors.staging.example.json | 9 + portfolio/index.html | 88 +++++++ 20 files changed, 1496 insertions(+) create mode 100644 .github/workflows/azure-deploy.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-pages.yml create mode 100644 .github/workflows/publish-gateway-image.yml create mode 100644 docs/devops/README.md create mode 100644 docs/devops/azure-student-deployment.md create mode 100644 docs/devops/github-hardening-checklist.md create mode 100644 docs/devops/github-pages.md create mode 100644 docs/devops/release-evidence.md create mode 100644 infra/azure/main.bicep create mode 100644 ops/gateway/Caddyfile create mode 100644 ops/gateway/Dockerfile create mode 100644 ops/staging/.env.example create mode 100644 ops/staging/Caddyfile create mode 100644 ops/staging/README.md create mode 100644 ops/staging/compose.yaml create mode 100644 ops/staging/cors.staging.example.json create mode 100644 portfolio/index.html diff --git a/.github/workflows/azure-deploy.yml b/.github/workflows/azure-deploy.yml new file mode 100644 index 0000000..b08948b --- /dev/null +++ b/.github/workflows/azure-deploy.yml @@ -0,0 +1,159 @@ +name: Deploy reviewed Azure portfolio revision + +on: + workflow_dispatch: + inputs: + release_id: + description: 'Existing release-evidence identifier, for example v0.14.0-demo.1' + required: true + type: string + +# OIDC is used instead of an Azure client secret. This workflow has no cloud +# credentials until the repository owner configures the azure-demo environment. +permissions: + contents: read + id-token: write + +concurrency: + group: azure-demo + cancel-in-progress: false + +jobs: + deploy: + name: Deploy reviewed single-instance revision + runs-on: ubuntu-latest + environment: azure-demo + env: + AZURE_RESOURCE_GROUP: ${{ vars.AZURE_RESOURCE_GROUP }} + AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} + AZURE_CONTAINERAPP_NAME: ${{ vars.AZURE_CONTAINERAPP_NAME }} + AZURE_MANAGED_ENVIRONMENT_ID: ${{ vars.AZURE_MANAGED_ENVIRONMENT_ID }} + GATEWAY_IMAGE_DIGEST: ${{ vars.GATEWAY_IMAGE_DIGEST }} + FRONTEND_IMAGE_DIGEST: ${{ vars.FRONTEND_IMAGE_DIGEST }} + BACKEND_IMAGE_DIGEST: ${{ vars.BACKEND_IMAGE_DIGEST }} + STORAGE_BUCKET_NAME: ${{ vars.STORAGE_BUCKET_NAME }} + STORAGE_SERVICE_URL: ${{ vars.STORAGE_SERVICE_URL }} + STORAGE_REGION: ${{ vars.STORAGE_REGION }} + RECAPTCHA_SITE_KEY: ${{ vars.RECAPTCHA_SITE_KEY }} + EMAIL_FROM_NAME: ${{ vars.EMAIL_FROM_NAME }} + DATABASE_CONNECTION_STRING: ${{ secrets.DATABASE_CONNECTION_STRING }} + MIGRATION_DATABASE_CONNECTION_STRING: ${{ secrets.MIGRATION_DATABASE_CONNECTION_STRING }} + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + LOBBY_CREDENTIAL_KEY: ${{ secrets.LOBBY_CREDENTIAL_KEY }} + RECAPTCHA_SECRET_KEY: ${{ secrets.RECAPTCHA_SECRET_KEY }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + EMAIL_FROM: ${{ secrets.EMAIL_FROM }} + EMAIL_SMTP_HOST: ${{ secrets.EMAIL_SMTP_HOST }} + EMAIL_SMTP_USERNAME: ${{ secrets.EMAIL_SMTP_USERNAME }} + EMAIL_SMTP_PASSWORD: ${{ secrets.EMAIL_SMTP_PASSWORD }} + STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }} + STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} + APP_ORIGIN: ${{ secrets.APP_ORIGIN }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate deployment inputs are release digests + shell: bash + run: | + set -euo pipefail + for image in "$GATEWAY_IMAGE_DIGEST" "$FRONTEND_IMAGE_DIGEST" "$BACKEND_IMAGE_DIGEST"; do + [[ "$image" == *@sha256:* ]] || { echo 'Every image must be image@sha256:digest.'; exit 1; } + done + [[ -n "$AZURE_RESOURCE_GROUP" && -n "$AZURE_CONTAINERAPP_NAME" && -n "$AZURE_MANAGED_ENVIRONMENT_ID" ]] + + - name: Sign in to Azure using GitHub OIDC + uses: azure/login@eec3c95657c1536435858eda1f3ff5437fee8474 # v2.3.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Preview Azure changes + shell: bash + run: | + set -euo pipefail + az deployment group what-if \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --template-file infra/azure/main.bicep \ + --parameters \ + location="$AZURE_LOCATION" \ + containerAppName="$AZURE_CONTAINERAPP_NAME" \ + managedEnvironmentId="$AZURE_MANAGED_ENVIRONMENT_ID" \ + gatewayImage="$GATEWAY_IMAGE_DIGEST" \ + frontendImage="$FRONTEND_IMAGE_DIGEST" \ + backendImage="$BACKEND_IMAGE_DIGEST" \ + databaseConnectionString="$DATABASE_CONNECTION_STRING" \ + migrationDatabaseConnectionString="$MIGRATION_DATABASE_CONNECTION_STRING" \ + jwtSecretKey="$JWT_SECRET_KEY" \ + lobbyCredentialKey="$LOBBY_CREDENTIAL_KEY" \ + recaptchaSecretKey="$RECAPTCHA_SECRET_KEY" \ + googleClientId="$GOOGLE_CLIENT_ID" \ + emailFrom="$EMAIL_FROM" \ + emailSmtpHost="$EMAIL_SMTP_HOST" \ + emailSmtpUsername="$EMAIL_SMTP_USERNAME" \ + emailSmtpPassword="$EMAIL_SMTP_PASSWORD" \ + storageAccessKey="$STORAGE_ACCESS_KEY" \ + storageSecretKey="$STORAGE_SECRET_KEY" \ + appOrigin="$APP_ORIGIN" \ + storageBucketName="$STORAGE_BUCKET_NAME" \ + storageServiceUrl="$STORAGE_SERVICE_URL" \ + storageRegion="$STORAGE_REGION" \ + recaptchaSiteKey="$RECAPTCHA_SITE_KEY" \ + emailFromName="$EMAIL_FROM_NAME" + + - name: Deploy the reviewed revision + shell: bash + run: | + set -euo pipefail + az deployment group create \ + --name "${{ inputs.release_id }}-${GITHUB_RUN_ID}" \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --template-file infra/azure/main.bicep \ + --parameters \ + location="$AZURE_LOCATION" \ + containerAppName="$AZURE_CONTAINERAPP_NAME" \ + managedEnvironmentId="$AZURE_MANAGED_ENVIRONMENT_ID" \ + gatewayImage="$GATEWAY_IMAGE_DIGEST" \ + frontendImage="$FRONTEND_IMAGE_DIGEST" \ + backendImage="$BACKEND_IMAGE_DIGEST" \ + databaseConnectionString="$DATABASE_CONNECTION_STRING" \ + migrationDatabaseConnectionString="$MIGRATION_DATABASE_CONNECTION_STRING" \ + jwtSecretKey="$JWT_SECRET_KEY" \ + lobbyCredentialKey="$LOBBY_CREDENTIAL_KEY" \ + recaptchaSecretKey="$RECAPTCHA_SECRET_KEY" \ + googleClientId="$GOOGLE_CLIENT_ID" \ + emailFrom="$EMAIL_FROM" \ + emailSmtpHost="$EMAIL_SMTP_HOST" \ + emailSmtpUsername="$EMAIL_SMTP_USERNAME" \ + emailSmtpPassword="$EMAIL_SMTP_PASSWORD" \ + storageAccessKey="$STORAGE_ACCESS_KEY" \ + storageSecretKey="$STORAGE_SECRET_KEY" \ + appOrigin="$APP_ORIGIN" \ + storageBucketName="$STORAGE_BUCKET_NAME" \ + storageServiceUrl="$STORAGE_SERVICE_URL" \ + storageRegion="$STORAGE_REGION" \ + recaptchaSiteKey="$RECAPTCHA_SITE_KEY" \ + emailFromName="$EMAIL_FROM_NAME" \ + --query properties.outputs -o json > deployment-outputs.json + + - name: Verify public readiness through Caddy + shell: bash + run: | + set -euo pipefail + origin=$(jq -r '.publicOrigin.value' deployment-outputs.json) + for attempt in {1..36}; do + if curl --fail --silent --show-error "$origin/health/ready" | jq -e '.status == "healthy"' >/dev/null; then + echo "public_origin=$origin" >> "$GITHUB_OUTPUT" + exit 0 + fi + sleep 10 + done + echo "Readiness failed for $origin" >&2 + exit 1 + + - name: Publish safe deployment summary + shell: bash + run: | + echo '### Azure portfolio deployment' >> "$GITHUB_STEP_SUMMARY" + echo "Release evidence ID: \`${{ inputs.release_id }}\`" >> "$GITHUB_STEP_SUMMARY" + jq -r '.publicOrigin.value, .deployedBackendImage.value, .deployedFrontendImage.value, .gatewayImageDigest.value' deployment-outputs.json | sed 's/^/- `/' | sed 's/$/`/' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9046127 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main, "feature/**"] + pull_request: + branches: [main] + +# This is the status check required by the Project repository's protect-main +# ruleset. Deployment remains manual and separately environment-gated. +permissions: + contents: read + +jobs: + delivery-config: + name: Validate delivery configuration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate portable Compose configuration + run: docker compose --env-file ops/staging/.env.example -f ops/staging/compose.yaml config --quiet + + - name: Validate staging Caddy routes + run: | + docker run --rm \ + -v "$PWD/ops/staging/Caddyfile:/etc/caddy/Caddyfile:ro" \ + caddy:2.10.2-alpine \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + + - name: Validate Azure gateway Caddy routes + run: | + docker run --rm \ + -v "$PWD/ops/gateway/Caddyfile:/etc/caddy/Caddyfile:ro" \ + caddy:2.10.2-alpine \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + + - name: Compile Azure Bicep + run: | + docker run --rm \ + --mount "type=bind,source=$PWD/infra/azure,target=/work,readonly" \ + mcr.microsoft.com/azure-cli:2.79.0 \ + az bicep build --file /work/main.bicep --outfile /tmp/main.json diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..0d4ad68 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,47 @@ +name: Deploy portfolio evidence hub + +on: + push: + branches: [main] + paths: + - portfolio/** + - .github/workflows/deploy-pages.yml + workflow_dispatch: + +permissions: {} + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Upload static evidence hub + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + steps: + - name: Check out static site + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Configure GitHub Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: portfolio + + deploy: + name: Deploy static evidence hub + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/publish-gateway-image.yml b/.github/workflows/publish-gateway-image.yml new file mode 100644 index 0000000..dea97f4 --- /dev/null +++ b/.github/workflows/publish-gateway-image.yml @@ -0,0 +1,45 @@ +name: Publish immutable gateway image + +on: + workflow_dispatch: + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + IMAGE_NAME: ghcr.io/simpleplatform/simple-gateway + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push immutable gateway + id: push + uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6.19.0 + with: + context: ops/gateway + push: true + tags: ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + - name: Attest image build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + - name: Record digest in job summary + run: | + echo '### Immutable gateway image' >> "$GITHUB_STEP_SUMMARY" + echo "\`${IMAGE_NAME}@${{ steps.push.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 73c53e3..f7ad12f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ Thumbs.db *.bak *.swp docs/handoffs/ + +# Local staging configuration. Copy ops/staging/.env.example to ops/staging/.env +# and keep the resulting values on the operator machine only. +ops/staging/.env diff --git a/README.md b/README.md index 7768798..816d68a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,16 @@ authenticated pages in the shared `AppShell` layout. Feature components live in `src/features/`, UI primitives in `src/components/ui/`, and current planned product data lives in `src/mock/`. +## Engineering Evidence & Delivery + +The [DevOps evidence hub](docs/devops/README.md) records current verification levels, portable-staging +guidance, release-evidence rules, and the manual GitHub hardening checklist. It uses explicit local/CI/ +staging/deployment levels so documentation never turns a local check into a cloud-deployment claim. + +The zero-dependency [portfolio evidence page](portfolio/index.html) can be published through GitHub Pages +after the repository owner enables it manually. It is a public navigation page, not an application deployment +or availability monitor. + --- ## Current Status diff --git a/docs/devops/README.md b/docs/devops/README.md new file mode 100644 index 0000000..be46375 --- /dev/null +++ b/docs/devops/README.md @@ -0,0 +1,47 @@ +# DevOps evidence hub + +This folder is the permanent, public record of how SimPle is built and reviewed. It is intentionally +evidence-led: a document or workflow is not proof that a cloud deployment, CI run, security scan, or +backup restore has happened. + +## Current truthful status + +| Area | Status | +|---|---| +| Modules 1–4 | Completed locally and merged, with module-level evidence in this repository | +| Modules 5–6 | Locally complete; hosted CI, portable-staging, and deployment evidence is deferred to Module 14 | +| Modules 7–13 | Planned or in progress; no cloud resource should be created for normal module work | +| Module 14 delivery foundation | Repository-owned implementation added; full portable-staging and hosted-CI evidence is still pending | +| Cloud deployment | Not created and not claimed | + +Read the [portable staging guide](../../ops/staging/README.md), the +[GitHub hardening checklist](github-hardening-checklist.md), the +[release-evidence guide](release-evidence.md), and the +[GitHub Pages guide](github-pages.md), and the +[Azure student deployment runbook](azure-student-deployment.md) before describing the project publicly. + +## Evidence levels + +Keep these independent in every release note and LinkedIn post: + +| Field | It means | It does not mean | +|---|---|---| +| `LocalComplete` | Required local checks for the exact candidate were recorded | Hosted CI or a deployed environment passed | +| `CiVerified` | The exact committed release tuple passed hosted CI | Portable staging or cloud deployment passed | +| `PortableStagingVerified` | The exact tuple passed documented disposable Compose staging checks | A public cloud environment is available | +| `DeploymentVerified` | A human-approved cloud environment was checked against that tuple | High availability, scale-out, or perpetual hosting | + +The project is a **portfolio demo / single-instance system** until a separately evidenced change says +otherwise. Do not call it "production ready," "highly available," or deployed based only on this folder. + +## What recruiters can inspect + +- Module documentation, technical flows, test reports, and security audits under `docs/modules/` and + `docs/security/`. +- The source-controlled local staging topology under `ops/staging/`. +- A no-secret static overview under `portfolio/`, suitable for GitHub Pages after the repository owner + enables Pages manually. +- Future release evidence under `docs/releases//`, where every passing check must identify the + backend, frontend, and Project SHAs plus image digests. + +The static portfolio is a navigation aid, not a replacement for source records or CI artifacts. diff --git a/docs/devops/azure-student-deployment.md b/docs/devops/azure-student-deployment.md new file mode 100644 index 0000000..31ed631 --- /dev/null +++ b/docs/devops/azure-student-deployment.md @@ -0,0 +1,228 @@ +# Azure student demo: beginner runbook + +This is the human-operated part of the delivery foundation. Complete it only after Modules 7-13 are +finished and their normal module workflow has produced its final configuration checklist. It creates real +external accounts, so this repository intentionally cannot perform these steps for you. + +The target is an honest **portfolio demo / single-instance** deployment: + +```text +browser (one HTTPS origin) + -> Azure Container Apps ingress -> Caddy -> frontend (localhost:3000) + -> backend + SignalR (localhost:8081) + -> Neon PostgreSQL (TLS) + -> private Backblaze B2 bucket (short-lived signed media URLs) +``` + +The public evidence hub remains GitHub Pages/source documentation even if the student Azure resources are +later stopped or disabled. Do not describe this as highly available or production-ready. + +## Guardrails before creating any account + +1. Finish the [GitHub hardening checklist](github-hardening-checklist.md) in all three repositories. +2. Do **not** add a credit/debit card or upgrade to a paid plan. If any provider demands one for the private + demo path below, stop there and keep using local Compose staging instead. +3. Store values only in the GitHub environment and Azure Container App secrets described below. Never put a + production value in `.env`, `appsettings*.json`, a commit, an Actions log, a screenshot, or LinkedIn. +4. A budget is an alert, not a spending kill switch. Azure evaluates budget cost periodically; it can arrive + after usage. Keep `minReplicas: 0`, `maxReplicas: 1`, and stop the Container App when you are not + demonstrating it. + +## 1. Create the no-card accounts + +### Azure for Students + +1. Go to [Azure for Students](https://azure.microsoft.com/en-us/free/students/) while signed in with your + student email. Azure states that it has no card requirement and includes student credit; read the offer + shown for your country before accepting it. +2. In the Azure portal, open **Cost Management + Billing**. Create one monthly budget scoped to the future + `rg-simple-portfolio` resource group with an amount of **USD 50** (or your local equivalent). +3. Azure budget thresholds are percentages, not fixed-dollar fields, and the minimum threshold is greater + than zero. Create actual-cost email alerts at **1%** (the closest practical early warning), **20%** + (USD 10 of a USD 50 budget), and **100%** (USD 50). Also watch Azure's student-credit notifications. +4. Create resource group `rg-simple-portfolio` in the same region you plan to use for Neon. Create a + **Container Apps environment** in that group. Use Consumption, not a dedicated workload profile. Enable + log collection only if you understand the student's credit impact; it is useful evidence, but it is not + a guarantee of $0 usage. +5. Copy the Container Apps environment resource ID from **Properties**. It starts with + `/subscriptions/.../resourceGroups/rg-simple-portfolio/providers/Microsoft.App/managedEnvironments/...`. + +Azure Container Apps supports a zero minimum replica count; the Bicep template deliberately caps this demo +at one replica. Never add a second backend replica until the distributed realtime, rate-limit, and data +protection design is implemented and tested. + +### Neon PostgreSQL + +1. Create a [Neon Free](https://neon.com/pricing) account and a project. The current Free plan advertises + no card requirement; keep it on Free. +2. Create a database such as `simple_demo` in the region closest to the Azure Container Apps environment. +3. Copy the supplied **direct** TLS connection string for a schema-owner/deployment user. Keep its SSL mode; + do not add `Trust Server Certificate=true`. +4. Create a separate `simple_app` login role for the running API. Give it only `CONNECT`, schema `USAGE`, + table `SELECT/INSERT/UPDATE/DELETE`, and sequence `USAGE/SELECT/UPDATE` rights. Set matching default + privileges so a future migration grants the same rights to new tables/sequences. +5. Keep two values: `MIGRATION_DATABASE_CONNECTION_STRING` for the schema owner and + `DATABASE_CONNECTION_STRING` for `simple_app`. The Bicep init migration/seed jobs use the former; the + running backend uses the latter. Never place either connection string in a repository. + +Before a real release, rehearse restore in a **new Neon branch/database**: restore the backup/point-in-time +copy, point a disposable Compose staging run at it, and run the read-only smoke plus the relevant E2E +checks. Record the timestamp and outcome in release evidence. Do not restore over the demo database. + +### Backblaze B2 media storage + +1. Create a [B2 S3-compatible account](https://www.backblaze.com/sign-up/s3). Its current signup page says + no card is required. B2 pricing includes the first 10 GB of storage, but do not rely on that as a hard + cap: stay below it and watch the B2 Caps & Alerts/Billing pages. +2. Enable **B2 Cloud Storage**, then create a uniquely named **private** bucket, for example + `simple-demo-profile-media-`. Do not create a public bucket: Backblaze documents extra + payment-history/card requirements for a first public bucket, and SimPle does not need one. +3. Add a lifecycle rule that removes unfinished/hidden upload remnants promptly. Keep profile-media storage + small and manually check the storage cap after every demo upload batch. +4. Create an application key restricted to this one bucket (and, if the console supports it, the + `profile-assets/` prefix). Grant only the S3 operations the SDK needs: list, read, write, and delete. + Copy the key ID and secret **once** into a password manager; revoke and recreate it if you lose it. +5. Add a bucket CORS rule with exactly one allowed origin: the final HTTPS Container Apps origin. Allow only + the required signed upload/read methods and headers; never use `*`. +6. Record the bucket name, region, and S3 endpoint as GitHub variables. Treat key ID and secret as secrets. + +### Google OAuth, reCAPTCHA, and Gmail SMTP + +The Azure URL is not known until a harmless bootstrap revision exists, so do these after the first successful +Azure deployment below. + +1. In Google Cloud Console, create a **Web application** OAuth client. For this app's Google Identity popup + flow, add the exact `https://` as an **Authorized JavaScript origin**. Do not use a + wildcard, HTTP, an IP address, or a trailing path. The current implementation uses the popup credential + callback, not a redirect URI. +2. In the reCAPTCHA console, make a separate production v2 key pair and add only that hostname. Keep + `localhost` in a separate development key; do not disable domain validation. +3. For the demo sender Gmail account, turn on 2-Step Verification and create one named App Password (for + example `SimPle portfolio demo`). Configure `smtp.gmail.com`, port **587**, the sender address as + username/from address, and the generated app password. Never use SMTP port 25. Revoke the App Password + after the demo if no longer needed. +4. Build/publish a new frontend image after changing the public Google Client ID or reCAPTCHA site key: + Next.js embeds `NEXT_PUBLIC_*` values at build time. The public site key/client ID is not a secret, but + it should still be set as a GitHub environment variable rather than committed. + +Use Mailtrap or another non-delivery SMTP sink only for local portable staging. Do not use a real Gmail app +password in Compose. + +## 2. Set up GitHub image publishing + +The three manual workflows below push digest-addressed images to GitHub Container Registry (GHCR): + +| Repository | Workflow | Image | +|---|---|---| +| `SimPLe.Backend` | **Publish immutable backend image** | `ghcr.io/simpleplatform/simple-backend` | +| `SimpLe.Frontend` | **Publish immutable frontend image** | `ghcr.io/simpleplatform/simple-frontend` | +| `SimPle.Project` | **Publish immutable gateway image** | `ghcr.io/simpleplatform/simple-gateway` | + +For the frontend repository, create the `azure-demo` GitHub environment and add these **environment +variables** before publishing the final image: + +| Name | Value | +|---|---| +| `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | Google web client ID | +| `NEXT_PUBLIC_RECAPTCHA_SITE_KEY` | production reCAPTCHA site key | + +For each repository, run its publish workflow only for a green `main` commit. Open the resulting GHCR +package settings and make the package **public** so Azure Container Apps can pull it without a registry +password. Copy the exact `image@sha256:...` digest from the workflow summary; never use only the `sha-...` +tag. The workflow creates a GitHub provenance attestation for that digest; link it in release evidence. + +## 3. Configure GitHub OIDC and the Project environment + +Do this in `SimPle.Project` after the Azure resource group/environment exist. + +1. Azure portal -> **Microsoft Entra ID** -> **App registrations** -> **New registration**. Name it + `simple-github-deploy`; no client secret and no redirect URI are needed. +2. Copy its **Application (client) ID** and **Directory (tenant) ID**. Copy the Azure subscription ID from + **Subscriptions**. +3. In `rg-simple-portfolio` -> **Access control (IAM)** -> **Add role assignment**, grant that application + **Contributor** at the resource-group scope. Do not grant Owner or subscription-wide access. +4. In the app registration -> **Federated credentials** -> **Add credential** -> **GitHub Actions deploying + Azure resources**. Set organization `SimPlePlatform`, repository `SimPle.Project`, entity type + **Environment**, and value `azure-demo`. +5. In `SimPle.Project` -> **Settings** -> **Environments**, create `azure-demo`. Add the following values. + +### `azure-demo` variables + +| Variable | What to enter | +|---|---| +| `AZURE_RESOURCE_GROUP` | `rg-simple-portfolio` | +| `AZURE_LOCATION` | your chosen Azure region | +| `AZURE_CONTAINERAPP_NAME` | a globally valid short name, e.g. `simple-demo-` | +| `AZURE_MANAGED_ENVIRONMENT_ID` | ID copied from the Container Apps environment | +| `GATEWAY_IMAGE_DIGEST` | published gateway `image@sha256:...` | +| `FRONTEND_IMAGE_DIGEST` | published frontend `image@sha256:...` | +| `BACKEND_IMAGE_DIGEST` | published backend `image@sha256:...` | +| `STORAGE_BUCKET_NAME` | the private B2 bucket name | +| `STORAGE_SERVICE_URL` | B2 S3 endpoint from its console | +| `STORAGE_REGION` | B2 region, e.g. `us-east-005` | +| `RECAPTCHA_SITE_KEY` | public production site key | +| `EMAIL_FROM_NAME` | `SimPle` or your demo sender label | + +### `azure-demo` secrets + +| Secret | What it is | +|---|---| +| `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` | OIDC identity IDs; no Azure client secret exists | +| `DATABASE_CONNECTION_STRING` | Neon least-privilege application-role TLS connection string | +| `MIGRATION_DATABASE_CONNECTION_STRING` | Neon schema-owner TLS connection string | +| `JWT_SECRET_KEY` | a newly generated 32+ character secret | +| `LOBBY_CREDENTIAL_KEY` | a different newly generated 32+ character secret | +| `RECAPTCHA_SECRET_KEY`, `GOOGLE_CLIENT_ID` | production Google provider values | +| `EMAIL_FROM`, `EMAIL_SMTP_HOST`, `EMAIL_SMTP_USERNAME`, `EMAIL_SMTP_PASSWORD` | dedicated Gmail SMTP configuration | +| `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY` | B2 bucket-scoped application key | +| `APP_ORIGIN` | exact `https://` | + +The `azure-deploy.yml` workflow validates that every image value contains `@sha256:` and uses GitHub OIDC. +It never needs an Azure client secret or a stored cloud password. + +## 4. Bootstrap, then configure the final revision + +1. Create Neon first. For the first deployment only, use harmless placeholder Google/SMTP/B2 values that + satisfy configuration validation and set `APP_ORIGIN` to a temporary HTTPS placeholder. Publish matching + frontend public placeholder variables. Do not invite people or create real accounts in this bootstrap + revision. +2. In `SimPle.Project` -> **Actions**, run **Deploy reviewed Azure portfolio revision**. Enter a release + evidence ID such as `bootstrap-not-a-release`. The Bicep template creates one Container App revision; + its init containers apply migrations and idempotent seed data before the public containers start. +3. Copy the `publicOrigin` from the successful deployment summary. Confirm the browser sees one HTTPS origin + and `GET /health/ready` returns `{"status":"healthy"}` through Caddy. +4. Now complete the B2, Google, reCAPTCHA, and Gmail sections above using that exact origin. Update the + Project environment values/secrets, rebuild and republish the frontend image, update its digest variable, + then run the deploy workflow again with a genuine reviewed release ID. +5. Test as two accounts: register/verify/reset/login/logout/Google login; profile media upload/read/replace/ + delete; friends; catalog; lobby; reconnect; and expected failure paths. Run Playwright against the public + origin only after Module 14's release suite exists. Record each result separately. + +## 5. Operations, rollback, and public evidence + +- **Logs and correlation:** Azure Container Apps logs should be JSON. Copy an `X-Correlation-ID` from a + failed browser/API response into Log Analytics/Container Apps log search. Do not put request payloads, + cookies, or secrets in a dashboard or post. +- **Dashboard/alerts:** create a small dashboard for revision health, replica count, failed requests, and + restart count. Create alerts for readiness failure, repeated restarts, and an unexpected replica count. + Add the alert links and the human response steps to release evidence. These are operational signals, not + proof of high availability. +- **Rollback:** Azure portal -> Container App -> **Revisions** -> activate the last known-good revision. + Verify `/health/ready`, then record the incident, correlation ID, before/after digests, and reason. Do not + roll a database schema backward casually; prefer a tested forward fix unless the migration runbook says + otherwise. +- **Stop when idle:** Azure portal -> Container App -> deactivate the active revision or delete the resource + group when the demonstration period ends. Deleting the resource group is irreversible for those resources; + export the evidence first and verify the GitHub Pages hub still works. +- **LinkedIn wording:** say `single-instance portfolio demo with OIDC deployment, digest-pinned containers, + health probes, SBOM/provenance, and documented rollback`. Do not say `production-ready`, `always on`, or + `highly available`. + +## Provider references + +- [Azure for Students](https://azure.microsoft.com/en-us/free/students/) +- [Azure budgets and their alert thresholds](https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/quick-create-budget-template) +- [Azure Container Apps scaling](https://learn.microsoft.com/en-us/azure/container-apps/scale-app) +- [Neon Free pricing](https://neon.com/pricing) +- [Backblaze B2 private buckets](https://help.backblaze.com/hc/en-us/articles/1260803542610-Creating-a-B2-Bucket-using-the-Web-UI), [application keys](https://www.backblaze.com/docs/en/cloud-storage-application-keys), and [CORS](https://www.backblaze.com/docs/cloud-storage-cross-origin-resource-sharing-rules) +- [Google OAuth origin rules](https://developers.google.com/identity/oauth2/web/guides/error), [reCAPTCHA domain validation](https://developers.google.com/recaptcha/docs/domain_validation), and [Gmail App Passwords](https://support.google.com/mail/answer/185833) diff --git a/docs/devops/github-hardening-checklist.md b/docs/devops/github-hardening-checklist.md new file mode 100644 index 0000000..25dd38f --- /dev/null +++ b/docs/devops/github-hardening-checklist.md @@ -0,0 +1,93 @@ +# GitHub hardening checklist (manual, no cost) + +Complete these steps in each public SimPle repository: `SimPLe.Backend`, `SimpLe.Frontend`, and +`SimPle.Project`. They change account/repository settings, so they cannot safely be completed by the +workflow or committed as code. GitHub Actions is free for public repositories; none of these steps needs a +cloud provider, a credit card, or a secret. + +Record the completion date and Settings-page URL in a private note or a release checklist. Do not put +recovery codes, tokens, secrets, or security-alert details in a public issue, screenshot, or LinkedIn post. + +## 1. Protect your GitHub account + +1. Open GitHub → profile picture → **Settings** → **Password and authentication**. +2. Enable two-factor authentication. An authenticator app or a passkey is preferable to SMS. +3. Download recovery codes and store them in a password manager or another private offline location. +4. Confirm you can sign out and recover access before treating this step as complete. + +This reduces the chance that someone who learns your password can push code or change repository settings. + +## 2. Turn on repository security features + +Repeat this in each repository: **Settings** → **Security** → **Code security and analysis** (GitHub's +labels can differ slightly by account type). + +1. Enable **Dependabot alerts**. GitHub will notify you when a dependency is known to be vulnerable. +2. Enable **Dependabot security updates**. GitHub can open a proposed update pull request when a safe update + is available. +3. Enable **Secret scanning** and **push protection**. Push protection warns or blocks a commit that appears + to contain a credential. It is not a reason to put test credentials in source control. +4. Enable **CodeQL default setup** if GitHub offers it for the repository language, or wait for the + reviewed CodeQL workflow delivered in the relevant backend/frontend repository. Verify that the first + analysis has actually completed; an enabled toggle is not a clean result. + +If a feature is unavailable in the interface, record `Unavailable on this plan/account` rather than marking +it complete. Never bypass a push-protection alert by committing a real credential. + +## 3. Add a `main` branch ruleset + +In each repository, open **Settings** → **Rules** → **Rulesets** → **New branch ruleset**. + +1. Name it `protect-main`, set enforcement to **Active**, and target the default branch `main`. +2. Require a pull request before merging. For a solo portfolio project, set required approvals to **0** so + you keep a reviewable PR trail without needing a second account. +3. Block force pushes and branch deletion. +4. After the repository's own CI workflow has passed once on `main`, require its exact status checks. Do not + select a similarly named check from another repository. The backend, frontend, and Project orchestration + checks are independent. +5. Do not enable automatic bypasses for routine work. If GitHub requires the repository owner to retain an + emergency bypass, use it only for documented recovery and record why. + +Rulesets protect history and make CI gates meaningful. They do not make unrun CI pass. + +### Required status checks after the DevOps foundation is pushed + +The PR/deletion/force-push rules are not enough on their own: the `protect-main` ruleset must also contain a +**Require status checks to pass** rule. Add the following primary job only after the updated workflow has run +successfully once in that repository, so GitHub can offer the exact check name: + +| Repository | Select this check in the `protect-main` ruleset | +|---|---| +| `SimPLe.Backend` | `CI / Build, test, package, and container smoke` | +| `SimpLe.Frontend` | `CI / Build, test, package, and container smoke` | +| `SimPle.Project` | `CI / Validate delivery configuration` | + +Do not require the attest-only jobs: they deliberately run only after the main validation job has passed. +After saving each ruleset, from the workspace root run: + +```powershell +node scripts/check-github-hardening.mjs +``` + +This read-only checker confirms repository-visible settings and fails if a required status-check rule is still +missing. GitHub deliberately does not expose recovery codes, and the current CLI token may not expose alert +details, so 2FA/recovery-code ownership remains a manual confirmation. + +## 4. Verify and publish only safe evidence + +1. Open the **Actions** and **Security** tabs. Confirm each expected workflow has a real completed run and + review any alerts. +2. Keep the public run URL, commit SHA, workflow name, and outcome in release evidence. Keep screenshots + cropped so they contain no secret values, email addresses, or alert payloads. +3. If a dependency/security alert remains, report its severity, owner, review date, and mitigation in the + relevant private or public security record—never label the project secure merely because scanning exists. + +## Completion record + +Use one row per repository: + +| Repository | 2FA owner confirmed | Dependabot | Secret scanning/push protection | CodeQL run URL | `main` ruleset | Date / notes | +|---|---:|---:|---:|---|---:|---| +| SimPLe.Backend | ☐ | ☐ | ☐ | | ☐ | | +| SimpLe.Frontend | ☐ | ☐ | ☐ | | ☐ | | +| SimPle.Project | ☐ | ☐ | ☐ | | ☐ | | diff --git a/docs/devops/github-pages.md b/docs/devops/github-pages.md new file mode 100644 index 0000000..d2983c3 --- /dev/null +++ b/docs/devops/github-pages.md @@ -0,0 +1,23 @@ +# GitHub Pages evidence hub + +`portfolio/index.html` is a zero-dependency static overview. It contains no telemetry, form, secret, +provider credential, or deployment endpoint. The workflow in `.github/workflows/deploy-pages.yml` only +uploads that folder to GitHub Pages; it does not provision Azure, deploy the app, or change repository +security settings. + +## One-time manual enablement + +After this branch is merged into `main`: + +1. Open `SimPle.Project` on GitHub → **Settings** → **Pages**. +2. Under **Build and deployment**, choose **GitHub Actions** as the source and save. +3. Open **Actions**, run **Deploy portfolio evidence hub** (or push a change under `portfolio/`), and wait for + the `deploy` job to finish. +4. Open the job's published URL. Verify the page says “Not a deployment status page” and links only to public + source evidence. +5. Save the successful workflow URL in the portfolio/release evidence. If Pages is disabled, unavailable, or + fails, keep the repository source as the canonical evidence hub and mark Pages as unavailable—not deployed. + +The workflow uses SHA-pinned official GitHub actions and least job permissions. It requires no secret, credit +card, or external account. GitHub Pages availability is controlled by the repository/account settings, so +this repository cannot truthfully assume it has been enabled. diff --git a/docs/devops/release-evidence.md b/docs/devops/release-evidence.md new file mode 100644 index 0000000..28ae2b2 --- /dev/null +++ b/docs/devops/release-evidence.md @@ -0,0 +1,42 @@ +# Release evidence guide + +Use this guide after Modules 7–13 are complete and Module 14 has created real build, migration, scan, +SBOM, provenance, and E2E artifacts. It deliberately does **not** authorize a cloud deployment or let a +lower evidence level imply a higher one. + +## Release tuple + +Every release record must name the exact: + +```text +(backendSha, frontendSha, projectSha, migrationHead, backendImageDigest, frontendImageDigest) +``` + +Do not use a workspace-root SHA: the workspace root is not a Git repository. A local dirty candidate also +needs deterministic source-tree digests for all modified/approved-untracked files; it cannot be described +as CI-verified before those exact changes are committed and CI has run. + +## Required record for each check + +For tests, scans, migrations, backup/restore, container smoke, E2E, SBOM, provenance, and human review, +record the tool/version/configuration, tuple hash, command, start time, exit code, environment, result, +limitations, and durable evidence location. `Skipped`, `Unknown`, or scan-service outage is a blocker—not a +pass. + +Store durable Project-repository evidence at: + +```text +docs/releases//module-14-release-evidence.json +``` + +CI run URLs and expiring CI artifacts are supporting links, not the only evidence copy. Append a new release +record when CI or deployment results arrive; do not overwrite a prior local record to make it look stronger. + +## Minimum honest release wording + +Good: “The exact release tuple passed local checks and portable staging; hosted CI and cloud deployment are +recorded separately.” + +Not acceptable: “Production-ready,” “fully deployed,” “secure,” or “highly available” without the specific +human-approved evidence that supports that claim. Phase 1 remains single-instance until the project designs, +implements, and tests a distributed realtime/data-protection/rate-limit architecture. diff --git a/infra/azure/main.bicep b/infra/azure/main.bicep new file mode 100644 index 0000000..ee25c62 --- /dev/null +++ b/infra/azure/main.bicep @@ -0,0 +1,253 @@ +@description('The Azure Container Apps managed environment ID created for this single-instance portfolio demo.') +param managedEnvironmentId string + +@description('The Azure region. Keep this close to the student account and the chosen Neon region.') +param location string = resourceGroup().location + +@description('Name of the public Container App.') +param containerAppName string + +@description('Public, immutable Caddy gateway image digest from SimPle.Project.') +param gatewayImage string + +@description('Public, immutable frontend image digest from SimpLe.Frontend.') +param frontendImage string + +@description('Public, immutable backend image digest from SimPLe.Backend.') +param backendImage string + +@secure() +@description('SSL-required Neon PostgreSQL connection string for the application user.') +param databaseConnectionString string + +@secure() +@description('Schema-owner Neon PostgreSQL connection string used only by the idempotent migration and seed init jobs.') +param migrationDatabaseConnectionString string + +@secure() +param jwtSecretKey string + +@secure() +param lobbyCredentialKey string + +@secure() +param recaptchaSecretKey string + +@secure() +param googleClientId string + +@secure() +param emailFrom string + +@secure() +param emailSmtpHost string + +@secure() +param emailSmtpUsername string + +@secure() +param emailSmtpPassword string + +@secure() +@description('Backblaze B2 S3-compatible application key ID.') +param storageAccessKey string + +@secure() +@description('Backblaze B2 S3-compatible application key secret.') +param storageSecretKey string + +@secure() +@description('Public application origin, for example https://your-app.region.azurecontainerapps.io.') +param appOrigin string + +@description('B2 bucket name. This is configuration, not a credential.') +param storageBucketName string + +@description('B2 S3 endpoint, for example https://s3.us-east-005.backblazeb2.com.') +param storageServiceUrl string + +@description('B2 region used by the S3-compatible client.') +param storageRegion string = 'us-east-005' + +@description('Public reCAPTCHA site key compiled into the frontend image; recorded here only for deployment evidence.') +param recaptchaSiteKey string + +@description('Email sender label.') +param emailFromName string = 'SimPle' + +@description('SMTP submission port. Gmail app passwords use 587, never 25.') +param emailSmtpPort int = 587 + +var secrets = [ + { name: 'database-connection-string', value: databaseConnectionString } + { name: 'migration-database-connection-string', value: migrationDatabaseConnectionString } + { name: 'jwt-secret-key', value: jwtSecretKey } + { name: 'lobby-credential-key', value: lobbyCredentialKey } + { name: 'recaptcha-secret-key', value: recaptchaSecretKey } + { name: 'google-client-id', value: googleClientId } + { name: 'email-from', value: emailFrom } + { name: 'email-smtp-host', value: emailSmtpHost } + { name: 'email-smtp-username', value: emailSmtpUsername } + { name: 'email-smtp-password', value: emailSmtpPassword } + { name: 'storage-access-key', value: storageAccessKey } + { name: 'storage-secret-key', value: storageSecretKey } + { name: 'app-origin', value: appOrigin } +] + +resource app 'Microsoft.App/containerApps@2024-03-01' = { + name: containerAppName + location: location + properties: { + managedEnvironmentId: managedEnvironmentId + configuration: { + activeRevisionsMode: 'Single' + secrets: secrets + ingress: { + external: true + targetPort: 8080 + transport: 'auto' + allowInsecure: false + traffic: [ + { + latestRevision: true + weight: 100 + } + ] + } + } + template: { + // Phase 1 safety boundary: Caddy, frontend, workers, and SignalR all share exactly one backend process. + scale: { + minReplicas: 0 + maxReplicas: 1 + } + // These idempotent init jobs run once for each newly created revision, before any public container starts. + // They make an app revision fail closed rather than serve a schema that its code does not understand. + initContainers: [ + { + name: 'migrate' + image: backendImage + args: ['--apply-migrations'] + resources: { + cpu: json('0.25') + memory: '0.5Gi' + } + env: [ + { name: 'ConnectionStrings__DefaultConnection', secretRef: 'migration-database-connection-string' } + ] + } + { + name: 'seed' + image: backendImage + args: ['--seed'] + resources: { + cpu: json('0.25') + memory: '0.5Gi' + } + env: [ + { name: 'ConnectionStrings__DefaultConnection', secretRef: 'migration-database-connection-string' } + ] + } + ] + containers: [ + { + name: 'gateway' + image: gatewayImage + resources: { + cpu: json('0.25') + memory: '0.5Gi' + } + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/health/live' + port: 8080 + } + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + } + ] + } + { + name: 'frontend' + image: frontendImage + resources: { + cpu: json('0.25') + memory: '0.5Gi' + } + } + { + name: 'backend' + image: backendImage + resources: { + cpu: json('0.5') + memory: '1Gi' + } + env: [ + { name: 'ASPNETCORE_ENVIRONMENT', value: 'Production' } + { name: 'ASPNETCORE_URLS', value: 'http://+:8081' } + { name: 'ConnectionStrings__DefaultConnection', secretRef: 'database-connection-string' } + { name: 'Jwt__Issuer', value: 'SimPle' } + { name: 'Jwt__Audience', value: 'SimPle' } + { name: 'Jwt__SecretKey', secretRef: 'jwt-secret-key' } + { name: 'LobbyCredential__Key', secretRef: 'lobby-credential-key' } + { name: 'LobbyCredential__DefaultRegion', value: 'eu-west' } + { name: 'Recaptcha__SecretKey', secretRef: 'recaptcha-secret-key' } + { name: 'Recaptcha__VerificationUrl', value: 'https://www.google.com/recaptcha/api/siteverify' } + { name: 'Google__ClientId', secretRef: 'google-client-id' } + { name: 'Email__From', secretRef: 'email-from' } + { name: 'Email__FromName', value: emailFromName } + { name: 'Email__SmtpHost', secretRef: 'email-smtp-host' } + { name: 'Email__SmtpPort', value: string(emailSmtpPort) } + { name: 'Email__Username', secretRef: 'email-smtp-username' } + { name: 'Email__Password', secretRef: 'email-smtp-password' } + { name: 'Email__AppUrl', secretRef: 'app-origin' } + { name: 'Storage__Provider', value: 'S3Compatible' } + { name: 'Storage__BucketName', value: storageBucketName } + { name: 'Storage__Region', value: storageRegion } + { name: 'Storage__ServiceUrl', value: storageServiceUrl } + { name: 'Storage__AccessKey', secretRef: 'storage-access-key' } + { name: 'Storage__SecretKey', secretRef: 'storage-secret-key' } + { name: 'Storage__ProfilePrefix', value: 'profile-assets' } + { name: 'Storage__ForcePathStyle', value: 'false' } + { name: 'Cors__AllowedOrigin', secretRef: 'app-origin' } + ] + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/health/live' + port: 8081 + } + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + } + { + type: 'Readiness' + httpGet: { + path: '/health/ready' + port: 8081 + } + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + } + ] + } + ] + } + } +} + +output containerAppFqdn string = app.properties.configuration.ingress.fqdn +output publicOrigin string = 'https://${app.properties.configuration.ingress.fqdn}' +output deployedBackendImage string = backendImage +output deployedFrontendImage string = frontendImage +output gatewayImageDigest string = gatewayImage +output configuredRecaptchaSiteKey string = recaptchaSiteKey diff --git a/ops/gateway/Caddyfile b/ops/gateway/Caddyfile new file mode 100644 index 0000000..de5d0ce --- /dev/null +++ b/ops/gateway/Caddyfile @@ -0,0 +1,23 @@ +{ + auto_https off + admin off +} + +# Azure Container Apps puts the three containers in one revision. Caddy is the +# only public listener; browser API and SignalR requests keep the same origin +# and are forwarded to the single local backend process. +:8080 { + log { + output stdout + format json + } + + @backend path /api/* /hubs/* /health/* + handle @backend { + reverse_proxy 127.0.0.1:8081 + } + + handle { + reverse_proxy 127.0.0.1:3000 + } +} diff --git a/ops/gateway/Dockerfile b/ops/gateway/Dockerfile new file mode 100644 index 0000000..6864da4 --- /dev/null +++ b/ops/gateway/Dockerfile @@ -0,0 +1,11 @@ +FROM caddy:2.10.2-alpine + +# The public gateway has only route configuration: application credentials stay +# in the backend Container App secrets, never in this image. +RUN addgroup -S -g 10001 simplegateway \ + && adduser -S -D -H -u 10001 -G simplegateway simplegateway \ + && chown -R simplegateway:simplegateway /config /data /etc/caddy + +COPY --chown=simplegateway:simplegateway Caddyfile /etc/caddy/Caddyfile + +USER 10001 diff --git a/ops/staging/.env.example b/ops/staging/.env.example new file mode 100644 index 0000000..b8bd574 --- /dev/null +++ b/ops/staging/.env.example @@ -0,0 +1,47 @@ +# Copy to .env. This example intentionally contains no working secret or image digest. +# Do not commit .env, screenshots of it, or CI logs containing its values. + +# Replace every `replace-before-use` image reference with the immutable image@sha256 digest +# recorded in the reviewed release tuple. Tags are examples only and are not release evidence. +BACKEND_IMAGE=registry.example.invalid/simple/backend:replace-before-use +FRONTEND_IMAGE=registry.example.invalid/simple/frontend:replace-before-use +CADDY_IMAGE=caddy:2.10.2-alpine +POSTGRES_IMAGE=postgres:16-alpine +MINIO_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z +MINIO_CLIENT_IMAGE=minio/mc:RELEASE.2025-04-16T18-13-26Z + +# Local, disposable staging only. Generate unique values on your own machine. +POSTGRES_DB=simple_staging +POSTGRES_USER=replace_before_use +POSTGRES_PASSWORD=replace_before_use +MINIO_ROOT_USER=replace_before_use +MINIO_ROOT_PASSWORD=replace_before_use + +# The gateway binds only to localhost. This is not a public deployment address. +STAGING_HTTP_PORT=8080 +APP_ORIGIN=http://localhost:8080 +S3_PUBLIC_ENDPOINT=http://storage.localhost:8080 +S3_BUCKET=simple-profile-assets-staging +S3_REGION=us-east-1 +S3_PROFILE_PREFIX=profile-assets +S3_UPLOAD_URL_EXPIRY_MINUTES=5 +S3_READ_URL_EXPIRY_MINUTES=30 + +# Backend secrets. Use unique, non-placeholder values of at least 32 characters for the two keys. +JWT_ISSUER=SimPle.Staging +JWT_AUDIENCE=SimPle.Staging +JWT_SECRET_KEY=replace_with_a_unique_32_plus_character_local_secret +LOBBY_CREDENTIAL_KEY=replace_with_a_different_unique_32_plus_character_local_secret +LOBBY_DEFAULT_REGION=eu-west + +# Provider values remain fake or test-only for portable staging. Never use a live payment key here. +RECAPTCHA_SECRET_KEY=replace_with_a_test_or_fake_provider_value +RECAPTCHA_VERIFICATION_URL=https://www.google.com/recaptcha/api/siteverify +NEXT_PUBLIC_RECAPTCHA_SITE_KEY=replace_with_a_test_or_fake_provider_value +GOOGLE_CLIENT_ID=replace_with_a_test_or_fake_provider_value +EMAIL_FROM=staging@example.invalid +EMAIL_FROM_NAME=SimPle Staging +EMAIL_SMTP_HOST=replace_with_a_local_or_test_smtp_host +EMAIL_SMTP_PORT=1025 +EMAIL_SMTP_USERNAME=replace_before_use +EMAIL_SMTP_PASSWORD=replace_before_use diff --git a/ops/staging/Caddyfile b/ops/staging/Caddyfile new file mode 100644 index 0000000..e3011de --- /dev/null +++ b/ops/staging/Caddyfile @@ -0,0 +1,31 @@ +# Local portable-staging gateway only. TLS is intentionally not configured here: +# Docker Desktop staging binds to localhost, while a future approved cloud ingress terminates TLS. +{ + auto_https off + admin off +} + +# MinIO uses a separate local media hostname so presigned URLs are reachable both by the browser +# and by the backend container. It has no authentication cookies; MinIO's narrowly scoped CORS policy +# is configured by minio-init. A real deployment uses the approved object-store endpoint instead. +http://storage.localhost:8080 { + reverse_proxy minio:9000 +} + +# The application stays on one browser origin. Caddy forwards API and SignalR hub routes to the same +# single backend process, preserving the original Host/cookies and WebSocket upgrade automatically. +http://:8080 { + log { + output stdout + format json + } + + @backend path /api/* /hubs/* /health/* + handle @backend { + reverse_proxy backend:8080 + } + + handle { + reverse_proxy frontend:3000 + } +} diff --git a/ops/staging/README.md b/ops/staging/README.md new file mode 100644 index 0000000..fe8321e --- /dev/null +++ b/ops/staging/README.md @@ -0,0 +1,83 @@ +# Portable staging topology (Module 14 skeleton) + +This directory is a **single-instance, local portable-staging topology**. It is not a cloud deployment, +does not create an account or resource, and has not yet been verified against a release tuple. The current +backend and frontend images do not provide the Module 14 image/job contracts yet, so this is deliberately a +safe wiring skeleton rather than a command that can honestly be called "ready". + +## What it will run after Module 14 image work + +```text +browser -- http://localhost:8080 --> Caddy --> frontend + | \-> backend (/api/*, /hubs/*, /health/*) + \----> MinIO (storage.localhost only) + +backend --> PostgreSQL +backend --> MinIO through storage.localhost +``` + +Only Caddy publishes a host port, and only on `127.0.0.1`. PostgreSQL, the MinIO console, the backend, +and the frontend are private to the Compose network. The MinIO gateway hostname lets browser presigned +uploads work locally without publishing the MinIO port; its CORS file permits only `http://localhost:8080`. + +The app/API/hub share one browser origin. `storage.localhost` is a local media endpoint for presigned S3 +requests, not an authentication origin and not a model for a public deployment. A later B2/S3 deployment +must restrict its CORS policy to the deployed app origin. + +## What must be supplied before it can run + +1. Module 14 must first publish hardened backend and frontend images for the exact reviewed + backend/frontend commits. The backend image also runs the explicit one-shot migration/seed jobs below. + Both images must be recorded as `image@sha256:...` values in a release tuple; do not substitute mutable + tags as evidence. +2. The backend image must expose process-only `GET /health/live` and dependency-aware + `GET /health/ready`. Its Dockerfile owns the actual health check because Compose must not assume a shell + exists in a hardened runtime image. +3. The backend image must implement the two explicit commands used here: `--apply-migrations` and idempotent + `--seed`. Neither job is allowed to start automatically with the app. +4. The frontend image must be built with `NEXT_PUBLIC_API_URL` set to the empty string so current frontend + API calls resolve to same-origin `/api/...`; setting that environment variable at container start is not + enough for a Next.js public variable. +5. The image hardening work must prove non-root execution, read-only root filesystems, writable temp paths, + graceful shutdown, and no secret in layers. Compose removes Linux capabilities and blocks privilege gain, + but cannot prove a sibling repository image satisfies that contract. + +## Beginner-safe local sequence + +1. Install Docker Desktop and start it. This is local software; it does not need an Azure, Neon, B2, or + payment-provider account. +2. From this directory, copy the template: `Copy-Item .env.example .env`. +3. Replace every `replace_before_use` value in `.env` on your own computer. Generate two different random + 32+ character values for `JWT_SECRET_KEY` and `LOBBY_CREDENTIAL_KEY`. `.env` is ignored by Git. +4. Replace all three application image references with the matching immutable digests from the reviewed + release tuple. Also pin the PostgreSQL, MinIO, MinIO client, and Caddy image references to approved + digests before a verification run. +5. Validate only the configuration shape first: + + ```powershell + docker compose --env-file .env -f compose.yaml config + ``` + +6. Once the Module 14 migrator exists, bring up dependencies and run jobs explicitly, one at a time: + + ```powershell + docker compose --env-file .env -f compose.yaml up -d postgres minio minio-init + docker compose --env-file .env -f compose.yaml --profile jobs run --rm migrate + docker compose --env-file .env -f compose.yaml --profile jobs run --rm seed + docker compose --env-file .env -f compose.yaml up -d backend frontend caddy + ``` + +7. Verify `GET /health/live`, then `GET /health/ready`, through Caddy. Run the release's browser E2E suite + against `http://localhost:8080`. Record the exact images, command output, timestamps, and result in + release evidence; a successful `up` alone is not a verification result. +8. Remove disposable local data only when you intend to: `docker compose --env-file .env -f compose.yaml down -v`. + This deletes the local PostgreSQL and MinIO volumes. It must never be pointed at cloud resources. + +## Explicit boundaries + +- There is one backend instance. Do not scale it or enable a second replica: Phase 1 realtime has no + Redis/Azure SignalR backplane or distributed presence design. +- This directory contains no Azure, Neon, Backblaze, Google, Gmail, Stripe, or real user configuration. +- Provider fakes/test settings belong in a local `.env` or CI secret store, never in this repository. +- A future cloud deployment needs separate Bicep/OIDC, Azure Container Apps ingress, TLS, object-storage CORS, + a human approval, and deployment evidence. It is intentionally out of scope for this skeleton. diff --git a/ops/staging/compose.yaml b/ops/staging/compose.yaml new file mode 100644 index 0000000..2070a53 --- /dev/null +++ b/ops/staging/compose.yaml @@ -0,0 +1,210 @@ +# Portable, single-instance *staging* topology for Module 14. +# +# This file deliberately consumes already-built image references. It does not build the sibling backend +# or frontend repositories, does not create cloud resources, and is not deployment evidence by itself. +# Use immutable image digests when a reviewed release tuple exists; .env.example contains safe placeholders. +name: simple-staging + +x-backend-environment: &backend-environment + ASPNETCORE_ENVIRONMENT: Staging + ASPNETCORE_URLS: http://+:8080 + ConnectionStrings__DefaultConnection: Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} + Jwt__Issuer: ${JWT_ISSUER} + Jwt__Audience: ${JWT_AUDIENCE} + Jwt__SecretKey: ${JWT_SECRET_KEY} + LobbyCredential__Key: ${LOBBY_CREDENTIAL_KEY} + LobbyCredential__DefaultRegion: ${LOBBY_DEFAULT_REGION:-eu-west} + Recaptcha__SecretKey: ${RECAPTCHA_SECRET_KEY} + Recaptcha__VerificationUrl: ${RECAPTCHA_VERIFICATION_URL:-https://www.google.com/recaptcha/api/siteverify} + Google__ClientId: ${GOOGLE_CLIENT_ID} + Email__From: ${EMAIL_FROM} + Email__FromName: ${EMAIL_FROM_NAME:-SimPle Staging} + Email__SmtpHost: ${EMAIL_SMTP_HOST} + Email__SmtpPort: ${EMAIL_SMTP_PORT:-1025} + Email__Username: ${EMAIL_SMTP_USERNAME} + Email__Password: ${EMAIL_SMTP_PASSWORD} + Email__AppUrl: ${APP_ORIGIN} + Storage__Provider: S3Compatible + Storage__BucketName: ${S3_BUCKET} + Storage__Region: ${S3_REGION:-us-east-1} + # storage.localhost resolves to Caddy both from a container and from a browser on the host. + # It is a local-staging media origin, not a public deployment URL. + Storage__ServiceUrl: ${S3_PUBLIC_ENDPOINT:-http://storage.localhost:8080} + Storage__AccessKey: ${MINIO_ROOT_USER} + Storage__SecretKey: ${MINIO_ROOT_PASSWORD} + Storage__ProfilePrefix: ${S3_PROFILE_PREFIX:-profile-assets} + Storage__ForcePathStyle: "true" + Storage__UploadUrlExpiryMinutes: ${S3_UPLOAD_URL_EXPIRY_MINUTES:-5} + Storage__ReadUrlExpiryMinutes: ${S3_READ_URL_EXPIRY_MINUTES:-30} + Cors__AllowedOrigin: ${APP_ORIGIN} + +services: + postgres: + image: ${POSTGRES_IMAGE} + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + security_opt: + - no-new-privileges:true + networks: + - private + + minio: + image: ${MINIO_IMAGE} + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + volumes: + - minio-data:/data + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:9000/minio/health/live || exit 1"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + security_opt: + - no-new-privileges:true + networks: + - private + + minio-init: + image: ${MINIO_CLIENT_IMAGE} + restart: "no" + depends_on: + minio: + condition: service_healthy + entrypoint: ["/bin/sh", "-ec"] + command: | + mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" + mc mb --ignore-existing "local/$$S3_BUCKET" + mc anonymous set none "local/$$S3_BUCKET" + mc cors set "local/$$S3_BUCKET" /config/cors.json + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + S3_BUCKET: ${S3_BUCKET} + volumes: + - ./cors.staging.example.json:/config/cors.json:ro + security_opt: + - no-new-privileges:true + networks: + - private + + # Run it explicitly with `docker compose --profile jobs run --rm migrate`; do not start it automatically. + migrate: + image: ${BACKEND_IMAGE} + profiles: ["jobs"] + restart: "no" + command: ["--apply-migrations"] + environment: *backend-environment + depends_on: + postgres: + condition: service_healthy + minio-init: + condition: service_completed_successfully + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: ["ALL"] + networks: + - private + + # This must be an idempotent seed operation implemented by Module 14, never an ad-hoc SQL import. + seed: + image: ${BACKEND_IMAGE} + profiles: ["jobs"] + restart: "no" + command: ["--seed"] + environment: *backend-environment + depends_on: + postgres: + condition: service_healthy + minio-init: + condition: service_completed_successfully + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: ["ALL"] + networks: + - private + + backend: + image: ${BACKEND_IMAGE} + environment: *backend-environment + depends_on: + postgres: + condition: service_healthy + minio-init: + condition: service_completed_successfully + # The Module 14 backend image must provide a real HEALTHCHECK against /health/live. + # Compose cannot add a shell-based check without assuming tooling inside the hardened image. + security_opt: + - no-new-privileges:true + cap_drop: ["ALL"] + networks: + - private + + frontend: + image: ${FRONTEND_IMAGE} + environment: + # The frontend image must be built with same-origin /api behavior; Next.js public variables are build-time. + NEXT_PUBLIC_API_URL: "" + NEXT_PUBLIC_RECAPTCHA_SITE_KEY: ${NEXT_PUBLIC_RECAPTCHA_SITE_KEY} + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + depends_on: + backend: + condition: service_started + security_opt: + - no-new-privileges:true + cap_drop: ["ALL"] + networks: + - private + + caddy: + image: ${CADDY_IMAGE} + depends_on: + backend: + condition: service_started + frontend: + condition: service_started + minio-init: + condition: service_completed_successfully + ports: + - "127.0.0.1:${STAGING_HTTP_PORT:-8080}:8080" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + read_only: true + tmpfs: + - /config + - /data + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: ["ALL"] + networks: + private: + aliases: + # The browser maps *.localhost to loopback; containers resolve this alias to Caddy. + - storage.localhost + +networks: + private: + internal: true + +volumes: + postgres-data: + minio-data: diff --git a/ops/staging/cors.staging.example.json b/ops/staging/cors.staging.example.json new file mode 100644 index 0000000..8d6988c --- /dev/null +++ b/ops/staging/cors.staging.example.json @@ -0,0 +1,9 @@ +[ + { + "AllowedOrigins": ["http://localhost:8080"], + "AllowedMethods": ["GET", "PUT", "HEAD"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3000 + } +] diff --git a/portfolio/index.html b/portfolio/index.html new file mode 100644 index 0000000..4f245f6 --- /dev/null +++ b/portfolio/index.html @@ -0,0 +1,88 @@ + + + + + + + SimPle | Engineering Evidence + + + +
+

SimPle platform · public engineering evidence

+

A social gaming platform, built with an evidence trail.

+

+ SimPle is a modular ASP.NET Core, PostgreSQL, Next.js, and SignalR social-gaming platform. This page + links to the source records behind the work rather than making unsupported deployment or scale claims. +

+ + + +
+

Phase 1 status

+
+
Local evidence

Modules 1–4

Authentication, profile identity, friends, and game discovery have module-level implementation, testing, and security records.

+
Review pending

Modules 5–6

Local work is recorded; final production review and release evidence remain separate outstanding work.

+
Planned / in progress

Modules 7–14

Realtime through delivery readiness are not represented as completed simply because their design documents exist.

+
+
+ +
+

Delivery shape

+

Browser → Caddy gateway → Next.js frontend + ASP.NET Core API/SignalR → PostgreSQL / S3-compatible media

+

The planned portable-staging topology uses one backend instance. Redis/Azure SignalR backplanes, distributed presence, and multi-instance operation are intentionally deferred.

+
+ +
+

How to read the evidence

+ + + + + + + + +
LevelMeaning
LocalCompleteExact candidate local checks were recorded.
CiVerifiedExact committed tuple passed hosted CI.
PortableStagingVerifiedExact tuple passed the documented disposable Compose checks.
DeploymentVerifiedA human-approved cloud environment was checked against that tuple.
+
+ +
+

Inspect the source records

+ +
+ + +
+ +