Conversation
This sets the login/OIDC alarm destination to the shared ISPG Zero Trust inbox in both dev and prod. Prod had no destination at all, so the SNS subscription was count-gated to zero subscribers and the login auth alarms had nowhere to go. Dev was pointed at a personal address that was only ever meant to stand up alerting during the Entra pilot. An email subscription requires a one-time manual confirmation click after apply. Because dev's address is changing, Terraform will replace that subscription too, so both dev and prod will send a fresh confirmation email that someone monitoring the shared inbox needs to click. ## Test plan - [ ] `terraform fmt -check` passes in `infrastructure/` - [ ] After apply, `aws sns list-subscriptions-by-topic` for the dev topic shows the `ISPGZeroTrust@cms.hhs.gov` endpoint with a `SubscriptionArn` that is not `PendingConfirmation` - [ ] After apply, `aws sns list-subscriptions-by-topic` for the prod topic shows the same endpoint with a confirmed `SubscriptionArn` ## Blockers This is one of two prerequisites for merging #495 (enable Entra dual-IdP in prod), so that monitoring exists and is confirmed before the flag flips. Refs #495
The session cookie now omits the `Domain` attribute, so the browser scopes it to the exact host that issued it. Both cookie setters previously copied the configured cookie domain onto `Domain`, and per RFC 6265 an explicit `Domain` scopes a cookie to that domain and to every subdomain beneath it. The prod hostname is the bare apex, so a prod session would be delivered to the dev and impl hostnames as well, and to any subdomain added later. Omitting the attribute is what produces host-only scoping, which keeps the credential confined to the environment that minted it. Go's `net/http` gives us exactly that with no extra work: `Cookie.String()`, which `http.SetCookie` calls, writes `; Domain=` only when the field is non-empty, so an unset `Domain` is absent from the serialized `Set-Cookie` header rather than emitted empty. `sameOrigin` in `backend/cmd/api/internal/auth/middleware.go` is deliberately untouched, and that is the part worth checking. It reads the same `Auth.CookieDomain` value as the expected `Origin` host and falls back to the request `Host` when the field is empty. Behind CloudFront with a VPC origin to the internal ALB, the `Host` the container observes is not necessarily the public hostname, so clearing or renaming the field could start failing the Origin check on every state-changing request. This change therefore leaves the config field, its env binding and the Origin-check path exactly as they were, and only stops putting the value on the cookie. Splitting the overloaded field into a separate cookie-domain setting plus an Origin-check setting named for its actual purpose is a deliberate follow-up, still outstanding. Both setters change in the same commit because they are coupled. A browser only removes a cookie when the expiring cookie's `Domain` matches the one it was stored with, so dropping `Domain` from `SetSessionCookie` alone would leave `ClearSessionCookie` unable to clear the session. That failure mode passes a casual test, since login and normal browsing look fine and only logout silently stops working. ## Test plan - [x] `TestSetSessionCookie_Attributes` asserts the issued cookie carries no `Domain`, so a future change cannot silently re-widen it. The test sets `Auth.CookieDomain` to a non-empty value first, because it is empty in the test environment and the assertion would otherwise pass no matter what the setter does. - [x] `TestClearSessionCookie_Attributes` asserts the logout cookie is emptied, expired, and also carries no `Domain`, covering the coupled-setter trap. - [x] Both new assertions were confirmed to fail when `Domain` is reintroduced, so they are load-bearing rather than vacuous. - [x] `go build ./...` clean. - [x] `go vet ./...` clean. - [x] `go test -short ./...` fully green across all 19 packages. - [x] `go test ./...` green except the pre-existing integration tests, which need a live database and fail the same way on `main` without one. `cmd/api/internal/auth` passes. - [x] `gofmt` clean on both changed files. The repo carries no golangci-lint config; the only lint gates are terraform fmt and tflint for infrastructure and the Redocly OpenAPI lint, none of which this change touches. - [ ] Manual check that no unit test can cover: sign in to prod first, then to a lower environment, in one browser profile, and confirm the lower environment works. That ordering is the one that breaks while the cookie is apex-scoped. ## Deploy note A session issued before this change is a distinct cookie from the host-only one, so the new expiry does not delete it. Only dev is in that state, because `entra_enabled` gates the variable and it is true only in dev, where the configured value is dev's own leaf hostname. The effect there is bounded: `r.Cookie` returns the older of two same-named cookies, so a dev browser holding a pre-deploy cookie keeps presenting it and logout does not end that session until the 3 hour `MaxAge` elapses. It self-heals, and clearing site cookies ends it immediately. No prod session is involved, since prod sets no cookie domain today and so holds nothing domain-scoped. Emitting an extra legacy domain-scoped expiry would close that window, but it would put an apex `Domain` back on the wire for every prod logout once #495 lands, and leave a dead dependency on a field the planned split is going to rename, so this leaves it out. Worth a cookie clear if a dev logout looks sticky just after this deploys. ## Blockers This is one of two prerequisites for merging #495, which enables Entra dual-IdP in prod. Merging #495 is the deploy that applies the flag and injects the cookie domain into the API task, so merging it is what turns the over-scoping on. Please do not apply `entra_enabled = true` in prod until this is in. The second prerequisite is the config-field split described above. That work is outstanding, so this PR does not close the tracking ticket. Refs #495 Refs CMS-Enterprise/ztmf-misc#257
`config.Auth.CookieDomain`, bound to `AUTH_COOKIE_DOMAIN`, used to do two unrelated jobs. It supplied the session cookie's `Domain` attribute, and it is the host `sameOrigin` expects to see in the `Origin` (then `Referer`) header of a state-changing request. #497 removed `Domain` from both `SetSessionCookie` and `ClearSessionCookie`, so no code path sets a cookie domain any more and the same-origin check is the field's only remaining consumer. That leaves the name describing a job the value no longer does, which is a trap of its own. Someone setting a variable called `AUTH_COOKIE_DOMAIN` would reasonably expect it to scope the session cookie, and it does not. This renames the field to `OriginHost` and the environment variable to `AUTH_ORIGIN_HOST`, and rewrites the doc comment to describe the same-origin check it actually feeds, including the fallback to the request `Host` when the value is empty. This is a pure rename. `sameOrigin` reads the same value, compares it the same way, and falls back the same way. Terraform still injects `local.domain_name`, only under the new name, because the public hostname is exactly what a browser puts in `Origin` and therefore exactly what the check should expect. #497 described the follow-up as splitting the field into a cookie-domain setting plus an Origin-check setting named for its purpose. This does the rename half and deliberately skips the other half. After #497 there is no code path that reads a cookie domain, so a new `AUTH_SESSION_COOKIE_DOMAIN` would be either dead configuration or, the moment something started reading it, a re-armed version of the over-scoping #497 removed. Host-only scoping is the behaviour we want in every environment, and the way to get it is to have no field for it at all. Renaming the surviving field is what actually closes the root cause, since the root cause was one name standing for two things. #497 left the field alone specifically because clearing or renaming it could start failing the Origin check behind CloudFront, and that is the part of this change worth checking rather than the rename itself. It is settled below. ## Deploy atomicity An environment variable rename couples the task-definition change to the image rollout. If Terraform injected `AUTH_ORIGIN_HOST` while the running image still read `AUTH_COOKIE_DOMAIN`, `sameOrigin` would silently fall back to `r.Host` for the duration of the gap, and behind CloudFront that is not guaranteed to be the public hostname. The two land together, in the same ECS task definition revision, so no such gap exists. `.github/workflows/orchestration-prod.yml` runs `analysis`, then `backend`, then `infrastructure`, and the `infrastructure` job declares `needs: [analysis, backend]`, so `terraform apply` cannot start until the backend job has succeeded. `.github/workflows/backend.yml` pushes the image tagged with the short commit SHA, then writes that same SHA to the image-pointer SSM parameter with `aws ssm put-parameter --overwrite`, and closes with the comment "Deployment of the latest image will happen with terraform apply during infrastructure deploy". `.github/workflows/infrastructure.yml` then checks out the merge commit and runs `terraform apply`, which reads that pointer through `data "aws_ssm_parameter" "ztmf_api_tag"` in `infrastructure/data.tf` and so sees the tag the backend job just wrote. The two changes are attributes of one resource. `infrastructure/ecs.tf` sets `aws_ecs_task_definition.ztmf_api`'s `image` from that data source, and builds the container's `environment` as `concat([...], local.entra_api_env)`, which is where the renamed variable lives. A single apply produces a single new revision carrying both the new image and the new variable name, so there is no revision in which the new variable name meets an image that reads the old one, and none in which the new image meets the old variable name. `orchestration-dev.yml` gates its backend job on a backend diff and this PR changes Go files, so the dev deploy builds and rolls out a new image the same way. A bare rename is therefore safe and no back-compat fallback is needed. As a secondary point, the `/api/*` CloudFront behaviour forwards `headers = ["*"]`, so even the fallback path would most likely resolve to the viewer's host, but naming the expected host explicitly is still right, because the check should not depend on header-forwarding configuration staying as it is. ## Note on the test setup that goes away Two call sites in `session_test.go` set `CookieDomain` to a non-empty value before asserting the cookie stayed host-only, because the field used to be the one thing that could have leaked a `Domain`, and #497 needed that setup to keep the assertion from passing vacuously. No configuration can feed the cookie's `Domain` now, so the setup has nothing to prove and it is removed. The host-only assertions stay as the guard against reintroducing a `Domain`. They are weaker than they were, in that they can no longer be driven by a config value, and there is no way to keep that strength without keeping a cookie-domain field, which is the thing this change is removing the last trace of. ## Test plan - [x] `go build ./...` clean - [x] `go vet ./...` clean - [x] `go test -short ./...` green, 577 tests across 19 packages - [x] `TestSameOrigin` passes all six subtests unchanged, confirming the fallback-to-`Host` path behaves the same - [x] `TestSetSessionCookie_Attributes` and `TestClearSessionCookie_Attributes` still assert the cookie is issued host-only - [x] `terraform fmt -check -recursive` clean in `infrastructure/` - [x] `gofmt` clean on the lines this change touches. `middleware.go` carries one pre-existing alignment nit on an unrelated const block that `gofmt` also reports against `main`, left alone to keep this diff to the rename - [x] `git grep -n 'CookieDomain\|AUTH_COOKIE_DOMAIN'` returns nothing - [ ] After the rollout, confirm a state-changing request from the browser still succeeds, which is what exercises `sameOrigin` against the injected `AUTH_ORIGIN_HOST` Refs CMS-Enterprise/ztmf-misc#257. This completes that ticket's code cleanup. Its remaining post-rollout verification stays open.
) Flips `entra_enabled = true` for prod. Same one-line change as #350 did for dev, same file shape, different tfvars. ## Why now Needed to kick off the **2026 data call**. HHS/OpDiv participants authenticate via Entra — `users.identity_provider` is `okta` for CMS and `entra` for HHS/OpDivs (migration 0030). With the flag false in prod, the `/login/entra*` listener rule doesn't exist and `/api/v1/auth/lookup` falls through to the OIDC-gated `/api/*` rule, so those users have no login path at all. Every non-CMS participant is blocked until this flips. ## Scope One line plus its comment, `infrastructure/tfvars/prod.tfvars`. No code, no migrations. `dev.tfvars` is untouched (already `true` on main). ## What dev has proven Dev has run dual-IdP for ~6 weeks. Nothing Entra-related has landed since #408 on 2026-07-09. Prod also gets the flip in better shape than dev did — dev was flipped bare and shook out five follow-ups afterward, all of which are already on main here: - #351 — CloudFront `/login/*` behavior to the ALB origin (Entra login was AccessDenied without it) - #354 — Okta JWT `kid` validation + key-fetch hardening - #352 — HHS-wide admins can set `identity_provider` on user create - #377 — ALB access logs, ELBAuth alarms, backend 401 instrumentation - #408 — logout clearing app session + both ALB OIDC cookies ## Pre-merge blockers These are prod-account operational state, not code. Not verifiable from the repo, so they need confirming before merge. Status and references updated 2026-07-30. - [x] **`ztmf_session_signing_key` seeded in prod** (`scripts/bootstrap-entra-secrets.sh`). An empty key fails session minting closed (`secrets.tf:37`). Note this breaks **both** IdPs, not just Entra — post-flip, Okta also routes through `SessionHandler` to mint the app session cookie. An unseeded key means nobody logs into prod. - **Still open.** The container exists but there is no evidence a value was written. The 2026-06-15 15:03:20Z timestamp on both secrets falls inside #341's merge-triggered PROD `terraform apply` (15:03:03Z to 15:05:50Z), so it records container creation, not seeding. No workflow invokes the bootstrap script, so CI cannot have written them. Settling this needs `LastChangedDate` against `CreatedDate`, or CloudTrail `PutSecretValue`, neither of which is reachable from a human credential. The script generates this key itself, so no external input is required to close it. - [x] **`ztmf_entra_oidc` seeded in prod.** Terraform reads this one at plan time (`data.tf:74`), so an unseeded secret fails the apply rather than breaking at runtime. - **Still open**, same evidence as above. Closing it needs tenant id, client id and client secret from the Entra tenant owners. - [ ] **Entra app registration has the prod redirect URI** and prod users are **assigned to the app**. Flagging specifically because of the AADSTS50105 (user-not-assigned) hit on dev on 2026-06-30 — and per the scope note in `monitoring-login-auth.tf:12-16`, the ELBAuth alarms *cannot* catch that class. Entra rejects it before any callback, so no datapoint is ever emitted. It's the most likely first failure on a fresh prod tenant and it will be silent. - **Still open, and the only item that is not ours to close.** The URI needed is `https://ztmf.cms.gov/oauth2/idpresponse`. Also worth settling in the same conversation: whether prod gets its own app registration rather than sharing dev's, since sharing means one client secret spans both environments and stops `AUTH_ENTRA_AUDIENCE` distinguishing them. - [x] **Decide login alarm routing for prod.** `alarm_notification_email` is unset in `prod.tfvars`, and `monitoring-login-auth.tf:31` count-gates the SNS subscription on a non-empty string — so prod gets the topic and the alarms with **zero subscribers**. Dev's `jono@aquia.us` is flagged in its own comment as an interim individual address to be replaced before prod. Deliberately left out of this PR since it needs a real destination decision (shared inbox or Slack webhook); happy to fold it in here or take it as a follow-up. - **Done, #496.** Both dev and prod now route to `ISPGZeroTrust@cms.hhs.gov`, which also de-personalises dev. Applied, and both SNS subscriptions confirmed rather than left pending. ### Found during review and fixed ahead of this PR - **#497** — the session cookie is now host-only. `AUTH_COOKIE_DOMAIN` resolved to the bare apex in prod, so this flip would have handed a live prod session cookie to the dev and impl hostnames. Merged and deployed ahead of this PR, so the condition is prevented rather than remediated. Detail in CMS-Enterprise/ztmf-misc#257 (closed). - **#498** — `AUTH_COOKIE_DOMAIN` renamed to `AUTH_ORIGIN_HOST`, since after #497 it only feeds the same-origin check. Tracked separately, neither gating this PR: CMS-Enterprise/ztmf-misc#258 (cross-environment cookie check, blocked *on* this PR) and CMS-Enterprise/ztmf-misc#259 (session tokens are unbound bearer credentials with no revocation path). ## Deploy behavior Nothing applies while this is open, draft or not. `orchestration-prod.yml` triggers only on `pull_request: closed` + merged. `orchestration-dev.yml` does run on the open PR, but it applies with `dev.tfvars`, so it can't exercise this change — there's no pre-merge plan output for prod. **Merging is the deploy.** The flag swap is destroy-and-create in a single apply: the two Okta rules (priorities 1-2) are destroyed and the four dual-IdP rules created, so priorities never collide. `moved` blocks in `alb-internal.tf:127-135` keep the un-indexed addresses from churning. Worth having someone on a prod Okta login the moment it applies — priority 4 takes the ALB out of the `/api/*` auth path and hands the gate to the backend session cookie, which is the highest-consequence part of the change. Refs #341, #350
Closes the P0 in #500 for the FY2026 cycle. **Temporary by design — this block is deleted once the FY2026 data call exists in prod.** ## Problem `copyPreviousScores` resolves **one** predecessor cycle globally, for every system. That was correct while ZTMF ran a single annual cadence. It is not correct now that two independent cycles cover FY2025: | Cycle | Owner | Deadline | |---|---|---| | `FY2025 Q3` | CMS's own hand-entered call | 2025-05-07 | | `FY25 ZTM` | HHS import | 2025-09-30 | `findPreviousDataCall` orders by `deadline DESC` (the #448 fix — correct, and kept). So `FY25 ZTM` wins on its later September deadline and **every CMS system inherits HHS's imported view of itself** instead of the answers its ISSOs submitted. Reproduced on a prod snapshot in #500: the new cycle took all 38,160 rows from `FY25 ZTM` and ignored `FY2025 Q3` entirely. ## Approach A system's OpDiv selects exactly **one** source cycle; the other is not read at all. - CMS systems (`opdivs.code = 'CMS'`) → `FY2025 Q3` - every other OpDiv → `FY25 ZTM` Exclusion, not preference. The two cycles are separate lineages, not one record with gaps — CMS ran its own call, and the HHS ZTM cycles were loaded independently with their systems only arriving in ZTMF recently. Where they overlap (the import carries its own view of some CMS systems), the CMS entry is the record and the imported view is not authoritative. That overlap is exactly what made the global predecessor wrong. Confirmed with the data owner: there is no case where a CMS system should pull from the HHS cycle. Routing is by `fismasystems.opdiv_id`, which is `NOT NULL` with an FK to `opdivs` (migrations 0027/0029), so every system resolves and none can be routed by accident. ## Gating Two independent conditions, so the override cannot leak onto any other data call before it is removed: 1. **Name** — target starts with `FY2026` or `FY26`. Both year forms are accepted deliberately: FY2026 is a single unified cycle covering all of HHS including CMS, its final name is not fixed, and the prior conventions disagree (`FY2025 Q3` four-digit vs `FY25 ZTM` two-digit). Declining the real cycle over a naming guess is the costlier failure. 2. **Deadline** — target's deadline must be later than *both* sources'. This is the **#448 invariant restated**: a call named FY26 but deadlined earlier is a backfill, not the next cycle. The override replaces `findPreviousDataCall` entirely, so it carries #448's guarantee itself rather than inheriting it. 3. **First cycle only** — the target must be the earliest cycle matching those prefixes, ordered by `(deadline, datacallid)`. Without this a second same-year call (`FY2026 Q4`, a redo, a backfill) passes gates 1 and 2 and re-sources from the 2025 cycles, *discarding* everything the real FY2026 cycle accrued. This gate is what makes the broad prefix in gate 1 safe. It reads live state, not history — deleting a data call and recreating it re-arms the override, so dev stays re-testable. Sources resolve by **name, not id** — `datacalls.datacall` is `UNIQUE` (migration 0013) and ids differ between dev and prod. Any decline logs a specific reason and falls through to the existing global path unchanged, so this is inert on any environment lacking both 2025 cycles. ## Safety **The diff is purely additive — no existing line is removed.** `findPreviousDataCall` and the normal copy path are byte-identical. - `status` still seeded as the `'not_started'` literal in the same `INSERT…SELECT` (#435 — the answer carries forward, its review state does not) - `DISTINCT ON (fismasystemid, functionid)` guarantees one answer per question; `scores` has no uniqueness constraint, so a source cycle holding duplicates would otherwise copy both - rollover stays best-effort: a copy error is logged, never fails the data call create ## Observability One `ROLLOVER_HARDCODE` line per create. On the FY26 create: ``` ROLLOVER_HARDCODE datacall=<new> status=active mode=exclusion cms_source=<id>("FY2025 Q3") other_source=<id>("FY25 ZTM") cms_systems=… other_systems=… src_rows=… selected_rows=… excluded_rows=… copied=… deduped=… dropped_unresolvable=… from_cms_source=… from_other_source=… cms_stranded=… other_stranded=… misfiled_carried=… unscored_env=… ``` `from_cms_source` is the headline check — under the old behavior it would be **0**. `excluded_rows` is the fix working: HHS's view of CMS systems, discarded. `status=inactive` with a reason means the override declined and the normal path ran (`source_lookup`, `target_name_not_fy2026`, `target_deadline_not_after_sources`, `target_is_a_source`). Alarms reuse the existing `ROLLOVER_ANOMALY` token, so they page through the current CloudWatch metric with no new infra: - `reason=hardcode_empty_copy` — rows selected, none written - `reason=hardcode_stranded_systems` — a system answered somewhere but rolled forward empty - `reason=hardcode_unresolvable_rows` — assigned rows the copy could not carry because `functionoptionid` resolves to nothing Two diagnostics were corrected while building this and are worth noting for anyone reading the log: - `misfiled_carried` is restricted to systems that **have** a scoring environment. Without that, every row for a system whose `datacenterenvironment` is unmapped or maps to a NULL `scoring_key` (the `DECOMMISSIONED` marker, migration 0045) reports as mis-filed — measured 36 reported / 36 false on the local fixture. Those are counted separately as `unscored_env`. - Row accounting separates `excluded_rows` (dropped by the OpDiv filter) from `deduped` (collapsed duplicates). Conflating them would report deliberately excluded rows as deduplication and hide how much exclusion drops. `dropped_unresolvable` is a genuine partial-copy signal. An earlier revision of this PR argued it was unnecessary because enforced FKs meant the copy's INNER JOINs could not drop a row. **That was wrong** — `scores_functionoptionid_fkey` reports `convalidated = t` only because it was created against an empty table, and bulk loads run under `session_replication_role = replica`, which bypasses FK triggers. The real data contains 33 rows across 11 systems in `FY2025 Q3` with `functionoptionid = -1`, which has no `functionoptions` row. The copy discards them silently, so the check reports 33 rather than never firing. See the review comment below. ## Testing - `make test-unit` — passes - `make test-integration` (isolated freshly-seeded DB) — passes - `TestMatchesRolloverHardcodeTarget` — 12 cases covering both naming conventions, case-insensitivity, and rejection of FY2027 / backfills / the source cycles - **End-to-end through `POST /api/v1/datacalls`** against a prod-like snapshot: 993 systems, 13 OpDivs, 262 CMS / 731 other. Both routing branches exercised. Verified against the database after the run: | Assertion | Result | |---|---| | CMS rows not sourced from `FY2025 Q3` | **0** | | non-CMS rows not sourced from `FY25 ZTM` | **0** | | rows with status ≠ `not_started` | **0** | | duplicate answers per (system, function) | **0** | | orphan rows carried forward | **0** | | `FY2027 Q1` | declined, `reason=target_name_not_fy2026`, fell through to `findPreviousDataCall` | Against unpatched `main` on the same data: | | main | this PR | |---|---|---| | CMS systems fed the HHS imported view | 226 | **0** | | Systems covered | 957 | 969 | | Systems rolled forward empty | 21 | 9 | | Systems properly scored (not exactly 1.00) | 196 | 272 | The committed fixture cannot exercise this — `_test_data_empire.sql` has `FY25 ZTM` but no `FY2025 Q3`, so the override is inert there and no existing test path changes behavior. **Note for anyone modifying the copy SQL:** the `::int` casts on the CASE branches are load-bearing and cannot be validated in psql. Literal ids and `PREPARE p(int,…)` both supply the type information pgx omits, so a psql harness will certify SQL that fails at runtime with `integer = text`. It needs a real POST. ## Before creating the prod cycle 1. Confirm the exact names — `SELECT datacallid, datacall, deadline FROM datacalls ORDER BY deadline DESC;`. A mismatch means a **silent no-op**: FY26 would roll from HHS exactly as today, with only a `status=inactive` log line to show for it. 2. Name the FY2026 call starting `FY2026` or `FY26`, deadline after 2025-09-30. 3. **Decide the seven CMS-tagged HHS arrivals** — see the review comment; identifiers are in the internal handoff doc. They carry forward today and roll forward empty under exclusion. Retagging them to their true OpDiv makes this a clean win. 4. Check the log: `from_cms_source > 0` and `other_stranded=0`. `cms_stranded` is expected to be **9** on current data, from three known causes — not a reason to abort, but confirm the breakdown matches. The copy runs **once**, at creation, with no re-run path (#411). If the log is wrong, recovery is deleting the data call — `scores_datacallid_fkey` cascades — and recreating it. Verify before anyone begins answering. ## Removal Delete the block between `TEMPORARY HARD-CODE - ztmf#500` and `END TEMPORARY HARD-CODE`, plus the 4-line call site in `copyPreviousScores`, and optionally `TestMatchesRolloverHardcodeTarget`. No schema change, no migration, nothing to unwind — the rows written are ordinary `scores` rows identical in shape to a normal rollover. From FY2026 onward there is a single global predecessor again containing every system, so #500 goes dormant on its own. The durable per-OpDiv design (#500 decisions 2 and 3, and the missing cycle-ownership column on `datacalls`) is still open and only matters if genuinely divergent per-OpDiv cadences return.
… score write authorization (#517) Combines #511, #514, #515 and #516 into one merge so the FY2026 work deploys once instead of four times. Admin-merged without a formal approval on the combined head. This is a deliberate call, not a missed gate: it is an emergency patch during the live FY2026 data call and the team expected it tonight. #511, #515 and #516 were each approved individually; #514 was reviewed with no defect found, its only outstanding item being the rebase this branch performs. Verified on the combined head before merge: all four PRs' distinctive changes confirmed present, openapi regenerates byte-identical, unit and integration suites green, emberfall 197/197 with both stacks' cases checked by content rather than assumed.
…d) (#521) Backend counterpart to the frontend freeze batch in CMS-Enterprise/ztmf-ui#674. Running batch of approved work, draft until the remaining approvals land, then converted manually. Closes #513 ## What is in it | Source | Author | Change | Closes | | --- | --- | --- | --- | | #519 | @voidspooks | Read only in-app edits for score-progress last-updated | #513 | @voidspooks' commit is carried unchanged from his fork: same content, his authorship, and the signature still verifies. Nothing was rewritten or re-signed. This supersedes #520, which was opened as a standalone rehome before the batch approach was settled. Same branch content, renamed so the freeze artifacts match across the two repositories. ## Why this is more than the ticket described `LastUpdatedAt` took the newest `events` row of any action, while the status backfill in `0048scoresstatus.go` counts only `created` and `updated`. So a row whose only provenance was an out-of-band load reported the load's timestamp while `questionsupdated` stayed 0. Review surfaced a second case nobody had stated: because the filter was absent entirely, a row with a **genuine in-app edit** could have its displayed timestamp overwritten by a **later** import. An ISSO's real edit could be relabelled with an ETL run's timestamp. That case is not bounded to closed cycles the way the reported symptom is, so the fix is worth more than its description claims. The allowlist is applied in the `WHERE`, so the descent picks the newest matching event regardless of how imports interleave. Verified in both orderings rather than by example. ## CI proof, which is the point of bringing this in-repo A fork PR cannot reach org secrets, so on #519 the Snyk jobs, the deploy, and the integration tests never ran. In-repo they do, and all eleven checks pass on the identical commit (run `30941730744`): | Check | Result | | --- | --- | | Analysis / snyk test | pass, 22s | | Analysis / snyk code test | pass, 37s | | Analysis / snyk iac test | pass, 34s | | Analysis / openapi spec up to date | pass, 41s | | Analysis / lint go | pass, 1m8s | | Analysis / lint terraform | pass, 7s | | Analysis / detect backend changes | pass, 5s | | Check for Changes | pass, 7s | | Backend / Integration Tests | pass, 1m36s | | Backend / Build and Smoke Test | pass, 2m24s | | Infrastructure / Deploy | pass, 3m15s | The openapi drift check passing matters specifically: `openapi.yaml` here is a regeneration, since swag sources the `lastupdatedat` description from the struct comment. Two independent reviews confirmed it regenerates byte-for-byte identical to what is committed, and CI now agrees. ## Review already done, recorded because it happened off the PR Reviewed against live data and cleared. Confirmed rather than assumed: the action allowlist exactly matches the `0048` backfill, the audit index keys only on scoreid and createdat so the filter rides on top of the index descent as the author noted, the spec regenerates identically, and the new integration test genuinely fails with the filter removed and passes with it. **Blast radius is historical only.** The affected rows are imported-only ones in closed cycles, and no system in the open FY2026 data call is affected, so nothing changes for anyone mid-data-call. Exact figures were kept off this public PR and can be recorded internally. A second independent pass reproduced all of it, including emberfall at `Ran: 197 / Failed: 0`, and confirmed the cross-repo claim: a nil last-updated feeds the `-1` tie-break in ztmf-ui's `aggregateScores.ts`, which is already pinned there by a passing test naming the imported-call case explicitly. ## Non-blocking notes carried forward For an imported-only row the lateral now matches nothing and scans that row's events rather than short-circuiting at `LIMIT 1`. Bounded per row, but worth eyeballing an archives-year dashboard load rather than assuming it is free. `('created', 'updated')` is now written literally in four places: the migration, this query, and two raw SQL fragments in the integration test. Two independent SQL fragments disagreeing about which actions count is exactly what produced the original bug, so a shared Go constant would close that off. Can follow separately. ## How this branch is maintained Members are reviewed work. **The approval lives on this batch, not on the member PRs**, and it is collected once the queue is final rather than per addition, because any push here dismisses an approval already given. So a member PR sitting at `REVIEW_REQUIRED` is expected and is not a gap: during the freeze the batch is the thing that gets approved and merged, and the member is the thing that got reviewed. Each member's review is recorded on this PR so the trail does not depend on where the conversation happened. Additions are appended, never reordered, which keeps the push a fast-forward, avoids re-hashing contributors' commits, and preserves the record of what joined when. This PR stays a draft while the queue fills. That is deliberate, not an oversight: it means no CI has run here yet, since draft PRs skip both the analysis and deploy jobs. After merge, #519 closes without merging, so its own `Closes #513` never fires. That is why the keyword is declared here. Co-authored-by: Cameron Testerman <11036339+voidspooks@users.noreply.github.com>
…l-questionnaire export (#529) Backend release train for the code freeze. Approved PRs only, cherry-picked so each contributor's authorship and signature survive. Frontend counterpart is CMS-Enterprise/ztmf-ui#682. Closes #445 Closes #526 ## What is in it | Source | Author | Change | Closes | | --- | --- | --- | --- | | #522 | @danielbowne | Expose `last_seen` on the users list, plus a login event so it means last sign-in | — | | #524 | @danielbowne | Single home for event-action and score-status values | #445 | | #528 | @MackOverflow, commits by @voidspooks | Export the full questionnaire per system rather than only answered rows | #526 | #522 declares no closing issue by design: it is the backend piece, and presentation is deliberately left open on CMS-Enterprise/ztmf-ui#675. Ordering is by approval. #522 and #524 both touch `events.go` and `users.go`, and they merged without conflict because the edits sit in disjoint regions. #528 is disjoint from both. One thing dropped on purpose: #522's tip was an empty `chore(ci): retrigger DEV deploy under a fresh image tag` commit, pushed to force a fresh image tag past the immutable ECR repo. It has no meaning in a batch that gets its own SHA, so it was skipped rather than carried. ## Verification on the combined branch Verified the combination rather than trusting the parts, since three separately green PRs can still interact: - `go build ./...` and `go vet ./...` clean - `go test -short ./...` — 13 packages ok - Full integration suite against a seeded database — 13 packages ok, zero failures, including the three tests that are sometimes date-dependent - **`make generate-openapi` produces no drift**: the committed spec matches the generated one byte-for-byte. Worth doing here specifically because #522 regenerated the spec and #524 changed models independently, so the combination is the first time those two meet. ## Deploy note, and it is a real one **Do not stack deploys on this branch.** #528's own SHA has two dev deployments two minutes apart: the first succeeded, the second failed with `waiting for ECS Service update: timeout while waiting for state to become 'tfSTABLE' (last state: 'tfPENDING', timeout: 20m0s)`. The second run raced the first while ECS was still rolling out. #524 lost a deploy the same way earlier in the day. So this PR was opened ready rather than draft-then-ready, because that transition is what produced the double run. If the deploy here fails on a stabilise timeout, let ECS settle and re-run **one** deploy; do not push again or trigger a second in parallel. ## Merge order **This merges first.** The frontend batch follows at least five minutes later, which is both a courtesy to the prod pipeline and a correctness requirement: #528's export change has to be live before the frontend's select-all makes never-started systems selectable, or a user can select them and download a header-only file. ## What is not in it - #525 and #492 are drafts. - #423 is a dependency bump with no approval. --------- Co-authored-by: Daniel Bowne <daniel.bowne1@cms.hhs.gov> Co-authored-by: Cameron Testerman <11036339+voidspooks@users.noreply.github.com>
…#537) Closes #534. ## Problem `functions.pillarid` and `questions.pillarid` store the same fact independently — two separate FKs to `pillars`, with nothing forcing them to agree. `Function.Save()` wrote the function's copy straight from caller input rather than deriving it from the function's question, so an admin edit or a direct API call could create a disagreement at any time. Nothing user-facing reads the function's copy today: #528 re-anchored the export's Pillar column through the question, matching the questionnaire and `/scores/progress`, and #530 verified zero drift in production (360/360 agree). So this was latent rather than live — but the stale copy sat there waiting to mislead the next query that read it directly. ## Approach Option 1 from the issue: close the write path rather than drop the column. `Save()` now resolves the function's question and writes that question's `pillarid`, discarding whatever the caller sent. `questionid` becomes required — a function with no question reaches no questionnaire (`FindQuestionsByFismaSystem` inner-joins `functions` on `questionid`) and has no pillar to derive from, so it is invalid input. With `pillarid` always derived, the `pillarid` check in `validate()` is dead for every valid input and comes out. An unknown `questionid` returns `ErrNoReference`, which is the same 400 the caller already got — today that input reaches Postgres and comes back as a 23503 that `trapError` maps to `ErrNoReference` anyway — just detected before the insert. ### Requiring questionid also closes an accidental-NULL path Worth calling out separately, because it is a second bug rather than a consequence of the first. The UPDATE in `Save()` sets every column unconditionally, so pre-PR a `PUT /functions/{id}` that omitted `questionid` — an ordinary partial-update shape — wrote NULL to the column and silently orphaned the function from every questionnaire, since `FindQuestionsByFismaSystem` inner-joins `functions` on `questionid`. The function would simply stop appearing for every system in its data center environment, with no error to the caller. Requiring `questionid` makes that request a 400 instead. **No migration.** #530 showed production already agrees across all 360 functions, and writes are now self-correcting. ### Scope of the guarantee This closes the `/functions` write path only: a caller-supplied `pillarid` can no longer put a function out of agreement with its question. It does **not** make the invariant total. `PUT /api/v1/questions/{id}` writes `questions.pillarid` with no cascade to dependent functions, so moving a question to a different pillar leaves its functions stale until each is next saved (at which point this change re-derives and self-heals it). That path is pre-existing and untouched here — see the follow-up below. ## Acceptance criteria - [x] `POST`/`PUT /api/v1/functions` writes the question's `pillarid` regardless of the `pillarid` in the request body, on both insert and update. - [x] A request whose `pillarid` disagrees with its question returns 201/200 with the corrected value, rather than persisting the disagreement. - [x] `questionid` is required; a request without one returns 400 with `questionid` in the error payload, and nothing is written. - [x] An unknown `questionid` returns 400 (`ErrNoReference`) and nothing is written. - [x] A `PUT` omitting `questionid` returns 400 rather than NULLing the column and orphaning the function from every questionnaire. - [x] No schema change and no data migration. - [x] Existing readers are unaffected: the questionnaire, `/scores/progress`, the scoring aggregate and the export all already join `questions.pillarid`. ## Testing - `internal/model/functions_pillarderive_integration_test.go` (new) — insert and update each correct a deliberately wrong `pillarid`; missing `questionid` → `InvalidInputError` carrying `questionid`; unknown `questionid` → `ErrNoReference`. Fixtures use the `AWS` environment, which no seeded system maps to, so they are never visible to a questionnaire while the test runs. - `emberfall_tests.yml` — `functionData` / `updatedFunctionData` gained `questionid: 8001`; added a drift case (claims pillar 6 against a pillar-1 question → 201 with `pillarid: 1`), a questionless `POST` → 400, and a questionless `PUT` → 400 followed by a `GET` asserting the whole prior state, so a partial write of any field fails the case. - `make test-unit` — pass - `make test-integration` — pass, including all 4 new subtests - `make test-e2e` — 201 ran, 0 failed - `openapi.yaml` regenerated: `pillarid` is now `readOnly: true` with a description. Redocly lint is unchanged at 8 warnings / 2 ignored, verified against the baseline. ## Reviewer notes - **Behavior change:** `POST`/`PUT /api/v1/functions` without a `questionid` now returns 400 where it previously succeeded. The frontend never writes to this endpoint — its only `functions` calls are `GET functions/{id}/options` in `QuestionnairePage` and `QuestionnareModal`, and there is no admin "manage functions" view — so the only affected callers would be hand-rolled scripts against the admin API. - `Save()` repeats the nil check on `QuestionID` immediately before dereferencing it. `validate()` already rejects nil, so the branch is unreachable today; it is there so the deref does not silently depend on a guard sixteen lines up in another method. - Worth confirming before merge that production has no `functions` rows with a NULL `questionid` (`SELECT count(*) FROM functions WHERE questionid IS NULL;`). Such rows are already orphaned from every questionnaire, but they would no longer be editable through the API without supplying a question. #530's 360/360 count only covered rows that join to a question, so it would not have surfaced them. ## Follow-up (post data call) Deferring the remaining work to a single change after the current data call, rather than splitting it across cycles: - Cascade or eliminate the question-edit path described above, so a question moving pillars cannot leave its functions stale. - Option 2 from #534: drop `functions.pillarid` entirely and read through the question everywhere. This subsumes the cascade — with no second copy there is nothing to keep in sync. It needs `functions.questionid` made NOT NULL, plus updates to the `PillarID` filter in `FindFunctions`, the Empire seed's score generator, and the OpenAPI schema. Doing both at once avoids writing a cascade that the column drop would immediately delete. Not scheduled during the data call because it touches the questionnaire's schema while systems are actively being scored. Refs #528, #530.
…login alarm tuning, DB secret resolution (#541) One batch carrying the approved backend PRs so they take a single dev deploy and a single prod deploy instead of three. The dev lane is shared and serialises, and three separate deploys tonight would have raced each other; that race has already cost three deploys today. Closes #531 Closes #484 ## What is folded in | PR | Author | Approvals before folding | | --- | --- | --- | | #539 questionnaire ordering end to end, plus the export index | Daniel Bowne | costacalvin, jsos3-cms | | #525 require 3-of-6 windows on the login auth-failure alarm | Daniel Bowne | jsos3-cms | | #540 move DB secret resolution into config, rehome of #538 | Cameron Testerman | none | Appended in approval order, then #540 last since it has no approval to order by. Nothing was reordered, so the record of what joined when survives. **Please give the #540 commit real attention when reviewing.** It is the only part of this batch that has never had an independent approval. Its content was reviewed and came back clean, and it is a verbatim relocation rather than a rewrite, but a review verdict is not a recorded approval and I authored the rehome so I cannot approve it. Approving this batch is what puts a second pair of eyes on that commit. ## How it was built Started from #539's head rather than cherry-picking its range, because #539 contains a merge commit (folding in `chore/functionoptions-index`) that a range pick cannot replay. #525's single commit and #540's single commit were then appended. #539 carries two `chore(ci)` retrigger commits of its own. They were pushed to get past the immutable ECR tag on its original branch and are meaningless here, but removing them would have rewritten Daniel's commits, so they ride along. This batch has since needed one of its own, for a reason worth writing down: the API image tag is derived from the commit, and ECR has tag immutability enabled, so a DEV deploy can never be re-run for a SHA that has already pushed an image. Refreshing the deployment requires a new commit. That is the mechanical reason the retrigger-commit pattern exists in this repo rather than a habit. Re-running only a *failed* deploy job does work, since the Docker push step has already succeeded and is skipped. ## Verification Files are disjoint across the three changes, splitting into `internal/config` and `internal/db`, `internal/model` and the migrations, and `infrastructure`. Checked on the combined branch rather than trusting the individual runs: - `go build` and `go vet` clean - Full backend suite passes 13 of 13 packages against a freshly seeded database - `internal/config` and `internal/db` clean under `-race`, which matters because #540 relocates a `sync.Once` - `make generate-openapi` produces no drift, which #539 edits - `terraform fmt -check` clean on the alarm file #525 edits The suite run is the check worth having here: it exercises #540's relocated credential path and #539's new ordering and migration tests in the same process, which neither PR could do alone. Each constituent also had its own green DEV deploy before being folded, so each piece is known to apply against real infrastructure. #539's deploy is the first time migration 0056 has run against real data, since the local seed's function names never match the ordering map and leave every rank at zero. --------- Co-authored-by: Daniel Bowne <daniel.bowne1@cms.hhs.gov> Co-authored-by: Cameron Testerman <11036339+voidspooks@users.noreply.github.com>
Part of CMS-Enterprise/ztmf-misc#289 — phase 1 of 2. **Deploy before ztmf-ui#690.** ## Problem Devices and Applications do not apply to a SaaS-hosted system, so the consumer answers 25 questions, not 40. That is program-approved, but it was enforced only in the frontend (ztmf-ui#430) — every backend query still resolved 40. So the dashboard showed a permanent `25/40` that could never reach Complete (the chip is gated on `answered >= expected`, and the other 15 questions are unreachable in the UI), those systems stayed ranked as laggards by `progressSortValue` and matched the "Not updated" facet, and the export returned 15 questions nobody was asked. ## Approach One predicate, `saasPillarScopeSQL` (`internal/model/pillarscope.go`), applied to **both** `buildScoreProgressSQL` CTEs and to `FindAnswers` as a top-level `WHERE`. In scope from the FY26 cycle onward only: a cycle qualifies when it is named FY26 or its deadline falls after every FY26 cycle. Anchored on deadline, not `datacallid`, because ids are not chronological — `findPreviousDataCall` documents the same trap. Closed cycles were collected and reported against 40 and keep reading 40. Three details are load-bearing rather than stylistic: - **Both progress CTEs.** In `expected` alone, a denominator of 25 would face a numerator of 40 from carried-forward answers — breaking the documented answered-cannot-exceed-expected invariant and reporting a false "Complete" on a closed call. - **Top-level `WHERE` in the export.** That functions join is applicable-OR-answered (#528), so filtering one branch would export 40 rows for a system whose excluded answers were carried forward and 25 for a freshly created one. - **`COALESCE` on the scoring key.** It is nullable (`DECOMMISSIONED` maps to NULL) and `NOT(NULL AND …)` is NULL, which a `WHERE` reads as false. Without it, a decommissioned system carrying FY26 answers — which `FindAnswers` deliberately still exports — silently lost 15 rows, on a **non-SaaS** system. **Scoring (`scores.go`) and the questionnaire endpoint (`questions.go`) are untouched, so no score moves.** Those, plus seeding the rule as data instead of a literal, are #545 — the issue's remaining two acceptance criteria (SaaS presenting exactly 25 questions from the API, and not being scored for the excluded pillars) belong to that PR, not this one. No rows are deleted here either: the retained answers stay in the database and stop counting. ## Acceptance criteria - [x] Progress/completion calculates against 25 for SaaS systems — 25 for every SaaS system on FY2026, with the numerators drawn from the same set so `answered`/`updated` can never exceed it. A fully-answered closed call now reaches Complete instead of a permanent 25/40. - [x] All other environments unchanged (40) — every other environment value, and non-SaaS export row counts byte-identical. - [x] Closed historical calls unchanged — FY25 ZTM and FY2025 Q3 report 40 and export identical counts. The gate anchors on the *last* FY26 deadline rather than the first precisely so a mis-dated FY26 call cannot restate a closed cycle. - [x] The export returns only the in-scope questions for FY26 onward (post-issue decision from Mack/Allie). See the row-count note below. ## Testing - `make test-unit` — pass. `make test-integration` — pass. - Against a production-shaped database: on FY2026 every SaaS system reports 25 and every other environment 40; on FY25 ZTM and FY2025 Q3 every system still reports 40; **no** row on any cycle has `answered`/`updated` exceeding `expected`; the FY2026 export drop matches the excluded-pillar count exactly, per system, with prior-cycle counts byte-identical; `/scores/aggregate` output unchanged. - New: `pillarscope_test.go`; `pillarscope_integration_test.go`, which evaluates the predicate in Postgres because its correctness rests on three-valued logic no string assertion can pin — verified to fail when the `COALESCE` is removed; progress and export integration subtests covering the carried-forward case, a prior cycle, and a non-SaaS control. - `EXPLAIN ANALYZE`: the `MAX(deadline)` and `EXISTS` plan as InitPlans with `loops=1`, evaluated once per query rather than per row. Export 1.79s vs 2.13s baseline (faster — fewer rows); whole-dashboard progress 57ms vs 25ms. No new indexes. ## Reviewer notes - **CMS is deliberately not exempt.** The reduced scope is understood to be an HHS-OpDiv rule with CMS answering all 40, but that is unconfirmed — and exempting CMS here alone would give CMS SaaS systems a 40 denominator with 15 questions their ISSOs cannot see, since the frontend filter is OpDiv-blind. All SaaS systems read 25 for now; the clause to add is in `pillarscope.go` and the current behavior is pinned by `CMSAlsoReducedWhileExemptionIsUndecided` so re-adding it has to change a test.
…the questionnaire (#549) Closes #545. Phase 2 of CMS-Enterprise/ztmf-misc#289. ## What The reduced-pillar rule moves from a name-matching SQL literal into a seeded table, and the two consumers #546 deliberately left untouched now honor it. Migration 0057 creates `reducedpillarscopes` (scoring_key, pillarid, effective_datacallid): a row excludes that pillar for systems scored under that key, on every data call whose deadline is on or after the anchor call's. The shared predicate now reads the table, so the progress counts, the export, the scoring aggregate and the questionnaire resolve the same rule from the same rows, and a future reduced-scope environment is an INSERT, not a code change. Scoring: the aggregate's expected CTE applies the predicate, so an excluded pillar produces no pillar score and the system score averages the pillars that remain (the divisor already followed the actual pillar count). Carried-forward answers on excluded pillars stop counting because the answers CTE is only read through the join from expected. Questionnaire: `GET /fismasystems/{id}/questions` accepts an optional `datacallid` and serves the reduced set for in-scope cycles, so the frontend can delete its client-side pillar filter (tracked separately in ztmf-ui). Without the param the response is byte-identical to today, which keeps the current frontend correct until it passes the param. Seeding: in deployed environments migration 0057 locates the FY26 anchor by the same name prefixes the interim predicate matched, once, at migration time; after that the rule is pure data. The ephemeral local/test databases get their rule rows from the empire seed, which owns the fixture cycles and runs after migrations. A deployed environment with no FY26-named call at migration time seeds nothing and keeps the full set until a rule row is inserted alongside the cycle that needs it; that environment-dependence is inherent to rule-as-data and deliberate. Per the decision recorded on ztmf-misc#289 (2026-08-11), CMS is not exempt and there is no OpDiv awareness anywhere in this change. The interim `CMSAlsoReducedWhileExemptionIsUndecided` pin is renamed to assert the now-permanent behavior. ## Visible behavior changes `/scores/aggregate` returns four pillar entries and a four-pillar system score for SaaS systems on FY26-onward cycles. Roughly 113 SaaS systems' current scores and tiers move when this deploys mid-call, so it needs a low-traffic window and a heads-up before it ships (flagged on #545). The questionnaire endpoint changes only when the new param is passed. Progress and export behavior is unchanged, now sourced from the seeded table instead of name matching. ## Acceptance criteria - [x] The reduced-pillar rule is seeded data, not a SQL literal or a frontend constant. No cycle name, environment, or pillar is compiled in; pinned by the shape tests. - [x] FY26-onward reduced-scope systems carry no Devices or Applications pillar scores. Integration-pinned for the SaaS fixture and its CMS twin, with carried-forward excluded answers present. - [x] The questionnaire endpoint returns the reduced set for reduced-scope systems and the full set for everything else, keyed by the new optional `datacallid` param. - [x] Closed data calls are unchanged: pillar scores, system scores, tiers, exports, and question sets all pinned on a pre-anchor cycle. - [x] Emberfall updated for the changed API contracts (five new pinned cases). ## Testing `make test-unit`, `make test-integration`, and `make test-e2e` all green locally (e2e: 206 ran, 0 failed, 0 skipped). New integration coverage: aggregate reduction, questionnaire reduction with and without the param plus a non-reduced control system, and a pin that the seed wiring produced exactly the two SaaS rule rows. `openapi.yaml` regenerated via `make generate-openapi`; redocly lint valid.
…ams redirecting scope (#547) Fixes two authorization defects in `GetDatacallExport`, both filed against the same handler, and retires the copy-pasted scope pattern that let the first one happen. ## The defects **CMS-Enterprise/ztmf-misc#267** (OpDiv scope): the export used a two-way `HasAdminRead()` branch that dropped both OpDiv-scoped tiers into the unscoped path, so an OPDIV_ADMIN or OPDIV_READONLY_ADMIN exported every OpDiv's answers. Now scoped by the same three-way tier split the rest of the codebase uses, with a fail-closed OpDiv predicate added to `FindAnswers`: an OpDiv-restricted caller with no grants gets no rows. **CMS-Enterprise/ztmf-misc#268** (query-param scope override): scope was set before the client query string was decoded onto the same struct, so `?UserID=<other>` redirected the export to another user's answers, and the scope pointer aimed at the session `User` meant decode mutated the authenticated identity itself. Now decode-then-scope (the ordering every other list handler documents), server-owned fields tagged `schema:"-"` so they cannot be bound from the query at all, and `UserID` copied off the session rather than pointed at its field. The path is now authoritative for the data call id. ## Retiring the pattern #267 happened because the export predated the tier-scoping pattern and never adopted it. The same two snippets were hand-copied across the codebase: the tier-classification switch (six controllers) and the fail-closed OpDiv predicate (nine model queries). This PR extracts both into an embedded `OpDivScope` type: - `ApplyTier(user)` owns the role classification (unscoped / OpDiv / self-scope). The self-scope default stays at the call site because it legitimately differs per endpoint (by UserID, by assigned systems, or a 403). - `OpDivWhere` / `AppendRawFilter` own the fail-closed decision (restricted-and-empty means no rows) for squirrel and hand-built queries respectively. The grants predicate stays at the call site because the path from each query's base table to `opdiv_id` differs (direct column, `fismasystemid` subquery, `EXISTS` against `users_opdivs`). The fields carry `schema:"-"`, so a server-owned scope field supplied in any list endpoint's query is now a 400 rather than silently honored. A new list endpoint can no longer mis-classify a tier or forget the fail-closed predicate. The per-resource access guards (`usersopdivs.go`, `users.go`, single-system checks) are a different pattern and are left for #427. ## Behavior changes - OpDiv-tier exports are now scoped to granted OpDivs; a zero-grant OpDiv caller gets an empty export. - `?UserID=` and `?DataCallID=` on the export return 400 instead of being honored; the path is authoritative. - The `schema:"-"` hardening is codebase-wide: server-owned scope fields on the scores, progress, diff, insights, fismasystems, and users list endpoints are likewise no longer bindable from the query (they were set from the session already; this makes an attempt a 400 rather than a silent no-op). - Unscoped admin (OWNER, HHS_ADMIN, HHS_READONLY_ADMIN) and ISSO/ISSM behavior is unchanged. ## Tests - `datacalls_export_authz_test.go`: export role matrix and the query-override attempt as an explicit negative case. - `answers_integration_test.go`: OpDiv scoping, empty-grant fail-closed, and cross-OpDiv `fsids` returning nothing. - Emberfall cases for an OpDiv-scoped export and the 400 rejection. - The existing per-query OpDiv and progress tests are repointed to the embedded struct and still pass. - `make test-unit`, `make test-integration`, `make test-e2e` green; staticcheck clean; OpenAPI spec unchanged. ## Sequencing These are scope fixes and should land before #427 (the ACL centralization), whose "preserve existing authorization behavior" criterion would otherwise carry #268 forward. Touches the same `FindAnswers` function as #546, so whichever merges second takes a small rebase.
…ndable (#552) ## What Adds `schema:"-"` to the two server-owned fields on `FindScoresInput`, and pins the boundary with tests. ```diff type FindScoresInput struct { input FismaSystemID *int32 `schema:"fismasystemid"` - FismaSystemIDs []*int32 + FismaSystemIDs []*int32 `schema:"-"` DataCallID *int32 `schema:"datacallid"` - UserID *string + UserID *string `schema:"-"` IncludePillars *bool `schema:"include_pillars"` OpDivScope } ``` ## Why `FindScoresInput` is the one query-input struct without this tagging. Its siblings — `FindAnswersInput`, `FindFismaSystemsInput`, `FindScoreDiffInput`, `FindScoreProgressInput`, `FindSystemInsightsInput` — all carry it. Both fields are populated by the server, never by a caller: `UserID` from the session via `ApplyTier`, and `FismaSystemIDs` from `user.AssignedFismaSystems` (`controller/scores.go:322`, its only assignment anywhere). Since neither is a parameter callers are meant to send, decode should reject them rather than accept them silently. `ApplyTier` assigns `UserID` only for the tiers needing self-scope, so for any other tier a decoded value would otherwise persist into the query. ## Tests Three cases, mirroring the shape `datacalls_export_authz_test.go` already uses for the export input: - **Negative** — the two tagged fields fail to decode from the query string. - **Positive** — `fismasystemid`, `datacallid` and `include_pillars` still decode normally, so the tagging doesn't cost callers anything they legitimately send. - **Handler** — `ListScores` answers 400 for a request naming a server-owned field, rather than proceeding. Decode failing short-circuits before the query runs, so this needs no database. ## Verification - `go build ./...` and `go vet ./...` clean - `go test -short ./...` — **630 passed across 19 packages**, up from 622 by exactly the eight added here - Confirmed `FismaSystemIDs` has no caller that sets it from a request, so the tag breaks nothing Small and self-contained; no behavior change for any legitimate request.
## What Declares `price_class` on the CloudFront distribution and adds a `lifecycle` postcondition asserting it. Two additions, twelve lines, no other change. ## Why `price_class` was inherited from the AWS provider's schema default rather than declared. That default is not stated in the provider's published argument reference — it exists only in the SDK schema — so the effective value was implicit and dependent on an upstream choice, while `terraform.tf` permits any 5.x minor. Declared as a literal rather than a variable so the value is identical in every environment. Placed adjacent to `restrictions` to match the canonical example in the registry docs. The postcondition evaluates against refreshed state during `plan`, so it catches an out-of-band change as well as a bad diff, and it fails at plan time rather than at apply. ## Verification - `terraform plan` against dev reports `No changes. Your infrastructure matches the configuration.` both before and after this commit. This is a no-op against live state. - `price_class` carries no `ForceNew` in the provider schema, so it cannot trigger distribution replacement under any value change. No recreate, no new distribution ID or domain name, no propagation event. - Postcondition negative-tested by falsifying the assertion — Terraform correctly fails the plan, confirming the guard is live rather than inert. - `terraform fmt -check` clean. ## Scope The `restrictions` block is untouched and unasserted. An earlier revision of this PR also asserted the geo restriction; that was dropped deliberately. Geo enforcement interacts with the WAF layer in ways that an assertion here would prematurely lock in, so the distribution's geo posture is left free to change independently of this PR. Tracked in CMS-Enterprise/ztmf-misc#293. Co-authored-by: Calvin Costa <108481161+costacalvin@users.noreply.github.com>
## Summary Closes #346. `ARRAY_AGG` over zero rows is SQL `NULL`, which scans into a nil slice and serializes as JSON `null`. A user holding no OpDiv grants was therefore indistinguishable from a response that omitted the field. Wraps the subquery in `COALESCE(..., '{}')` and lifts it to a shared const so the paths cannot drift. The spec was already on the fixed side of this. `backend/openapi.yaml` has always declared the field as a plain array: ```yaml assignedopdivids: items: type: integer type: array ``` No `nullable: true`. So the API was returning `null` for a field its own published contract declared non-nullable. This brings the implementation into conformance with the schema rather than the other way round — which also means the spec needs no regeneration (verified: regenerating is byte-identical). ## Changes * **`internal/model/users.go`:** `assignedOpDivIDsSubquery` const, applied to the `/users` paths — `FindUsers`, `findUser`, `Save` (insert and update), and `RestoreUser`. Scans realigned where the `RETURNING` list grew. The delegate paths deliberately do not select it; see below. * **`internal/model/users_opdivarray_integration_test.go` (new):** pins the contract on both the scanned slice and the marshalled JSON. * **`emberfall_tests.yml`:** assertions on create, get, list, update, restore, and `/users/current`, plus a delegates-roster assertion pinning the field as `null` there. ## Why this stops at the /users resource The delegate paths deliberately do **not** select the field, because populating it there would leak data an authorization gate already withholds: * An ISSO is `403`'d on `GET /users` — user management is restricted, and that is where OpDiv membership is otherwise exposed. * That same ISSO **can** read their own system's delegate roster: `ListDelegates` gates only on `CanAccessFismaSystem`. * A delegate is granted the OpDiv of every system they are attached to (`AddSystemDelegate` inserts into `users_opdivs`), so a shared delegate's other-OpDiv memberships would become visible to a system-scoped viewer. The counter-argument — that these responses already carry `assignedopdivids` as `null`, so selecting it merely corrects a value — is about schema conformance, not confidentiality. `null` discloses nothing; a populated array discloses real membership. Those are separate questions, and the confidentiality one wins. So the delegate responses keep spelling the field `null`. The consequence is that a client's `?? []` stays load-bearing off the `/users` resource, which is the right trade. The emberfall roster assertion pins the `null`, so adding the subquery to those paths fails loudly — verified by adding it and watching the assertion go red. ## Acceptance criteria * [x] `assignedopdivids` is always a JSON array (`[]` when no grants), never `null` * [x] Applied to both `FindUsers` (list) and `findUser` (detail) * [x] Implemented with `COALESCE(ARRAY_AGG(opdiv_id), '{}')` in the subquery * [x] Emberfall assertion added so the shape cannot regress ## Testing `go build`, `go vet`, `go test -short ./...`, `make test-integration`, and `make test-e2e` (211 cases) all clean. Both layers verified non-vacuous by reverting the COALESCE: | Check | Result | | --- | --- | | Go integration, COALESCE reverted | 4 subtests red | | emberfall, COALESCE reverted | 23 red | | emberfall, subquery re-added to a delegate path | roster assertion red | The Go test resolves a grantless user by query rather than hardcoding an id, so it cannot pass vacuously against a fixture where everyone is granted, and `GrantsAreNotFlattened` cross-checks every seeded user's array length against the `users_opdivs` counts — so a COALESCE that flattened everyone to `[]` would fail too.
The dependency scan is red across the whole backend queue on a Go standard-library advisory against 1.25.11, the version pinned in `backend/go.mod`. Nothing in any of the open PRs causes it, but because the backend and infrastructure jobs are gated on `!failure()` they skip while that scan is red, so no backend PR can reach the dev deploy main's ruleset requires. #558 and #559 are both sitting behind it. This moves the module directive and the builder image in`backend/Dockerfile` together, so the binary that gets scanned is built with the same toolchain the scan is checking. 1.25.14 rather than the minimum 1.25.13: both exist upstream, and taking the current patch in the series avoids landing on a release that carries its own open advisory. Verified before opening: `go build ./...` clean, and the full DB-free unit suite green at 630 tests across 19 packages.
…e Terraform timeout (#558) When a backend image starts against a database whose migration version is ahead of the image's registry, `migrations.Run()` hits tern's `BadVersionError` and `log.Fatal`s into CloudWatch, the task crash-loops, and the only symptom in Actions is the tfSTABLE timeout after 20 minutes, the same string a contention timeout produces. Grepping a failing run for the migration error finds nothing because the fatal never reaches the job output (ztmf-misc#298). Three changes, per the ticket's two candidate shapes: 1. `migrations.Run()` now classifies tern's `BadVersionError` and fatals with a greppable `MIGRATION_VERSION_MISMATCH` message naming the version gap and the remedy (rebase onto latest main). Only this error class is reachable in a deployed environment since `Migrate()` always targets the registry length; other errors pass through unchanged. 2. `infrastructure.yml` gains a `Deploy failure diagnostics` step (`if: failure()`) that prints the ECS service events, stopped-task reasons and exit codes, and any `MIGRATION_VERSION_MISMATCH` lines from the last 30 minutes of the log group. This repo is public, so raw application logs are never dumped into job output; anything beyond the controlled token points at CloudWatch by name. A crash-looped startup fatal and a genuine contention timeout now look different in the job output. 3. `orchestration-dev.yml` fails fast, in the existing Check-for-Changes job, when main carries migration files the branch lacks, naming the mismatch and the remedy in seconds instead of after a 20-minute burn. This is a git approximation: it cannot see a migration another open PR deployed to dev without merging, which is the case changes 1 and 2 cover. Gate: `go build`, unit, integration, and emberfall e2e all green locally. The fail-fast check was simulated from the parent of the newest migration-bearing commit (flags the missing migration and fails) and from HEAD (passes clean). No API contract change, so no `openapi.yaml` or `emberfall_tests.yml` changes.
Bumps `github.com/xuri/excelize/v2` from v2.9.0 to v2.11.0, resolving Dependabot alert 44 (GHSA-h69g-9hx6-f3v4, high): unbounded row-index allocation in the worksheet parser that can OOM or panic when opening a crafted xlsx. The vulnerable code path is not reachable in this codebase - our only excelize usage is the write-only data-call export (`NewFile()` + `SetCellValue` in `backend/cmd/api/internal/spreadsheet/spreadsheet.go`); we never parse untrusted spreadsheets. This is hygiene to close the alert, not an urgent patch. Both APIs we call are signature-identical between v2.9.0 and v2.11.0; the documented breaking changes in this range are chart/shape APIs we don't touch. Transitive upgrades (mscfb, msoleps, efp, nfp, x/crypto, x/net, and the mohae/deepcopy to tiendc/go-deepcopy swap) are excelize's own upstream dependency changes. The `go 1.25.14` directive already satisfies v2.11.0's Go 1.25 floor, so no toolchain or Dockerfile change is needed. ## Testing - `go build ./...` clean - `make test-unit` green - `go mod tidy -diff` produces no diff (go.mod/go.sum are canonical) - `go vet` on the spreadsheet package clean - No OpenAPI, migration, or emberfall changes required: dependency-only bump with no request/response surface change
…filters (#566) Closes #564. First slice of the admin events page epic (CMS-Enterprise/ztmf-ui#183). `GET /events` returned the entire events table unordered: no ORDER BY, no limit, no time filter, roughly 200k rows in prod. This makes it a paginated contract: results order by `createdat` DESC with `eventid` as tiebreaker, `limit` (default 50, capped at 500) and `offset` page through, and `from`/`to` bound `createdat` inclusively as RFC3339. The response becomes a page object, `{events, total, limit, offset}`, echoing the values actually applied so a client pager never re-derives the defaulting rules. No frontend consumer exists yet, so nothing breaks; the page in CMS-Enterprise/ztmf-ui#711 builds on this contract. Two deviations from the ticket text, worked out in scoping. Offset pagination instead of a cursor, because the consumer is MUI DataGrid server-side paging, which wants page and pageSize with arbitrary jumps. And a migration (0058) the ticket didn't call for: the events table has no primary key and bulk writers stamp identical createdat values, so createdat alone is not a total order and tied rows could swap across page boundaries; 0058 adds an `eventid` identity column as tiebreaker plus a `(createdat DESC, eventid DESC)` index, since the 0037/0054 indexes both need their leading column in the predicate and cannot serve the unfiltered scan. Event rows now expose `eventid`, which the UI can use as a row key. Also folded in: `sanitizeErr` now maps `ErrInvalidQueryParam` to 400 when a handler returns it directly. Previously only the schema-decode branch produced it; the new from/to parse path returns it, and unmapped it fell through to 500. Gate: `make test-unit`, `make test-integration`, and `make test-e2e` all green locally (emberfall 217 ran, 0 failed, including seven new /events cases covering the page shape, echo and clamp behavior, and 400s for bad params). New unit tests pin the limit defaulting and clamping and the inverted-range 400; a new integration test writes tied timestamps and proves paging covers every row exactly once, with the range bounds inclusive. `openapi.yaml` regenerated via `make generate-openapi`; redocly lint is clean with the same 8 pre-existing warnings as main.
…the events response (#569) Closes #565. Part of the admin events page epic (CMS-Enterprise/ztmf-ui#183, backend half). Event rows carried only the initiating user's uuid, forcing the client to join against the users list to show who acted. GET /events now resolves the user server-side: each row carries userfullname, useremail, and userdeleted, populated by an inner join to users in FindEvents. The join cannot drop rows because events.userid is NOT NULL with an FK to users (migration 0006), which is also why the count query stays join-free and Total cannot drift from the page. Soft-deleted users still own historical events, so the contract is: their identity is returned as it stands today, with userdeleted true as the client's cue to mark a retired account. Rendering that state is the frontend's call. The change is additive; nothing in the existing response moves. openapi.yaml is regenerated (model.Event becomes model.EventWithUser in the page schema) and emberfall gains a deterministic assertion using the seeded archives service account, which is the only writer of action=imported. Integration coverage asserts identity resolution on every paged row and the soft-deleted case end to end. Gate: make test-unit, make test-integration, and make test-e2e all pass locally.
…#568) Requires PRs that touch the migrations package to be current with the head of main, and fixes the missing-migration fail-fast from #558 so it compares against the PR head instead of the merge-ref checkout. Migrations are globally ordered state. A migration authored against a stale schema can collide on version number at merge, and an edited migration or schema change merged behind an open branch never shows up as a missing file, so the existing check could not see it. The new step fails the Check for Changes job with a rebase instruction whenever the PR diff touches backend/cmd/api/internal/migrations and the branch is behind main. PRs that do not touch migrations are unaffected. The #558 fix: on pull_request events the default checkout is refs/pull/N/merge, whose main side diffs empty by construction, so the missing-migration step could only fire if main advanced in the seconds between the merge ref being computed and the job's fetch. Diffing from github.event.pull_request.head.sha (the merge commit's second parent, already present in the full-history checkout) makes it do what its comment says. Verified both behaviors in a scratch repo: the head-sha diff reports main's migration missing from a stale branch where the merge-ref diff returns nothing. Known limit, recorded not solved: these steps run on PR events only, so a migration merging to main does not re-trigger open PRs. Making the rule binding at merge time is a branch-protection question (require branches up to date), not a workflow one. Gate: unit, integration, and emberfall e2e suites pass locally; actionlint clean on the changed steps (one pre-existing SC2086 info on the untouched backend-gating step). Closes CMS-Enterprise/ztmf-misc#303.
Rehomes #556 onto an in-repo branch, with the original commit's authorship preserved. Dependabot-triggered runs read the Dependabot secret store rather than the repo and environment secrets, so `SNYK_TOKEN` was empty on #556 and all three Snyk jobs failed with a 401 rather than on anything in the diff. That PR could never go green, and so could never satisfy the required dev deployment on main. Pushing the same commit from an in-repo branch gives those jobs real credentials. #556 is closed in favor of this one. The bump itself is unchanged: nine action updates across seven workflows, moving `actions/checkout` and `actions/setup-go` onto majors that run on Node 24. That part is not cosmetic - current runs already warn that `actions/checkout@v4` and `actions/setup-go@v5` are being forced onto Node 24 because Node 20 is deprecated. The second commit corrects two provenance comments. `snyk/actions` and `aquia-inc/emberfall` are pinned by SHA because neither publishes tags the workflow can track, and each carries a trailing comment naming the date of the pinned commit. Both SHAs moved in this bump while the comments still named the old date, so each described a commit that is no longer the one pinned. They now read 2026-08-03 and 2026-08-12 respectively. Verified before opening: the first commit's tree is identical to #556's head, and the branch as a whole differs from it only in those two comment lines. The residual mix of bare tags in `analysis.yml` and SHA pins elsewhere is untouched and remains the subject of #317. Expect the Go scan to be red here for the same reason it is red on #558 - a Go standard-library advisory against the version pinned in `backend/go.mod`, unrelated to this diff. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…efs, and replace staticcheck-action (#571) Stacked on #559: this branch sits on top of it, so the diff here includes #559's two commits until it merges. Once it does I will rebase onto main so only the commit below remains, and mark this ready for review. Draft until then. Finishes ztmf#317 on top of #559. That PR moved the remaining actions to their Node.js 24 majors; this one converts every ref it left as a floating tag to a commit SHA with a version comment, matching the format #349 established in backend.yml, and closes out the rest of the ticket's acceptance criteria. Also closes CMS-Enterprise/ztmf-misc#173. The two snyk/actions refs in analysis.yml were still on @master. All three snyk/actions uses now pin the v1.0.0 tag (9adf32b, 2025-10-03); comparing that tag to master head shows no changes under setup/, golang/, or docker/, so this is a provenance change only, and it retires the stale "no tagged releases" comment. aquia-inc/emberfall moves from a main commit to the v0.5.0 tag, whose action.yml is byte-identical to the previous pin; the binary stays at 0.4.1 via the version input. docker/setup-buildx-action goes to v4.3.0 for parity across backend.yml and ops-image.yml. The two setup-go blocks in analysis.yml gain cache-dependency-path: backend/go.sum, which #349 only did for backend.yml, so the openapi and snyk test jobs stop missing the cache on every run. The lint go job no longer uses dominikh/staticcheck-action. The composite pins actions/cache to a Node.js 20 build (upstream dominikh/staticcheck-action#22 and #23, master unchanged since March 2026), which was the one deprecation warning left after every other bump. The job now installs staticcheck with go install after a setup-go on Go stable, mirroring the composite's install-go default, since staticcheck's latest release requires a newer Go than the go.mod floor and setup-go v6+ sets GOTOOLCHAIN=local. The action's problem matcher is checked in under .github/matchers/staticcheck.json so PR annotations behave as before. The same commands run clean locally against backend/ on Go 1.26.5 with staticcheck 2026.2.1. Verification: a script resolved every pinned SHA back to its tag through the GitHub API and confirmed each action's runtime is node24 (or composite/docker); no @vn, @master, or @main refs remain under .github/workflows. actionlint reports the same 16 pre-existing shellcheck notes as main and nothing new. ops-image.yml and nightly-impl-sync.yml have no pull_request trigger, so their pins are verified by the resolver rather than a run. .github/dependabot.yml already carries the github-actions group from #349 and is unchanged. Closes #317 The red Check for Changes is the #568 migration-currency guard: this branch inherits #559's base, which is five commits behind main and predates 0058eventspagination.go. It clears with the rebase once #559 merges; as a draft this PR does not deploy to dev, so the guard's crash-loop scenario cannot occur here.
… their OpDivs (#574) Closes CMS-Enterprise/ztmf-misc#356. An OpDiv-scoped admin could not set FIPS or the other system attributes on their own systems. `SaveFismaSystem` nilled `hva`, `fips`, `system_type`, `cloud_system`, `cloud_service_model`, `cloud_vendor`, `system_operator`, `goco_coco_gogo`, and `legacy` on POST and restored the stored values on PUT for any caller without unscoped read, then returned success. The frontend has let every write admin edit those fields since ztmf-ui#459, so an HRSA admin saw an editable field whose value never persisted (reported by HRSA on 2026-08-28). The gate's stated purpose was partial-PUT protection for fields the form could not edit. `Save` already provides that for everyone: an omitted or null field leaves the stored value untouched. So this removes `clearUnscopedOnlyFields` and `preserveUnscopedOnlyFields` and their branch. The update path keeps `guardManageFismaSystem` for callers without unscoped read, so an OPDIV_ADMIN still gets 403 on a system outside their granted OpDivs and 404 on a missing one. OWNER and HHS_ADMIN paths are unchanged, and read-only tiers are still rejected at `IsAdmin()`. `fismasystems_metadata_test.go` is deleted; its four tests only exercised the removed helpers. Emberfall Test B is rewritten: the OpDiv admin's create now asserts every attribute persisted, the update sends a different value for each attribute and contact field and the GET asserts they landed, and a new case pins the cross-OpDiv PUT 403. `cloud_system` stays true in the update because `Save` rejects a service model or vendor when it is No. Gate: `make test-unit` green, `make test-integration` green, `make test-e2e` 219/219 (218 on main), `make generate-openapi` no drift. No frontend change is needed; ztmf-ui already sends these fields.
…emails.group (#580) Closes CMS-Enterprise/ztmf-misc#297. Closes CMS-Enterprise/ztmf-ui#653, which reports the same 400 from the frontend side. The EmailModal offers READONLY_ADMIN as a recipient group but massEmailGroups had no such key, so the request failed validation. Mack's call on the ticket was to add the backend group rather than drop the option, since read-only admins are a small but distinct cohort at CMS and likely at other OpDivs. This adds a READONLY_ADMIN selector covering HHS_READONLY_ADMIN and OPDIV_READONLY_ADMIN, mirroring how ADMIN covers the write tiers. ALL still excludes every admin tier. While wiring it up I found that massemails."group" was still the VARCHAR(5) from migration 0011, so Save rejects any key longer than five characters before recipients are resolved. SYSTEM_DELEGATE (15 chars) hits this today and returns 500; I confirmed it against a clean dev stack. Migration 0059 widens the column to VARCHAR(30), the same width users.role got in 0035, so READONLY_ADMIN and SYSTEM_DELEGATE both save. I also ran 0059 against a populated dev DB to check the upgrade path, not just a fresh schema. Behavior changes: POST /massemails with group READONLY_ADMIN goes from 400 to 201 and emails the read-only admin tiers. Group SYSTEM_DELEGATE goes from 500 to 201. No API shape changes, openapi.yaml regenerates with no drift. Tests: unit tests pin that every group key fits the widened column and that the READONLY_ADMIN query names exactly the two read-only roles. Integration tests round-trip every key through Save (the check that would have caught the width cap) and assert the READONLY_ADMIN audience against the empire seed. Emberfall gains 201 cases for READONLY_ADMIN and SYSTEM_DELEGATE; the test stack has no SMTP so the send goroutine logs a dial error and nothing leaves. Gate: make test-unit, make test-integration, and make test-e2e (221 passed, 0 failed) all green locally. Not changed here: none of the role-based recipient queries filter deleted users, so a deactivated admin or delegate still receives mail. That predates this change and applies to every group, so I will file it separately. Any service account carrying HHS_READONLY_ADMIN will be in the READONLY_ADMIN audience.
…js (#575) ## What Updates `make frontend-env` for CMS-Enterprise/ztmf-ui#722 (ztmf-misc#351): the local auth-bypass token now lands in `ztmf-ui/public/config.js`, read by the app at runtime, instead of `VITE_AUTH_TOKEN3` in `.env.development.local`, which nothing reads once #722 is in. The env file keeps the vite proxy settings (`VITE_CF_DOMAIN`, `VITE_IDP_ENABLED`); the retired `VITE_LOCAL_DEV` flag is no longer written. Token generation is unchanged. ## Merge order Landed after CMS-Enterprise/ztmf-ui#722 (merged 2026-09-08). The order mattered for safety, not convenience: #722 is what gitignores `public/config.js`, and `public/` is tracked, so on the old ui main this target would have left a live admin JWT untracked and unignored, one `git add -A` from a public commit. It would also have broken local-dev auth for anyone still on the env token. ## Verification `make frontend-env` emits both files, and the generated token authenticates against the local dev API (200 on `/api/v1/users/current`, 401 without it). No Go changes; the full local suite runs via the pre-push hook.
Bumps the github-actions group with 2 updates: [terraform-linters/setup-tflint](https://github.com/terraform-linters/setup-tflint) and [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials). Updates `terraform-linters/setup-tflint` from 6.3.0 to 6.3.1 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/terraform-linters/setup-tflint/releases">terraform-linters/setup-tflint's releases</a>.</em></p> <blockquote> <h2>v6.3.1</h2> <!-- raw HTML omitted --> <h2>What's Changed</h2> <h3>Features</h3> <ul> <li>Build dist on release instead of committing it to master by <a href="https://github.com/bendrucker"><code>@bendrucker</code></a> in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/447">terraform-linters/setup-tflint#447</a></li> </ul> <h3>Dependencies</h3> <ul> <li>build(deps-dev): Bump the eslint group across 1 directory with 7 updates by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/457">terraform-linters/setup-tflint#457</a></li> <li>build(deps): Bump actions/setup-node from 6.4.0 to 7.0.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/458">terraform-linters/setup-tflint#458</a></li> <li>build(deps): Bump actions/checkout from 6.0.2 to 7.0.1 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/459">terraform-linters/setup-tflint#459</a></li> <li>build(deps-dev): Bump <code>@humanfs/node</code> from 0.16.6 to 0.16.8 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/463">terraform-linters/setup-tflint#463</a></li> <li>build(deps-dev): Bump browserslist from 4.28.2 to 4.28.8 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/464">terraform-linters/setup-tflint#464</a></li> <li>build(deps-dev): Bump prettier from 3.8.3 to 3.9.6 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/455">terraform-linters/setup-tflint#455</a></li> <li>build(deps-dev): Bump globals from 17.5.0 to 17.11.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/454">terraform-linters/setup-tflint#454</a></li> <li>build(deps): Bump actions/cache from 5.0.5 to 6.1.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/450">terraform-linters/setup-tflint#450</a></li> <li>build(deps): Bump undici from 6.24.0 to 6.28.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/456">terraform-linters/setup-tflint#456</a></li> <li>build(deps-dev): Bump brace-expansion from 1.1.13 to 1.1.18 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/465">terraform-linters/setup-tflint#465</a></li> <li>build(deps): Bump <code>@actions/cache</code> from 6.0.1 to 6.2.0 in the actions group across 1 directory by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/terraform-linters/setup-tflint/pull/451">terraform-linters/setup-tflint#451</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/terraform-linters/setup-tflint/compare/v6.3.0...v6.3.1">https://github.com/terraform-linters/setup-tflint/compare/v6.3.0...v6.3.1</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/1cf010d3c7aef302051ccdb68c14c5dc2efa34ef"><code>1cf010d</code></a> Release v6.3.1</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/7d1a4fffc61fa34a7448ad8d91a6aa64cccf6764"><code>7d1a4ff</code></a> build(deps): Bump <code>@actions/cache</code> in the actions group across 1 directory (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/451">#451</a>)</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/1db284159cbed99dacdbd13359fb79057a572d91"><code>1db2841</code></a> build(deps-dev): Bump brace-expansion from 1.1.13 to 1.1.18 (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/465">#465</a>)</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/a87802ed8b3d0085978a2a1e8fdaf903a8eeb216"><code>a87802e</code></a> build(deps): Bump undici from 6.24.0 to 6.28.0 (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/456">#456</a>)</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/bf78bc1c4058e8c7ac027596aa0bca02ad8b0a9b"><code>bf78bc1</code></a> build(deps): Bump actions/cache from 5.0.5 to 6.1.0 (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/450">#450</a>)</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/0e90682785121c1789d948a6ea8da941beecbadd"><code>0e90682</code></a> build(deps-dev): Bump globals from 17.5.0 to 17.11.0 (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/454">#454</a>)</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/2e3e73f9f031d87cee574f2e82973a38a33fc369"><code>2e3e73f</code></a> build(deps-dev): Bump prettier from 3.8.3 to 3.9.6 (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/455">#455</a>)</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/c2c09245628fd3cf3790ea5b5ce1eddd15ca261e"><code>c2c0924</code></a> Build dist on release instead of committing it to master (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/447">#447</a>)</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/cd824714eaa30e35352ff29ab7e24a4c50089cea"><code>cd82471</code></a> build(deps-dev): Bump browserslist from 4.28.2 to 4.28.8 (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/464">#464</a>)</li> <li><a href="https://github.com/terraform-linters/setup-tflint/commit/b1e96471b81b9e8bdabe7df3f214b344c99844f6"><code>b1e9647</code></a> build(deps-dev): Bump <code>@humanfs/node</code> from 0.16.6 to 0.16.8 (<a href="https://redirect.github.com/terraform-linters/setup-tflint/issues/463">#463</a>)</li> <li>Additional commits viewable in <a href="https://github.com/terraform-linters/setup-tflint/compare/6e1e0642c0289bd619021bf6b34e3c08ed1e005a...1cf010d3c7aef302051ccdb68c14c5dc2efa34ef">compare view</a></li> </ul> </details> <br /> Updates `aws-actions/configure-aws-credentials` from 6.2.3 to 6.2.4 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/aws-actions/configure-aws-credentials/releases">aws-actions/configure-aws-credentials's releases</a>.</em></p> <blockquote> <h2>v6.2.4</h2> <h2><a href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.3...v6.2.4">6.2.4</a> (2026-08-31)</h2> <h3>Bug Fixes</h3> <ul> <li>account-ids handling, mask proxy as secret in logs (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1943">#1943</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/aa6526434b08748f8776b29964e3f1f5d90e7b63">aa65264</a>)</li> <li>skip backoff sleep after the final retryAndBackoff attempt (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1937">#1937</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/3852440c21363386b7b790605685d08a7c1a4876">3852440</a>)</li> </ul> </blockquote> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md">aws-actions/configure-aws-credentials's changelog</a>.</em></p> <blockquote> <h1>Changelog</h1> <p>All notable changes to this project will be documented in this file. See <a href="https://github.com/conventional-changelog/standard-version">standard-version</a> for commit guidelines.</p> <h2><a href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.3...v6.2.4">6.2.4</a> (2026-08-31)</h2> <h3>Bug Fixes</h3> <ul> <li>account-ids handling, mask proxy as secret in logs (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1943">#1943</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/aa6526434b08748f8776b29964e3f1f5d90e7b63">aa65264</a>)</li> <li>skip backoff sleep after the final retryAndBackoff attempt (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1937">#1937</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/3852440c21363386b7b790605685d08a7c1a4876">3852440</a>)</li> </ul> <h2><a href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.2...v6.2.3">6.2.3</a> (2026-07-22)</h2> <h3>Bug Fixes</h3> <ul> <li>attach git credentials before Tag Major Version push (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1877">#1877</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/9ae780b171afa8c5a3a6a2d154a765b709492482">9ae780b</a>)</li> <li>PackedPolicyTooLarge detection in STS tags (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1899">#1899</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/fa8d6a57bbf44b34439fb080bbdadc7c92c285eb">fa8d6a5</a>)</li> </ul> <h2><a href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.1...v6.2.2">6.2.2</a> (2026-07-07)</h2> <h3>Miscellaneous Chores</h3> <ul> <li>release 6.2.2 (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/d01d678e65d6d2bd9d5ca7a95d6f07b00e25f2c2">d01d678</a>)</li> </ul> <h2><a href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.0...v6.2.1">6.2.1</a> (2026-06-26)</h2> <h3>Bug Fixes</h3> <ul> <li>enforce allowed-account-ids on all auth paths (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1847">#1847</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/4d281fbc56a82e63c3fc14f2cc22361f34c97493">4d281fb</a>)</li> </ul> <h2><a href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.1.3...v6.2.0">6.2.0</a> (2026-06-01)</h2> <h3>Features</h3> <ul> <li>add additional session tags by default (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1775">#1775</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/e0ba7685077379a14a82d01fefd511490344ebfc">e0ba768</a>)</li> <li>add more retry logic and better logging (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1764">#1764</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/540d0c13aedb8d55501d220bd2f0b3cdedfe84e8">540d0c1</a>)</li> <li>add regex validation to role-session-name (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1765">#1765</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/e35449909c6ede5083a48ba4b8bbfaaa1cf09ba1">e354499</a>)</li> <li>Allow custom session tags to be passed when assuming a role (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1759">#1759</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/61f50f630f383628add73c1eab3f1935ba07da2b">61f50f6</a>)</li> <li>expose run id in STS client user-agent (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1774">#1774</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/29d1be30273e7ef371d59fccf6ec54572c64ec89">29d1be3</a>)</li> <li>support custom STS endpoints (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1762">#1762</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/8d52d05d7a4521fa52b39de50cb6114b12e5c332">8d52d05</a>)</li> </ul> <h3>Bug Fixes</h3> <ul> <li>skip credential check on output-env-credentials: false (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1778">#1778</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/58e7c47adf77846879008deadfeeef8a6969fe6c">58e7c47</a>)</li> <li>assumeRole failing from session tag size too large (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1808">#1808</a>) (<a href="https://github.com/aws-actions/configure-aws-credentials/commit/d6f5dc331b44474b19a52caaf85fa4d637b13c8e">d6f5dc3</a>)</li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/cbe3b392738ccf3f987d68400dafcf4b0624a56c"><code>cbe3b39</code></a> chore(main): release 6.2.4 (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1942">#1942</a>)</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/58065db07c99675fc21675188b003b7f0b167004"><code>58065db</code></a> chore(deps): bump js-yaml (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1944">#1944</a>)</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/609df23709e359dc01a42b4c5183ba71167ac38c"><code>609df23</code></a> chore: Update dist</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/aa6526434b08748f8776b29964e3f1f5d90e7b63"><code>aa65264</code></a> fix: account-ids handling, mask proxy as secret in logs (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1943">#1943</a>)</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/7fdbbb8968c49fb55011ace47efc7b0ccfc9a28f"><code>7fdbbb8</code></a> chore: Update dist</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/3852440c21363386b7b790605685d08a7c1a4876"><code>3852440</code></a> fix: skip backoff sleep after the final retryAndBackoff attempt (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1937">#1937</a>)</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/c16f89bdf4cd065448ea7bde8b96a1dad4c77e41"><code>c16f89b</code></a> mention renamed repos use the new immutable identifiers (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1941">#1941</a>)</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/9c362eeba7ac7d0419073a8b4af5a83e49e2afaf"><code>9c362ee</code></a> chore: Update dist</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/d5f8da8822f961cd3016c2cf87aad7e80b40558e"><code>d5f8da8</code></a> chore(deps): bump <code>@aws-sdk/client-sts</code> from 3.1111.0 to 3.1116.0 (<a href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1935">#1935</a>)</li> <li><a href="https://github.com/aws-actions/configure-aws-credentials/commit/2db24970cf129d7ff6fc04639072bcbe35f8c288"><code>2db2497</code></a> chore: Update dist</li> <li>Additional commits viewable in <a href="https://github.com/aws-actions/configure-aws-credentials/compare/e6de054238d6b7531b4efff3b6587d9aade6a06c...cbe3b392738ccf3f987d68400dafcf4b0624a56c">compare view</a></li> </ul> </details> <br /> Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated nightly sync of
mainintoimpl— main wins every overlap.This branch is
implwithmainmerged in using-X theirs, so the result is deterministic: main's version wins any conflict, and impl-only files that don't conflict are preserved. There should be nothing to hand-resolve.Squash-merge this PR —
implis squash-only by policy (linear history). Becausemainandimplshare no squash ancestry the diff is computed against a stale base, but-X theirsmakes it deterministic and the content settles (a no-change night opens no PR). Unlike ztmf-ui (which merges as a merge commit), this stays in content-sync rather than closing the ancestry gap.impland triggers the impl deploy (orchestration-impl: backend image build + terraform apply).--ref automation/sync-main-to-impl.Opened by the nightly-impl-sync workflow.