Moonshot — the app behind Dialogi (dialogi.duunitori.fi), Duunitori's employer questionnaire/ATS product — runs its own email-and-password login, completely separate from CIAM, the shared Cognito-based login used across the Duunitori sites. This project replaces moonshot's local auth with CIAM: one login across all .duunitori.fi apps, an entire self-maintained auth stack (and its Redis) deleted, and GDPR user deletions propagating to moonshot automatically instead of through a manual side process. The plan is complete — this page is here so the team can validate it before implementation starts.

The problem

Moonshot maintains a full standalone auth system for ~4,000 employer/admin accounts (account figures here are from the original project brief — to be confirmed against production data during migration planning): bcrypt password hashes, self-service registration with email-confirmation tokens, password-reset tokens, and server-side sessions in Redis. That is security-sensitive code we wrote and must keep maintaining ourselves — and Redis sits in moonshot's stack for sessions only; nothing else uses it.

It also means a separate account and password for people who are often already our users: roughly a quarter of moonshot's account emails already exist in CIAM.

GDPR deletion is a manual side process. When a user asks to be deleted, CIAM handles the request and broadcasts the deletion on an SNS topic; duunitori5 subscribes and deletes its data automatically. Moonshot is not a subscriber and has no deletion code at all — its deletions have to be remembered and done by hand.

Two things make fixing this unusually cheap right now:

  • The CIAM session cookie (dt-session, scoped to .duunitori.fi) already reaches dialogi.duunitori.fi on every request — the browser side needs nothing.
  • The ciam repo's own Go admin backend contains a reference implementation of cookie verification, guarded by a cross-repo golden-token test (a real CIAM-minted session token checked into CI as a fixture, so any cross-repo format drift breaks the build), that moonshot can port nearly verbatim.

Ironically, moonshot's schema was built for this day — an account / account_local multi-provider split — but the seam was never used: the only provider ever implemented is local. (Full verified inventory in 01-current-state.md.)

The proposal

Moonshot becomes a stateless consumer of the CIAM session cookie. A middleware on every request reassembles the (chunked) dt-session cookie, verifies it, maps the CIAM identity to a row in a single account table, and puts the account into the request context — everything downstream (handlers, the admin flag, account groups) is untouched.

In practice:

  • Transparent SSO — there is no login page. Anyone already logged into duunitori.fi is simply logged into Dialogi; unauthenticated visitors are redirected to CIAM login and back.
  • Single account table — gains ciam_id, email, locale; account_local, the password/token machinery, and the never-used provider abstraction are removed.
  • SSO-wide logout — logout redirects to CIAM logout, which clears the session for every .duunitori.fi app.
  • Automatic GDPR deletion — moonshot subscribes to CIAM's ciam-user-deleted SNS topic; a deletion handled in CIAM deletes the linked moonshot account's data too.
Key decisions

Each with its why and what was rejected (full 15-entry decision log in 02-target-state.md):

