From 54e4e57e9c54d38f8f9e2ee2fbb49f455736b5d9 Mon Sep 17 00:00:00 2001 From: Bennett Garcia Date: Tue, 4 Aug 2026 15:33:09 -0400 Subject: [PATCH] Add guarded CLA production data migration --- .github/workflows/deploy.yml | 2 +- .github/workflows/terraform-apply.yml | 4 +- api/test/security.test.js | 37 ++++ db/changelogs/cla-production-cutover.xml | 13 ++ .../v1_7_cla_organization_backfill.sql | 173 ++++++++++++++++ docs/runbooks/cla-production-migration.md | 119 +++++++++++ scripts/ci/api-smoke.sh | 4 +- scripts/deploy/bootstrap-admin.js | 76 +++++-- scripts/deploy/finalize-legacy-accounts.js | 194 ++++++++++++++++++ scripts/deploy/remote-deploy.sh | 19 +- terraform/README.md | 8 + terraform/envs/prod/app_stack.tf | 38 ++-- terraform/envs/prod/outputs.tf | 7 +- terraform/envs/prod/variables.tf | 19 ++ terraform/modules/api_backend/main.tf | 6 + terraform/modules/api_backend/variables.tf | 41 ++++ terraform/templates/env.tmpl | 6 + 17 files changed, 725 insertions(+), 41 deletions(-) create mode 100644 db/changelogs/cla-production-cutover.xml create mode 100644 db/changelogs/v1_7_cla_organization_backfill.sql create mode 100644 docs/runbooks/cla-production-migration.md create mode 100644 scripts/deploy/finalize-legacy-accounts.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 15e2a1e..0fddb82 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -135,7 +135,7 @@ jobs: mkdir -p "$STAGE_DIR/deploy" "$STAGE_DIR/db" rsync -a --exclude node_modules api "$STAGE_DIR/" cp -a db/changelogs "$STAGE_DIR/db/" - cp scripts/deploy/remote-deploy.sh scripts/deploy/bootstrap-admin.js "$STAGE_DIR/deploy/" + cp scripts/deploy/remote-deploy.sh scripts/deploy/bootstrap-admin.js scripts/deploy/finalize-legacy-accounts.js "$STAGE_DIR/deploy/" echo "$GITHUB_SHA" > "$STAGE_DIR/REVISION" tar -czf api-release.tar.gz -C "$STAGE_DIR" . diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index 96c5291..6e34d7c 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -15,7 +15,9 @@ permissions: contents: read concurrency: - group: terraform-apply-${{ inputs.environment }} + # Production infrastructure/config updates serialize with production API + # releases so deploy cannot read partially updated IAM or runtime config. + group: ${{ inputs.environment == 'prod' && 'api-release-production' || 'terraform-apply-staging' }} cancel-in-progress: false jobs: diff --git a/api/test/security.test.js b/api/test/security.test.js index 32b3b99..d380d3c 100644 --- a/api/test/security.test.js +++ b/api/test/security.test.js @@ -1689,6 +1689,43 @@ test('remaining IAM migration adds audit, invite/reset, and non-destructive surv assert.doesNotMatch(remaining, /\bDROP\b|\bTRUNCATE\b|\bDELETE\s+FROM\b|ALTER\s+TABLE[\s\S]+DROP\s+COLUMN/i); }); +test('CLA organization migration preserves survey data and enforces stable child relationships', () => { + const changelog = fs.readFileSync(path.join(__dirname, '../../db/changelogs/master-changelog.xml'), 'utf8'); + const cutoverChangelog = fs.readFileSync(path.join(__dirname, '../../db/changelogs/cla-production-cutover.xml'), 'utf8'); + const migration = fs.readFileSync(path.join(__dirname, '../../db/changelogs/v1_7_cla_organization_backfill.sql'), 'utf8'); + const bootstrap = fs.readFileSync(path.join(__dirname, '../../scripts/deploy/bootstrap-admin.js'), 'utf8'); + const cleanup = fs.readFileSync(path.join(__dirname, '../../scripts/deploy/finalize-legacy-accounts.js'), 'utf8'); + + assert.doesNotMatch(changelog, /v1_7_cla_organization_backfill\.sql/); + assert.match(cutoverChangelog, /master-changelog\.xml/); + assert.match(cutoverChangelog, /v1_7_cla_organization_backfill\.sql/); + assert.match(migration, /VALUES \('CLA', 'cla'\)/); + assert.match(migration, /WHERE r\.survey_id IS NULL/); + assert.match(migration, /WHERE e\.survey_id IS NULL/); + assert.match(migration, /organization_id IS DISTINCT FROM/); + assert.match(migration, /Respondent contains null, orphaned, or disagreeing survey relationships/); + assert.match(migration, /EMAIL contains null, orphaned, or disagreeing survey relationships/); + assert.match(migration, /FOREIGN KEY \(survey_id\) REFERENCES Survey\(id\) NOT VALID/i); + assert.match(migration, /ALTER TABLE Respondent VALIDATE CONSTRAINT respondent_survey_id_fkey/i); + assert.match(migration, /ALTER TABLE EMAIL VALIDATE CONSTRAINT email_survey_id_fkey/i); + assert.doesNotMatch(migration, /UPDATE\s+Respondent[\s\S]+\b(response|uuid|respondent_id|email_sent)\s*=/i); + assert.doesNotMatch(migration, /UPDATE\s+EMAIL[\s\S]+\b(text|invitation_subject)\s*=/i); + + assert.match(bootstrap, /BOOTSTRAP_ORGANIZATION_SLUG/); + assert.match(bootstrap, /BOOTSTRAP_PLATFORM_ADMIN/); + assert.match(bootstrap, /create-or-verify/); + assert.match(bootstrap, /bcrypt\.compare/); + assert.match(bootstrap, /created_by_user_id/); + assert.match(cleanup, /CLA owner-only access is not active and validated/); + assert.match(cleanup, /CLEANUP_MODE/); + assert.match(cleanup, /CONFIRM_FINAL_SNAPSHOT_ID/); + assert.match(cleanup, /EXPECTED_LEGACY_USER_IDS/); + assert.match(cleanup, /last_login_at/); + assert.match(cleanup, /SET status = 'disabled', is_platform_admin = false/); + assert.match(cleanup, /DELETE FROM sessions/); + assert.doesNotMatch(cleanup, /DELETE FROM users/); +}); + test('password reset request stores only token hash and returns raw token only with explicit manual-delivery flag', async (t) => { const originalQuery = pool.query; const originalReturnDevTokens = process.env.RETURN_DEV_TOKENS; diff --git a/db/changelogs/cla-production-cutover.xml b/db/changelogs/cla-production-cutover.xml new file mode 100644 index 0000000..ea90e8a --- /dev/null +++ b/db/changelogs/cla-production-cutover.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/db/changelogs/v1_7_cla_organization_backfill.sql b/db/changelogs/v1_7_cla_organization_backfill.sql new file mode 100644 index 0000000..4109da1 --- /dev/null +++ b/db/changelogs/v1_7_cla_organization_backfill.sql @@ -0,0 +1,173 @@ +--liquibase formatted sql + +--changeset cladvisors:cla-organization-backfill-1 splitStatements:false +--comment Reconcile legacy survey relationships and place the complete survey data space under the CLA organization without changing survey, respondent, token, response, or template identities. + +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '10min'; +LOCK TABLE Survey, Respondent, EMAIL, audit_events, organization_memberships IN SHARE ROW EXCLUSIVE MODE; + +-- Create the explicitly approved production organization. Reusing the slug on a +-- partially applied environment preserves its UUID and all references. +INSERT INTO organizations (name, slug) +VALUES ('CLA', 'cla') +ON CONFLICT (slug) DO UPDATE +SET name = EXCLUDED.name, + updated_at = CURRENT_TIMESTAMP, + archived_at = NULL; + +-- v1_2 normally supplies IDs. This defensive pass only assigns identities where +-- none exist; existing IDs are never rewritten. +UPDATE Survey +SET id = gen_random_uuid() +WHERE id IS NULL; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM Survey) THEN + RAISE EXCEPTION 'CLA backfill refused: no surveys found in the target database'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM Respondent r + LEFT JOIN Survey by_name ON by_name.name = r.survey_name + LEFT JOIN Survey by_id ON by_id.id = r.survey_id + WHERE r.survey_name IS NULL + OR by_name.id IS NULL + OR (r.survey_id IS NOT NULL AND by_id.id IS NULL) + OR (r.survey_id IS NOT NULL AND by_id.id <> by_name.id) + ) THEN + RAISE EXCEPTION 'CLA backfill refused: Respondent contains null, orphaned, or disagreeing survey relationships'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM EMAIL e + LEFT JOIN Survey by_name ON by_name.name = e.survey_name + LEFT JOIN Survey by_id ON by_id.id = e.survey_id + WHERE e.survey_name IS NULL + OR by_name.id IS NULL + OR (e.survey_id IS NOT NULL AND by_id.id IS NULL) + OR (e.survey_id IS NOT NULL AND by_id.id <> by_name.id) + ) THEN + RAISE EXCEPTION 'CLA backfill refused: EMAIL contains null, orphaned, or disagreeing survey relationships'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM Survey + WHERE archived_at IS NULL AND slug IS NOT NULL + GROUP BY slug + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'CLA backfill refused: active survey slugs would collide in the CLA organization'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM audit_events ae + LEFT JOIN Survey s ON s.id = ae.survey_id + WHERE ae.survey_id IS NOT NULL AND s.id IS NULL + ) THEN + RAISE EXCEPTION 'CLA backfill refused: audit event contains an orphaned survey_id'; + END IF; +END $$; + +-- Fill only missing stable child references from the still-globally-unique +-- legacy name relationship. Responses, respondent IDs/tokens, email state, and +-- invitation contents are untouched. +UPDATE Respondent r +SET survey_id = s.id +FROM Survey s +WHERE r.survey_id IS NULL + AND r.survey_name = s.name; + +UPDATE EMAIL e +SET survey_id = s.id +FROM Survey s +WHERE e.survey_id IS NULL + AND e.survey_name = s.name; + +-- Include active, archived, and demo surveys. Legacy creator fields deliberately +-- remain unchanged/null because the old schema did not record ownership. +UPDATE Survey +SET organization_id = (SELECT id FROM organizations WHERE slug = 'cla') +WHERE organization_id IS DISTINCT FROM (SELECT id FROM organizations WHERE slug = 'cla'); + +-- Maintain access between the committed data move and deploy-time creation of the +-- explicitly approved CLA owner. These transitional memberships are removed only +-- by the separately gated post-login cleanup. +INSERT INTO organization_memberships (organization_id, user_id, role) +SELECT o.id, u.id, 'owner' +FROM organizations o +CROSS JOIN users u +WHERE o.slug = 'cla' +ON CONFLICT (organization_id, user_id) DO NOTHING; + +-- Keep survey-scoped audit records internally consistent without changing their +-- IDs, actors, event types, metadata, or timestamps. +UPDATE audit_events ae +SET organization_id = s.organization_id +FROM Survey s +WHERE ae.survey_id = s.id + AND ae.organization_id IS DISTINCT FROM s.organization_id; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM Survey s + CROSS JOIN organizations o + WHERE o.slug = 'cla' + AND (s.id IS NULL OR s.organization_id IS DISTINCT FROM o.id) + ) THEN + RAISE EXCEPTION 'CLA backfill refused: Survey stable identity or CLA organization assignment is incomplete'; + END IF; + IF EXISTS ( + SELECT 1 FROM Respondent r + LEFT JOIN Survey s ON s.id = r.survey_id + WHERE r.survey_id IS NULL OR s.id IS NULL OR r.survey_name IS DISTINCT FROM s.name + ) THEN + RAISE EXCEPTION 'CLA backfill refused: Respondent stable and legacy survey relationships do not reconcile'; + END IF; + IF EXISTS ( + SELECT 1 FROM EMAIL e + LEFT JOIN Survey s ON s.id = e.survey_id + WHERE e.survey_id IS NULL OR s.id IS NULL OR e.survey_name IS DISTINCT FROM s.name + ) THEN + RAISE EXCEPTION 'CLA backfill refused: EMAIL stable and legacy survey relationships do not reconcile'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'survey_id_key' AND conrelid = 'survey'::regclass + ) THEN + ALTER TABLE Survey ADD CONSTRAINT survey_id_key UNIQUE (id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'respondent_survey_id_fkey' AND conrelid = 'respondent'::regclass + ) THEN + ALTER TABLE Respondent + ADD CONSTRAINT respondent_survey_id_fkey + FOREIGN KEY (survey_id) REFERENCES Survey(id) NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'email_survey_id_fkey' AND conrelid = 'email'::regclass + ) THEN + ALTER TABLE EMAIL + ADD CONSTRAINT email_survey_id_fkey + FOREIGN KEY (survey_id) REFERENCES Survey(id) NOT VALID; + END IF; +END $$; + +ALTER TABLE Survey ALTER COLUMN id SET NOT NULL; +ALTER TABLE Survey ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE Respondent ALTER COLUMN survey_id SET NOT NULL; +ALTER TABLE EMAIL ALTER COLUMN survey_id SET NOT NULL; + +ALTER TABLE Respondent VALIDATE CONSTRAINT respondent_survey_id_fkey; +ALTER TABLE EMAIL VALIDATE CONSTRAINT email_survey_id_fkey; diff --git a/docs/runbooks/cla-production-migration.md b/docs/runbooks/cla-production-migration.md new file mode 100644 index 0000000..bbcc278 --- /dev/null +++ b/docs/runbooks/cla-production-migration.md @@ -0,0 +1,119 @@ +# CLA production survey-data migration runbook + +Target application baseline: `a379f101aa4a722d6fc6fab7cdbb092547b0c717` + +Recorded pre-cutover API release: `c22e3031787c2403b5e9174bdd9a71385c481dde` + +This cutover moves the complete legacy survey data space into organization `CLA` +(slug `cla`) while preserving survey/respondent identities, bearer tokens, +responses, invitation bodies/subjects, archive state, and email-send state. +Legacy dashboard users are retained as disabled rows after cutover but lose +memberships and active sessions. + +## Recorded production preflight + +- RDS: `network-survey-prod-postgres-v2`, PostgreSQL 15.18, encrypted and deletion-protected +- Final snapshot: `network-survey-prod-postgres-v2-pre-cla-20260804191658` +- Snapshot status at creation: `available` +- Database: `ONA` +- Surveys: 11 +- Respondents: 113 +- Stored responses: 21 +- Respondents marked emailed: 80 +- Email templates: 111 +- Legacy users: IDs `1,2` (`Admin`, `Admin1`) +- Sessions: one for each legacy user at preflight +- Null survey names/tokens: 0 +- Duplicate non-null respondent tokens: 0 +- Legacy respondent/email orphans: 0 +- Pre-migration digests: + - Survey: `ad95ee6ecd6e4d201dcb69f2d7ede646` + - Respondent/response: `4f68353e26d6cc383b3680cb03a31ad4` + - Email: `40ca155d5df7c78dc1ef2b005318ad99` + +The survey digest is expected to change because v1.4–v1.6 add/backfill survey +slugs/display names, materialize SurveyJS `isRequired:false`, and add invitation +subjects. Respondent/response payloads and legacy email bodies must reconcile. + +## Execution order + +1. Confirm application downtime and no active database writers. +2. Confirm the final snapshot above is still `available`. +3. Confirm `/network-survey/prod/api/bootstrap-admin-password` exists as a + SecureString and the production API instance can decrypt it without printing it. +4. Apply the reviewed production Terraform plan with + `enable_cla_production_cutover=true` and `enable_cla_owner_bootstrap=true`. + It must contain only in-place runtime IAM policy and config-object changes; no + destroys or replacements. This one-time apply was completed before cutover. +5. Deploy the reviewed release with `CLA_PRODUCTION_CUTOVER=true`. This selects + `cla-production-cutover.xml`; the universal local/CI/staging master changelog + cannot execute `v1_7`. Liquibase aborts on null/orphaned/disagreeing child + relationships, active slug collisions, orphaned audit survey IDs, or an empty + survey set. Bootstrap `create-or-verify` mode is retry-safe: it creates the + approved owner once and subsequently requires exact identity and credential. +6. Confirm external API health, then authenticate as the new CLA owner and verify + survey listing/results. Login updates `users.last_login_at` and is required by + cleanup. +7. Run `finalize-legacy-accounts.js` in `dry-run` mode with the exact snapshot ID, + counts, and legacy user IDs above. Review output. +8. Repeat in `apply` mode with `CONFIRM_FINAL_SNAPSHOT_ID` exactly matching the + recorded snapshot. +9. Run post-migration reconciliation and respondent-link smoke tests. +10. Remove the one-time production bootstrap config/IAM access and rotate/delete + the bootstrap SecureString after the owner password is rotated. + +## Cleanup invocation environment + +Run on the production API instance from the active release with DB variables loaded +from runtime config. Required non-secret controls: + +```text +CLA_OWNER_USERNAME=sgarcia@cladvisors.com +CLA_ORGANIZATION_SLUG=cla +EXPECTED_DB_NAME=ONA +EXPECTED_SURVEY_COUNT=11 +EXPECTED_RESPONDENT_COUNT=113 +EXPECTED_EMAIL_COUNT=111 +EXPECTED_LEGACY_USER_IDS=1,2 +FINAL_SNAPSHOT_ID=network-survey-prod-postgres-v2-pre-cla-20260804191658 +CLA_CUTOVER_STARTED_AT= +CLEANUP_MODE=dry-run|apply +CONFIRM_FINAL_SNAPSHOT_ID= +``` + +Never place the owner password or decrypted database password in this runbook, +command logs, Terraform variables, or repository files. + +## Post-migration acceptance + +- Exactly 11 surveys belong to CLA, including archived/demo surveys. +- Exactly 113 respondents and 21 non-null responses remain. +- Exactly 80 respondents remain marked emailed. +- Exactly 111 templates have stable survey IDs and non-null invitation subjects. +- No null/orphaned/disagreeing respondent or email survey relationships exist. +- Respondent IDs, tokens, response JSON, contact data, and legacy email bodies are unchanged. +- `sgarcia@cladvisors.com` is active CLA owner and not platform admin. +- Legacy users 1 and 2 are disabled, have no memberships, and have no sessions. + +## Rollback + +Application artifact rollback does not reverse this data migration. If validation +fails after commit: + +1. Restore snapshot `network-survey-prod-postgres-v2-pre-cla-20260804191658` to a + unique RDS identifier in subnet group `db-subnet-group`, attaching security + group `sg-00d61e181de4cfb48`, with public access disabled. +2. Wait for `available`, record the restored endpoint, and verify TLS/connectivity + from production instance `i-065f1e1f497ab1481`. +3. Set Terraform variable `api_config_db_host_override` to that endpoint; leave + `enable_cla_production_cutover=false` and `enable_cla_owner_bootstrap=false`. + Apply only the reviewed runtime config/IAM changes. +4. Redeploy recorded pre-cutover artifact + `c22e3031787c2403b5e9174bdd9a71385c481dde`; its historical changelog must not + execute the CLA cutover. +5. Verify health plus the recorded 11/113/111 counts and pre-migration digests. +6. Preserve the migrated database for forensic comparison. To return, clear + `api_config_db_host_override`, review the plan, apply, and redeploy the intended + migrated artifact. + +Do not overwrite either database. diff --git a/scripts/ci/api-smoke.sh b/scripts/ci/api-smoke.sh index b6a2f9a..084508b 100644 --- a/scripts/ci/api-smoke.sh +++ b/scripts/ci/api-smoke.sh @@ -88,7 +88,7 @@ curl -fsS -b "$COOKIES" "$BASE/api/check-auth" | grep -q '"isAuthenticated":true echo "==> Authenticated survey CRUD" curl -fsS -b "$COOKIES" -X POST "$BASE/api/survey" \ -H 'Content-Type: application/json' \ - -d '{"surveyName":"ci-smoke-survey"}' >/dev/null -curl -fsS -b "$COOKIES" "$BASE/api/surveys" | grep -q 'ci-smoke-survey' + -d '{"surveyName":"CISmokeSurvey"}' >/dev/null +curl -fsS -b "$COOKIES" "$BASE/api/surveys" | grep -q 'CISmokeSurvey' echo "==> Smoke test passed" diff --git a/scripts/deploy/bootstrap-admin.js b/scripts/deploy/bootstrap-admin.js index 86a69de..a484941 100644 --- a/scripts/deploy/bootstrap-admin.js +++ b/scripts/deploy/bootstrap-admin.js @@ -6,15 +6,24 @@ const fs = require('fs'); const path = require('path'); -const apiDir = path.join(__dirname, '..', 'api'); +const releaseApiDir = path.join(__dirname, '..', 'api'); +const apiDir = fs.existsSync(releaseApiDir) ? releaseApiDir : path.join(__dirname, '..', '..', 'api'); const { Client } = require(path.join(apiDir, 'node_modules', 'pg')); const bcrypt = require(path.join(apiDir, 'node_modules', 'bcrypt')); const username = String(process.env.BOOTSTRAP_ADMIN_USERNAME || '').trim(); const password = String(process.env.BOOTSTRAP_ADMIN_PASSWORD || ''); +const email = String(process.env.BOOTSTRAP_ADMIN_EMAIL || '').trim() || null; +const organizationName = String(process.env.BOOTSTRAP_ORGANIZATION_NAME || 'Default / Imported').trim(); +const organizationSlug = String(process.env.BOOTSTRAP_ORGANIZATION_SLUG || 'default-imported').trim(); +const isPlatformAdmin = String(process.env.BOOTSTRAP_PLATFORM_ADMIN || 'true').toLowerCase() === 'true'; +const accountMode = String(process.env.BOOTSTRAP_ACCOUNT_MODE || 'ensure').trim().toLowerCase(); if (!username) throw new Error('BOOTSTRAP_ADMIN_USERNAME is required.'); if (password.length < 12) throw new Error('BOOTSTRAP_ADMIN_PASSWORD must be at least 12 characters.'); +if (!organizationName) throw new Error('BOOTSTRAP_ORGANIZATION_NAME is required.'); +if (!organizationSlug) throw new Error('BOOTSTRAP_ORGANIZATION_SLUG is required.'); +if (!['ensure', 'create-or-verify'].includes(accountMode)) throw new Error('BOOTSTRAP_ACCOUNT_MODE must be ensure or create-or-verify.'); const client = new Client({ host: process.env.DB_HOST, @@ -34,24 +43,61 @@ async function main() { await client.connect(); try { await client.query('BEGIN'); - const passwordHash = await bcrypt.hash(password, 12); - const userResult = await client.query( - `INSERT INTO users (username, password, status, is_platform_admin) - VALUES ($1, $2, 'active', true) - ON CONFLICT (username) DO UPDATE - SET status = 'active', is_platform_admin = true - RETURNING id`, - [username, passwordHash] - ); + let userResult; + if (accountMode === 'create-or-verify') { + const existing = await client.query( + `SELECT id, username, password, email, status, is_platform_admin + FROM users + WHERE username = $1 OR ($2::citext IS NOT NULL AND email = $2::citext) + FOR UPDATE`, + [username, email] + ); + if (existing.rowCount > 1) { + throw new Error('Bootstrap owner username and email resolve to different users.'); + } + if (existing.rowCount === 1) { + const user = existing.rows[0]; + const exactIdentity = user.username === username + && String(user.email || '').toLowerCase() === String(email || '').toLowerCase() + && user.status === 'active' + && Boolean(user.is_platform_admin) === isPlatformAdmin; + if (!exactIdentity || !await bcrypt.compare(password, user.password)) { + throw new Error('Existing bootstrap owner does not match the approved identity, role scope, and credential.'); + } + userResult = { rows: [{ id: user.id }] }; + } else { + const passwordHash = await bcrypt.hash(password, 12); + userResult = await client.query( + `INSERT INTO users (username, password, email, display_name, status, is_platform_admin) + VALUES ($1::varchar, $2, $3, $1::text, 'active', $4) + RETURNING id`, + [username, passwordHash, email, isPlatformAdmin] + ); + } + } else { + const passwordHash = await bcrypt.hash(password, 12); + userResult = await client.query( + `INSERT INTO users (username, password, email, display_name, status, is_platform_admin) + VALUES ($1::varchar, $2, $3, $1::text, 'active', $4) + ON CONFLICT (username) DO UPDATE + SET email = COALESCE(EXCLUDED.email, users.email), + status = 'active', + is_platform_admin = EXCLUDED.is_platform_admin + RETURNING id`, + [username, passwordHash, email, isPlatformAdmin] + ); + } const organizationResult = await client.query( `INSERT INTO organizations (name, slug) - VALUES ('Default / Imported', 'default-imported') - ON CONFLICT (slug) DO UPDATE SET updated_at = CURRENT_TIMESTAMP - RETURNING id` + VALUES ($1, $2) + ON CONFLICT (slug) DO UPDATE + SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP, archived_at = NULL + RETURNING id`, + [organizationName, organizationSlug] ); await client.query( - `INSERT INTO organization_memberships (organization_id, user_id, role) - VALUES ($1, $2, 'owner') + `INSERT INTO organization_memberships (organization_id, user_id, role, created_by_user_id) + VALUES ($1, $2, 'owner', $2) ON CONFLICT (organization_id, user_id) DO UPDATE SET role = 'owner'`, [organizationResult.rows[0].id, userResult.rows[0].id] ); diff --git a/scripts/deploy/finalize-legacy-accounts.js b/scripts/deploy/finalize-legacy-accounts.js new file mode 100644 index 0000000..f36cfc9 --- /dev/null +++ b/scripts/deploy/finalize-legacy-accounts.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/* + * Post-cutover account cleanup. Run in dry-run mode first and apply only after a + * successful CLA-owner login plus exact preflight/snapshot confirmation. Legacy + * users are retained for referential history, but disabled, detached, and logged out. + */ +const fs = require('fs'); +const path = require('path'); + +const releaseApiDir = path.join(__dirname, '..', 'api'); +const apiDir = fs.existsSync(releaseApiDir) ? releaseApiDir : path.join(__dirname, '..', '..', 'api'); +const { Client } = require(path.join(apiDir, 'node_modules', 'pg')); + +const required = (name) => { + const value = String(process.env[name] || '').trim(); + if (!value) throw new Error(`${name} is required.`); + return value; +}; +const expectedInteger = (name) => { + const value = required(name); + if (!/^\d+$/.test(value)) throw new Error(`${name} must be a non-negative integer.`); + return Number(value); +}; + +const ownerUsername = required('CLA_OWNER_USERNAME'); +const organizationSlug = String(process.env.CLA_ORGANIZATION_SLUG || 'cla').trim(); +const expectedDatabase = required('EXPECTED_DB_NAME'); +const expectedSurveyCount = expectedInteger('EXPECTED_SURVEY_COUNT'); +const expectedRespondentCount = expectedInteger('EXPECTED_RESPONDENT_COUNT'); +const expectedEmailCount = expectedInteger('EXPECTED_EMAIL_COUNT'); +const expectedLegacyUserIds = required('EXPECTED_LEGACY_USER_IDS') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => { + if (!/^\d+$/.test(value)) throw new Error('EXPECTED_LEGACY_USER_IDS must be comma-separated integer IDs.'); + return Number(value); + }) + .sort((a, b) => a - b); +const snapshotId = required('FINAL_SNAPSHOT_ID'); +const cutoverStartedAt = new Date(required('CLA_CUTOVER_STARTED_AT')); +const cleanupMode = String(process.env.CLEANUP_MODE || 'dry-run').trim().toLowerCase(); + +if (!organizationSlug) throw new Error('CLA_ORGANIZATION_SLUG is required.'); +if (expectedSurveyCount < 1) throw new Error('EXPECTED_SURVEY_COUNT must be at least 1.'); +if (Number.isNaN(cutoverStartedAt.getTime())) throw new Error('CLA_CUTOVER_STARTED_AT must be an ISO timestamp.'); +if (!['dry-run', 'apply'].includes(cleanupMode)) throw new Error('CLEANUP_MODE must be dry-run or apply.'); +if (cleanupMode === 'apply' && process.env.CONFIRM_FINAL_SNAPSHOT_ID !== snapshotId) { + throw new Error('CONFIRM_FINAL_SNAPSHOT_ID must exactly match FINAL_SNAPSHOT_ID in apply mode.'); +} + +const client = new Client({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'ONA', + ssl: process.env.DB_SSL === 'true' + ? { + ca: process.env.DB_SSL_CA ? fs.readFileSync(process.env.DB_SSL_CA, 'utf8') : undefined, + rejectUnauthorized: Boolean(process.env.DB_SSL_CA), + } + : undefined, +}); + +const sameIds = (actual, expected) => ( + actual.length === expected.length && actual.every((value, index) => value === expected[index]) +); + +async function main() { + await client.connect(); + try { + await client.query('BEGIN'); + const database = await client.query('SELECT current_database() AS name'); + if (database.rows[0]?.name !== expectedDatabase) { + throw new Error(`Connected database ${database.rows[0]?.name || ''} does not match ${expectedDatabase}.`); + } + + const access = await client.query( + `SELECT u.id AS user_id, u.status, u.is_platform_admin, u.last_login_at, + o.id AS organization_id, om.role + FROM users u + JOIN organization_memberships om ON om.user_id = u.id + JOIN organizations o ON o.id = om.organization_id + WHERE u.username = $1 AND o.slug = $2 + FOR UPDATE OF u, om, o`, + [ownerUsername, organizationSlug] + ); + const owner = access.rows[0]; + if (!owner || owner.status !== 'active' || owner.role !== 'owner' || owner.is_platform_admin) { + throw new Error('CLA owner-only access is not active and validated; refusing legacy account cleanup.'); + } + if (!owner.last_login_at || new Date(owner.last_login_at) < cutoverStartedAt) { + throw new Error('CLA owner has not completed a successful login since cutover began.'); + } + + const integrity = await client.query( + `SELECT + COUNT(*)::int AS survey_count, + COUNT(*) FILTER (WHERE s.organization_id IS DISTINCT FROM $1)::int AS wrong_org_surveys, + (SELECT COUNT(*)::int FROM Respondent r) AS respondent_count, + (SELECT COUNT(*)::int FROM EMAIL e) AS email_count, + (SELECT COUNT(*)::int FROM Respondent r + LEFT JOIN Survey rs ON rs.id = r.survey_id + WHERE r.survey_id IS NULL OR rs.id IS NULL OR r.survey_name IS DISTINCT FROM rs.name) AS respondent_gaps, + (SELECT COUNT(*)::int FROM EMAIL e + LEFT JOIN Survey es ON es.id = e.survey_id + WHERE e.survey_id IS NULL OR es.id IS NULL OR e.survey_name IS DISTINCT FROM es.name) AS email_gaps + FROM Survey s`, + [owner.organization_id] + ); + const counts = integrity.rows[0]; + if ( + counts.survey_count !== expectedSurveyCount + || counts.respondent_count !== expectedRespondentCount + || counts.email_count !== expectedEmailCount + || counts.wrong_org_surveys + || counts.respondent_gaps + || counts.email_gaps + ) { + throw new Error(`Survey reconciliation/count manifest failed; refusing cleanup: ${JSON.stringify(counts)}`); + } + + const legacyUsers = await client.query( + `SELECT id, username, status + FROM users + WHERE id <> $1 + ORDER BY id + FOR UPDATE`, + [owner.user_id] + ); + const actualLegacyIds = legacyUsers.rows.map(({ id }) => Number(id)); + if (!sameIds(actualLegacyIds, expectedLegacyUserIds)) { + throw new Error(`Legacy user manifest mismatch; expected ${expectedLegacyUserIds.join(',')}, found ${actualLegacyIds.join(',')}.`); + } + + const summary = { + mode: cleanupMode, + database: expectedDatabase, + finalSnapshotId: snapshotId, + ownerUserId: owner.user_id, + organizationId: owner.organization_id, + counts, + legacyUsers: legacyUsers.rows, + }; + console.log(JSON.stringify(summary, null, 2)); + + if (cleanupMode === 'dry-run') { + await client.query('ROLLBACK'); + console.log('Dry run complete; no account, membership, or session changes were committed.'); + return; + } + + await client.query( + 'DELETE FROM organization_memberships WHERE user_id = ANY($1::int[])', + [expectedLegacyUserIds] + ); + await client.query( + `DELETE FROM organization_memberships + WHERE user_id = $1 AND organization_id <> $2`, + [owner.user_id, owner.organization_id] + ); + await client.query( + `UPDATE users + SET status = 'disabled', is_platform_admin = false + WHERE id = ANY($1::int[])`, + [expectedLegacyUserIds] + ); + await client.query( + `UPDATE users + SET status = 'active', is_platform_admin = false, email = COALESCE(email, username::citext) + WHERE id = $1`, + [owner.user_id] + ); + await client.query( + `DELETE FROM sessions + WHERE COALESCE(sess->>'userId', '') = ANY($1::text[])`, + [expectedLegacyUserIds.map(String)] + ); + + await client.query('COMMIT'); + console.log('Legacy dashboard accounts disabled, detached, and logged out. CLA owner retained.'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + await client.end(); + } +} + +main().catch((error) => { + console.error(`Legacy account cleanup failed: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/deploy/remote-deploy.sh b/scripts/deploy/remote-deploy.sh index ecefe24..c7f0114 100755 --- a/scripts/deploy/remote-deploy.sh +++ b/scripts/deploy/remote-deploy.sh @@ -93,15 +93,19 @@ DB_PORT=$(read_runtime_env DB_PORT) DB_NAME=$(read_runtime_env DB_NAME) DB_USER=$(read_runtime_env DB_USER) DB_PASSWORD=$(read_runtime_env DB_PASSWORD) -# changeLogFile must stay "changelogs/..." — that path is the changeset -# identity recorded in DATABASECHANGELOG by all prior runs (local dev and the -# old liquibase-prod.sh both ran from db/). Changing it would make Liquibase -# re-run every migration. +# changeLogFile must stay under "changelogs/..." — included changeset paths are +# the identities recorded in DATABASECHANGELOG by all prior runs. The dedicated +# cutover root is selected only by explicit production runtime configuration; +# local, CI, and staging use the universal non-data-moving master changelog. +CHANGELOG_FILE=changelogs/master-changelog.xml +if [ "$(get_env_value CLA_PRODUCTION_CUTOVER)" = "true" ]; then + CHANGELOG_FILE=changelogs/cla-production-cutover.xml +fi liquibase \ --url="jdbc:postgresql://$DB_HOST:$DB_PORT/${DB_NAME:-ONA}?sslmode=verify-full&sslrootcert=$SERVICE_DIR/certs/rds-global-bundle.pem" \ --username="$DB_USER" \ --password="$DB_PASSWORD" \ - --changeLogFile=changelogs/master-changelog.xml \ + --changeLogFile="$CHANGELOG_FILE" \ --searchPath="$RELEASE_DIR/db" \ update @@ -109,6 +113,11 @@ if [ -n "$(get_env_value BOOTSTRAP_ADMIN_PASSWORD_PARAMETER)" ]; then echo "==> Ensuring bootstrap dashboard administrator" BOOTSTRAP_ADMIN_PASSWORD=$(get_secret_from_ssm BOOTSTRAP_ADMIN_PASSWORD_PARAMETER) BOOTSTRAP_ADMIN_USERNAME=$(get_env_value BOOTSTRAP_ADMIN_USERNAME) \ + BOOTSTRAP_ADMIN_EMAIL=$(get_env_value BOOTSTRAP_ADMIN_EMAIL) \ + BOOTSTRAP_ORGANIZATION_NAME=$(get_env_value BOOTSTRAP_ORGANIZATION_NAME) \ + BOOTSTRAP_ORGANIZATION_SLUG=$(get_env_value BOOTSTRAP_ORGANIZATION_SLUG) \ + BOOTSTRAP_PLATFORM_ADMIN=$(get_env_value BOOTSTRAP_PLATFORM_ADMIN) \ + BOOTSTRAP_ACCOUNT_MODE=$(get_env_value BOOTSTRAP_ACCOUNT_MODE) \ BOOTSTRAP_ADMIN_PASSWORD="$BOOTSTRAP_ADMIN_PASSWORD" \ DB_HOST="$DB_HOST" DB_PORT="$DB_PORT" DB_NAME="$DB_NAME" DB_USER="$DB_USER" DB_PASSWORD="$DB_PASSWORD" \ DB_SSL=true DB_SSL_CA="$SERVICE_DIR/certs/rds-global-bundle.pem" \ diff --git a/terraform/README.md b/terraform/README.md index 931434a..a8ab2c3 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -36,6 +36,7 @@ API runtime secrets are stored in SSM Parameter Store SecureString values, e.g.: /network-survey/prod/db/password /network-survey/prod/api/session-secret /network-survey/prod/api/resend-api-key +/network-survey/prod/api/bootstrap-admin-password ``` Never commit secret values or local `*.local.tfvars` files. @@ -56,6 +57,13 @@ first created; later deploys retain its existing password while re-ensuring its access. Rotate/reset the account through an approved operator recovery process, not by editing tracked configuration. +Production CLA cutover temporarily configures the equivalent production +SecureString to create the approved CLA owner in retry-safe `create-or-verify` +mode. Production bootstrapping grants organization owner only, not +platform-administrator access. +After first-login validation and legacy-account cleanup, remove the bootstrap +runtime configuration/IAM permission and delete or rotate the one-time parameter. + ## Current apply commands ```sh diff --git a/terraform/envs/prod/app_stack.tf b/terraform/envs/prod/app_stack.tf index c9e1762..012a55c 100644 --- a/terraform/envs/prod/app_stack.tf +++ b/terraform/envs/prod/app_stack.tf @@ -23,14 +23,16 @@ locals { app_name_prefix = "${var.project_name}-${var.environment}-v2" # Runtime secrets intentionally keep the existing production Parameter Store paths. - ssm_parameter_prefix = "/network-survey/${var.environment}" - db_password_parameter_name = "${local.ssm_parameter_prefix}/db/password" - session_secret_parameter_name = "${local.ssm_parameter_prefix}/api/session-secret" - resend_api_key_parameter_name = "${local.ssm_parameter_prefix}/api/resend-api-key" + ssm_parameter_prefix = "/network-survey/${var.environment}" + db_password_parameter_name = "${local.ssm_parameter_prefix}/db/password" + session_secret_parameter_name = "${local.ssm_parameter_prefix}/api/session-secret" + resend_api_key_parameter_name = "${local.ssm_parameter_prefix}/api/resend-api-key" + bootstrap_admin_password_parameter_name = "${local.ssm_parameter_prefix}/api/bootstrap-admin-password" frontend_url = "https://${var.dashboard_domain}" survey_url = "https://${var.survey_domain}" session_cookie_name = "sessionId" + api_config_db_host = coalesce(var.api_config_db_host_override, aws_db_instance.prod_replacement.address) app_common_tags = merge(local.prod_app_tags, { # Use a distinct discovery environment until legacy prod resources are retired. @@ -98,16 +100,24 @@ module "api_backend" { cloud_init_template_path = "${path.module}/../../cloud-init-template.sh" env_template_path = "${path.module}/../../templates/env.tmpl" - db_host = aws_db_instance.prod_replacement.address - db_port = aws_db_instance.prod_replacement.port - db_name = aws_db_instance.prod_replacement.db_name - db_user = var.db_user - db_password_parameter_name = local.db_password_parameter_name - session_secret_parameter_name = local.session_secret_parameter_name - resend_api_key_parameter_name = local.resend_api_key_parameter_name - frontend_url = local.frontend_url - survey_url = local.survey_url - session_cookie_name = local.session_cookie_name + db_host = local.api_config_db_host + db_port = aws_db_instance.prod_replacement.port + db_name = aws_db_instance.prod_replacement.db_name + db_user = var.db_user + db_password_parameter_name = local.db_password_parameter_name + session_secret_parameter_name = local.session_secret_parameter_name + resend_api_key_parameter_name = local.resend_api_key_parameter_name + bootstrap_admin_username = var.enable_cla_owner_bootstrap ? "sgarcia@cladvisors.com" : null + bootstrap_admin_email = var.enable_cla_owner_bootstrap ? "sgarcia@cladvisors.com" : null + bootstrap_admin_password_parameter_name = var.enable_cla_owner_bootstrap ? local.bootstrap_admin_password_parameter_name : null + bootstrap_organization_name = "CLA" + bootstrap_organization_slug = "cla" + bootstrap_platform_admin = false + bootstrap_account_mode = "create-or-verify" + cla_production_cutover = var.enable_cla_production_cutover + frontend_url = local.frontend_url + survey_url = local.survey_url + session_cookie_name = local.session_cookie_name common_tags = local.app_common_tags config_bucket_tags = merge(local.app_common_tags, { Name = "${local.app_name_prefix}-config", App = "ona-config" }) diff --git a/terraform/envs/prod/outputs.tf b/terraform/envs/prod/outputs.tf index e997316..ee5e702 100644 --- a/terraform/envs/prod/outputs.tf +++ b/terraform/envs/prod/outputs.tf @@ -97,9 +97,10 @@ output "survey_distribution_id" { output "runtime_secret_parameter_names" { value = { - db_password = local.db_password_parameter_name - session_secret = local.session_secret_parameter_name - resend_api_key = local.resend_api_key_parameter_name + db_password = local.db_password_parameter_name + session_secret = local.session_secret_parameter_name + resend_api_key = local.resend_api_key_parameter_name + bootstrap_admin_password = local.bootstrap_admin_password_parameter_name } description = "Existing production SSM Parameter Store paths reused by the replacement app runtime." } diff --git a/terraform/envs/prod/variables.tf b/terraform/envs/prod/variables.tf index b3a5fc6..233bbb1 100644 --- a/terraform/envs/prod/variables.tf +++ b/terraform/envs/prod/variables.tf @@ -48,6 +48,25 @@ variable "db_password" { sensitive = true } +variable "api_config_db_host_override" { + description = "Emergency runtime DB host override for snapshot-restore rollback. Null uses the Terraform-managed production RDS address." + type = string + default = null + nullable = true +} + +variable "enable_cla_production_cutover" { + description = "Explicitly select the one-time CLA production cutover changelog. Keep false for normal deploys and rollback." + type = bool + default = false +} + +variable "enable_cla_owner_bootstrap" { + description = "Temporarily expose the one-time CLA owner bootstrap config and secret permission to the API instance." + type = bool + default = false +} + variable "allocated_storage" { description = "Allocated storage in GB" default = 20 diff --git a/terraform/modules/api_backend/main.tf b/terraform/modules/api_backend/main.tf index 66d6aae..6cba97f 100644 --- a/terraform/modules/api_backend/main.tf +++ b/terraform/modules/api_backend/main.tf @@ -122,6 +122,12 @@ resource "aws_s3_object" "api_config" { resend_api_key_parameter_name = var.resend_api_key_parameter_name bootstrap_admin_username = var.bootstrap_admin_username bootstrap_admin_password_parameter_name = var.bootstrap_admin_password_parameter_name + bootstrap_admin_email = var.bootstrap_admin_email + bootstrap_organization_name = var.bootstrap_organization_name + bootstrap_organization_slug = var.bootstrap_organization_slug + bootstrap_platform_admin = var.bootstrap_platform_admin + bootstrap_account_mode = var.bootstrap_account_mode + cla_production_cutover = var.cla_production_cutover }) } diff --git a/terraform/modules/api_backend/variables.tf b/terraform/modules/api_backend/variables.tf index 791024f..44ef204 100644 --- a/terraform/modules/api_backend/variables.tf +++ b/terraform/modules/api_backend/variables.tf @@ -118,6 +118,47 @@ variable "bootstrap_admin_password_parameter_name" { default = null } +variable "bootstrap_admin_email" { + description = "Optional email for the deploy-time bootstrap administrator." + type = string + default = null +} + +variable "bootstrap_organization_name" { + description = "Organization name for the deploy-time bootstrap administrator." + type = string + default = "Default / Imported" +} + +variable "bootstrap_organization_slug" { + description = "Organization slug for the deploy-time bootstrap administrator." + type = string + default = "default-imported" +} + +variable "bootstrap_platform_admin" { + description = "Whether the deploy-time bootstrap administrator receives global platform-administrator access." + type = bool + default = true +} + +variable "bootstrap_account_mode" { + description = "Bootstrap account behavior: ensure preserves an existing password; create-or-verify is retry-safe and requires exact credentials/identity." + type = string + default = "ensure" + + validation { + condition = contains(["ensure", "create-or-verify"], var.bootstrap_account_mode) + error_message = "bootstrap_account_mode must be ensure or create-or-verify." + } +} + +variable "cla_production_cutover" { + description = "Select the one-time CLA production cutover changelog. Must remain false outside the reviewed production cutover." + type = bool + default = false +} + variable "frontend_url" { description = "Dashboard/frontend URL written to API runtime config." type = string diff --git a/terraform/templates/env.tmpl b/terraform/templates/env.tmpl index e96f59f..88dac30 100644 --- a/terraform/templates/env.tmpl +++ b/terraform/templates/env.tmpl @@ -10,7 +10,13 @@ SURVEY_URL=${survey_url} SESSION_SECRET_PARAMETER=${session_secret_parameter_name} SESSION_COOKIE_NAME=${session_cookie_name} RESEND_API_KEY_PARAMETER=${resend_api_key_parameter_name} +CLA_PRODUCTION_CUTOVER=${cla_production_cutover} %{ if bootstrap_admin_password_parameter_name != null ~} BOOTSTRAP_ADMIN_USERNAME=${bootstrap_admin_username} BOOTSTRAP_ADMIN_PASSWORD_PARAMETER=${bootstrap_admin_password_parameter_name} +BOOTSTRAP_ADMIN_EMAIL=${bootstrap_admin_email != null ? bootstrap_admin_email : ""} +BOOTSTRAP_ORGANIZATION_NAME=${bootstrap_organization_name} +BOOTSTRAP_ORGANIZATION_SLUG=${bootstrap_organization_slug} +BOOTSTRAP_PLATFORM_ADMIN=${bootstrap_platform_admin} +BOOTSTRAP_ACCOUNT_MODE=${bootstrap_account_mode} %{ endif ~}