Skip to content
Merged
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ dotnet test Cluckwork.sln # 688 tests as of 2026-07; integratio
- **Migrations + seed:** EF migrations auto-apply on startup (`Database:MigrateOnStartup`, default true — but **Production sets it `false`**; the `migrate` command runs DDL out-of-band, see the #263 bullet below). `DatabaseSeeder` seeds the default account + admin user + `Admin` role on every boot — **credentials only from `Seed:*` config, never a fallback secret**; failures **log + skip (never crash the host)**, including if the schema is not yet migrated.
- **Seed command (#280, #284):** demo sample data is NOT boot-seeded — there is no `Seed__Demo` flag anymore. Run it explicitly, against a database that has **already been booted once** so the base seed above exists (default account, `Admin` role, default egg grades — the demo seed needs all three and fails fast with a clear message if they're missing): `ASPNETCORE_ENVIRONMENT=Development dotnet Cluckwork.Api.dll seed --profile demo` (an unset `ASPNETCORE_ENVIRONMENT` defaults to Production, which the guard below blocks — always set it for a local/dev/CI run). The command migrates the schema, runs `DemoDataSeeder`, then exits (Kestrel never starts); it is **authoritative** (seeds regardless of any config) and **fail-loud** (a real exit code: `0` only when data was actually seeded or was already present, non-zero with an stderr message otherwise — never a silent no-op). `DemoDataSeeder` is registered only when `!IsProduction()` (defense-in-depth): running the command against a Production-env process fails with a clear message instead of writing fake data. Intended flow: the serving process for a real farm stays Production; a non-Production process (dev box, CI, or a sim/load-test harness) runs `seed --profile demo` against its own already-base-seeded database. The **`simulation` profile** (#279) sits in the same `switch` with the same authoritative + fail-loud + Production-guarded contract (`SimulationDataSeeder`); it additionally records its date anchor + a completion marker in a durable `SimulationSeedState` row (anchor written before the first fixture mutation; completion stamped only after the manifest succeeds). A clean re-run — even one straddling a UTC-midnight rollover — reuses that anchor and converges to `AlreadySeeded` with an unchanged manifest fingerprint instead of writing a shifted duplicate set; `AlreadySeeded` is reported only when the counts are unchanged, so a `SimulationOptions` change that adds fixtures re-runs as `Seeded`. The durable anchor can't be poisoned by a mid-seed crash or by foreign daily entries a load test writes into the account (a data-derived anchor could); but a polluted account still fails the exact count validation **closed** (`Failed`) rather than certifying a fixture that no longer matches the definition.
- **Break-glass recovery (#265):** `recover-admin` is a second one-off CLI verb on the same binary, same run-then-exit shape as `seed` — but deliberately **NOT** environment-gated (it must work against a real Production database): `dotnet Cluckwork.Api.dll recover-admin --email <e> [--account <guid>] [--reason <text>]`. For a locked-out account (the sole-Owner-lost-password case, no email/SMTP reset path) it, in one transaction, resets to a **freshly generated** temporary password (never one passed on the command line), rotates the security stamp, revokes every refresh token, and writes a conspicuous `User.BreakGlassReset` audit row carrying `--reason`. The temp password is printed to **stdout only** (never the logger/OTLP) and exit `0`; failures go to stderr with exit `1` and change nothing. `AdminRecoveryService` orchestrates; `IdentityProvider.BreakGlassResetAsync` shares the reset/revoke core with `SetUserPasswordAsync`. Full procedure + verification drill: `docs/runbooks/break-glass-account-recovery.md`.
- **Farm timezone + tzdata/ICU image constraint (#264):** the default account is provisioned from **`Seed:TimeZoneId`** (default `"UTC"`; set the farm's real IANA id, e.g. `Asia/Manila`, at cutover) — no longer a hard `"UTC"` literal in `DatabaseSeeder`. The farm clock resolves IANA zones via `TimeZoneInfo.FindSystemTimeZoneById` for every safety-sensitive local-day decision and **fails closed**, so the runtime **image MUST carry tzdata + ICU** (the Debian `aspnet:10.0` base does; **never** an Alpine/chiseled base without tzdata, and **never** `InvariantGlobalization=true`). `Program.cs` asserts a representative IANA canary **and** the configured `Seed:TimeZoneId` resolve at boot (`TimeZoneAvailability.EnsureResolvable`) — a bad image or a typo'd zone fails the boot loudly instead of surfacing later as a per-request `FarmTimeZoneException`.
- **Farm timezone + tzdata/ICU image constraint (#264):** the default account is provisioned from **`Seed:TimeZoneId`** (default `"UTC"`; set the farm's real IANA id, e.g. `Asia/Manila`, at cutover) — no longer a hard `"UTC"` literal in `DatabaseSeeder`. The farm clock resolves IANA zones via `TimeZoneInfo.FindSystemTimeZoneById` for every safety-sensitive local-day decision and **fails closed**, so the runtime **image MUST carry tzdata + ICU** (the Ubuntu 24.04 (Noble) `aspnet:10.0` base does; **never** an Alpine/chiseled base without tzdata, and **never** `InvariantGlobalization=true`). `Program.cs` asserts a representative IANA canary **and** the configured `Seed:TimeZoneId` resolve at boot (`TimeZoneAvailability.EnsureResolvable`) — a bad image or a typo'd zone fails the boot loudly instead of surfacing later as a per-request `FarmTimeZoneException`.
- **Migrate command + prod migration split (#263):** `dotnet Cluckwork.Api.dll migrate` is a third run-then-exit CLI verb (same shape as `seed`) that applies EF migrations then exits — the **pre-deploy-job entrypoint**. In **Production** (`appsettings.Production.json`, loaded because the container leaves `ASPNETCORE_ENVIRONMENT` unset → Production; setting it to Development silently re-enables boot-migration) **`Database:MigrateOnStartup=false`**, so the request-serving process never runs schema DDL. The **actual guarantee is ordering** — migrate runs *before* serve: `deploy/docker-compose.yml` runs a one-shot `migrate` service first and `app` waits on `service_completed_successfully` (any other orchestrator runs the `migrate` job as a pre-deploy step). `DatabaseReadyHealthCheck` is the **backstop**: `/health/ready` returns **503 while any migration is pending**, so an orchestrator's readiness gate won't route traffic to a stale schema; and `DatabaseSeeder` **logs + skips (never crashes)** if it boots before the schema exists. This is the app-side of #263's privilege separation. The **remaining host-specific deploy step**: create a least-privilege runtime role (`GRANT USAGE ON SCHEMA public` + DML/sequence privileges, **no DDL**; plus **`ALTER DEFAULT PRIVILEGES`** for the migrator role so objects future migrations create are auto-granted to the runtime role) and point the migrate job at a separate owner/migrator credential. This PR **does not** close #263 on its own — it references it; the role split closes it.
- **Proxy-trust boot guard (#260):** behind a reverse proxy/edge, HSTS (#144) and the per-IP login limiter (#143) only work if the app trusts the proxy's `X-Forwarded-*`, which it does **only** for networks in `RateLimiting:TrustedProxies`. An **empty list in Production fails the boot** (a serving-process guard placed *after* the CLI dispatch, so `migrate`/`seed`/`recover-admin` are unaffected) rather than silently running with inert HSTS + a one-bucket limiter. Opt out with `RateLimiting:AllowNoTrustedProxies=true` only for a rare direct-TLS deploy with no fronting proxy. The concrete edge CIDR is deploy config (`cluckwork-deploy`).
- **Production Postgres TLS floor (#261/#262):** `ConnectionStrings:Default` accepts libpq URI form (`postgresql://…`, translated to Npgsql key-value; unknown params skip-with-warning) as well as key-value. In **Production** the effective `sslmode` is validated once at boot as a fail-closed **allow-list**: `VerifyCA`/`VerifyFull` silent, `Require` warns (prefer VerifyFull), and **everything else — `Disable`/`Allow`/`Prefer`, unset (Npgsql defaults to `Prefer`), or any undefined `SslMode` — fails the boot**. Never auto-injected. `Database:AllowInsecureConnection=true` is the explicit opt-out for the co-located plaintext compose stack (set on both `app` and `migrate`; a real deploy never sets it). The `VerifyFull` CA + per-host `sslmode` are deploy config (`cluckwork-deploy`). Integration tests opt out of this **and** the #260 guard in `CluckworkWebApplicationFactory` (plaintext Testcontainers, no proxy) — a Production-env test that skips those opt-outs will fail the boot.
- **Container image hardening (#267):** the runtime stage runs **non-root** (`USER $APP_UID`, uid 1654 — the base image's built-in `app` account); all three base images are **digest-pinned** (`@sha256:…`, kept current by Dependabot's `docker` ecosystem); a CI job builds the image and **Trivy** fails the build on a *fixable* HIGH/CRITICAL. `aquasecurity/trivy-action` is **SHA-pinned** — the action was compromised in the 2026-03 supply-chain incident, so pin the immutable commit, not the re-pointable tag (this is the standing rule for third-party actions). Keep the full glibc base (tzdata + ICU per #264); never chiseled/Alpine.
- **Nullable enabled**, no unused usings (both are build-breaking).

## Secrets — never commit
Expand Down