Verify the cookie inside moonshot — don't call CIAM on every request. The middleware verifies the cookie's outer signed JWT locally (shared secret from Secrets Manager) and the Cognito access token embedded inside it (against Cognito's published keys) — a direct port of the ciam repo's Go middleware, golden-token test included. Rejected: proxying every request to the HTTP session endpoint of ciam-ui (the app that serves the CIAM login flow) — it adds a network hop and an availability coupling to every request, has awkward semantics (returns {} with HTTP 200 when unauthenticated, and even GET responses can set cookies), and doesn't expose Cognito group membership, which the inner token gives us for free. ciam-ui's session endpoint is still used for one thing: refreshing an expired token server-side, roughly once per user per day (the same thing duunitori5 does).

Stateless — Redis sessions are removed entirely, not re-minted after CIAM verification. A local session would only cache what the cookie already proves, while delaying CIAM logout or account-disable by up to the session's lifetime. The per-request cost of going stateless is small: a local signature check, a cached key lookup, one indexed DB read. And since sessions are Redis's only consumer in moonshot, the whole dependency leaves the stack. Rejected: keeping Redis sessions with CIAM as just the login step — less deleted code, worse logout semantics, a dependency kept for nothing.

Lazy-link existing accounts by email — don't create Cognito users for them. A one-off script matches account emails against CIAM and links the expected ~1k that already exist. The other ~3k keep a null ciam_id and link automatically the first time their owner logs in via CIAM with the same email — keeping their group memberships and history. Rejected: bulk-creating Cognito users for all of them, which writes 3k users into the shared user pool and sends surprise invite/password emails to people who may never return. Dormant unlinked rows are harmless.

Big-bang cutover — not a dual-auth window. One coordinated deploy removes local login and turns the CIAM middleware on; every existing session dies at that moment, and account creation pauses for about a day so that no accounts are created between the final email-matching sync pass and the deploy (an account created in that window would miss its CIAM link). Rejected: running both auth paths in parallel for weeks — real complexity and testing surface for a ~4k-account app, when the disruption of a single cutover is small and (per planning) acceptable. Rollback stays trivial because the old tables aren't dropped until well after cutover.

Unknown CIAM users get an account row auto-created — not a 403. All data access is gated by the admin flag and account-group membership, so a fresh account sees nothing; auto-creation just removes the admin "create account" chore (admins now only grant access by adding people to groups). Rejected: keeping accounts invite-only, which preserves a manual step with no security benefit.

Authorization is deliberately unchanged. is_admin and account groups keep working exactly as today, keyed by the same account.id (no foreign keys move). The middleware additionally exposes Cognito group membership in the request context — consumed by nothing yet, it's the hook for the planned fine-grained access-management project. Rejected: mapping admin rights to the Cognito employees group now (would widen admin to all employees) or redesigning authorization in this project (that's the follow-up's job).

The GDPR listener copies duunitori5's pattern, not its code. A separate audit of the deletion flow (docs/user-data-deletion/) found duunitori5's SNS ingest forgeable — it doesn't properly validate that the message really came from AWS. Moonshot's endpoint follows the same shape but validates the signing-certificate URL, pins the expected topic, and deduplicates messages from day one.

The plan

Eight phases; detail in 03-roadmap.md. Effort: S ≈ hours–1 day · M ≈ several days · L ≈ 1–2+ weeks.

PhaseWhatEffort
0 — Infra prerequisitesFix the Terraform signing-secret bug (see risks); grant moonshot read access to the shared secret; env vars; verify CIAM can redirect back to dialogiS–M
1 — Additive schemaAdd ciam_id/email/locale to account, backfill from account_local; old auth path keeps workingS
2 — Backend auth corePort the CIAM middleware (golden-token test included), refresh path, identity→account mapping, logout redirectM–L
3 — FrontendRedirect unauthenticated users to CIAM login; delete the local login/register/reset UIM
4 — Migration toolingOne-off script linking the expected ~1k email matches via Cognito; cutover-day runbookS–M
5 — CutoverBig-bang deploy: local login gone, CIAM live; smoke tests (incl. the applicant iframe)S, coordinated
6 — Destructive cleanupDrop account_local + token tables, delete local-auth code, remove Redis from runtime and infraS–M
7 — GDPR deletion listenerSNS subscription + hardened deletion endpointS–M

Why this order: Phase 0 blocks everything (moonshot can't read the signing secret without it). Phase 1 is purely additive and safe to ship early. Phases 1–4 can then proceed as parallel branches into the Phase 5 cutover. Phase 7 is not a cutover blocker — it lands alongside or after Phase 5.

Rollback story: trivial until Phase 6. Through cutover, account_local and all its data stay untouched — rolling back is just redeploying the previous release, and local login works again. Phase 6 (dropping tables and deleting code) is the point of no return, which is why it waits for a stable week after cutover.

Risks

Technical

  • The Terraform signing-secret bug must be fixed first — and carelessly, it logs out everyone, everywhere. In the ciam repo, the resource that writes the shared cookie-signing secret's value writes it into the wrong secret (the password-reset one); the live signing key was presumably set out-of-band. A careless fix could make Terraform rotate the live key — which invalidates every dt-session cookie and logs out every user of every duunitori.fi app, not just moonshot. Phase 0 mandates read-only verification of which secret holds the live key before anything is applied.
  • New availability coupling to ciam-ui. Roughly once per user per day the middleware refreshes an expired token via ciam-ui's session endpoint. If ciam-ui is down, affected requests degrade to "unauthenticated" (a login redirect) — designed never to 500, but it is a runtime dependency moonshot doesn't have today.
  • The applicant questionnaire must stay anonymous. The embedded iframe flow has no auth today and must be demonstrably unaffected — it gets an explicit smoke test at cutover.
  • Spoofed deletion events. An SNS handler that skips verification lets an attacker delete real accounts with a forged HTTP POST — a defect actually found in duunitori5's implementation. Moonshot's handler is specified hardened from day one; this needs review attention, not just a copy-paste.
  • Cross-repo cookie format drift. Moonshot will depend on the exact JWT format ciam-ui mints. The guard is the golden-token test described above: a real ciam-ui-minted token checked into moonshot's CI, same pattern the ciam repo uses.
  • Unresolved detail: on a deletion event, is the account row hard-deleted or anonymized? Evaluations reference accounts, so the choice affects what past evaluations display. Deferred to implementation — input welcome (question 2 below).

Business

  • Big-bang cutover cost: account creation pauses for about a day (to keep the final email-matching sync pass and the deploy atomic), and every logged-in Dialogi user is logged out once. The larger, quieter cost: for the ~3k accounts not yet linked to CIAM, the old password stops working for good at that moment — each owner must log in (or register) at duunitori.fi with the same email once, after which lazy-linking restores their groups and history. Planning judged this acceptable — it needs an explicit business yes and a date.
  • Logout becomes SSO-wide: logging out of Dialogi also logs you out of duunitori.fi (the only coherent option with transparent SSO — a local-only logout would instantly re-login).
  • Accounts become self-serve: any CIAM-authenticated visitor gets an empty, no-access account row auto-created, where today accounts are admin-created. No data exposure (groups/admin gate everything), but the invite-only model changes.
  • ~3k legacy accounts stay on the manual GDPR path until their owners log in via CIAM and get linked. The number dwindles but never provably reaches zero.
Open questions

The concrete questions we want the team to answer — this is the discussion agenda.

Technical

  1. Verification depth: we plan to verify both the outer cookie JWT and the embedded Cognito access token (duunitori5 only verifies the outer one). Any objection to the deeper check — or any reason to think even that isn't enough?
  2. Deletion end-state: when a GDPR deletion arrives, do we hard-delete the account row or anonymize it? Concretely: what should an evaluation authored by a deleted user show afterwards?
  3. The Terraform secret fix: who owns fixing and applying it, and are we agreed on read-only verification of the live key ARN before any apply?
  4. Redis removal sanity check: we verified sessions are moonshot's only Redis consumer — does anyone know of another before we remove the infra?

Business

  1. Cutover disruption: is the full package acceptable — a ~1-day account-creation pause, a one-time logout of all Dialogi users, and the ~3k unlinked accounts losing their local passwords for good (their owners log in via duunitori.fi with the same email once; groups and history survive)? Which week suits best — and should affected employers get advance notice? The cutover plan includes an announcement step, but its audience and content are still open.
  2. SSO-wide logout: acceptable UX for Dialogi users?
  3. Self-serve accounts: OK to replace admin-created accounts with auto-creation on first CIAM login (access still granted manually via groups)?
  4. Legacy accounts: are we comfortable leaving never-returning unlinked accounts on the manual GDPR path indefinitely, or should we plan a cleanup of dormant unlinked rows?
Out of scope
  • Fine-grained access management. The is_admin boolean stays as-is; a follow-up project replaces it with proper roles. This project only avoids painting that corner and leaves it a hook (Cognito group membership exposed in the request context, consumed by nothing yet).
  • Applicant accounts. The questionnaire iframe is anonymous today and stays anonymous — no accounts, no sessions for applicants.
  • Changes to CIAM itself beyond what the integration needs: granting moonshot read access to the signing secret, registering the SNS subscription endpoint, and fixing the Terraform secret bug. No new CIAM features.
  • The rest of the user-data-deletion audit. Moonshot's other deletion findings (e.g. email-log tables) remain with that project (docs/user-data-deletion/); this project only provides the delivery hook that unblocks them.