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 ~}