PSA/ee/server/migrations/20260220143000_create_entra_phase1_schema.cjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

517 lines
19 KiB
JavaScript

/**
* Migration: Create Entra integration Phase 1 schema (EE)
*
* This migration is intentionally idempotent for shared/dev databases.
*/
const ensureTable = async (knex, tableName, createFn) => {
const exists = await knex.schema.hasTable(tableName);
if (!exists) {
await createFn();
}
};
const ensureColumn = async (knex, tableName, columnName, alterFn) => {
const exists = await knex.schema.hasColumn(tableName, columnName);
if (!exists) {
await knex.schema.alterTable(tableName, alterFn);
}
};
const ENTRA_DISTRIBUTED_TABLES = [
'entra_partner_connections',
'entra_managed_tenants',
'entra_client_tenant_mappings',
'entra_sync_settings',
'entra_sync_runs',
'entra_sync_run_tenants',
'entra_contact_links',
'entra_contact_reconciliation_queue',
];
const isCitusEnabled = async (knex) => {
const result = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_extension WHERE extname = 'citus'
) AS enabled
`);
return Boolean(result.rows?.[0]?.enabled);
};
const isTableDistributed = async (knex, tableName) => {
const result = await knex.raw(
`
SELECT EXISTS (
SELECT 1
FROM pg_dist_partition
WHERE logicalrelid = ?::regclass
) AS distributed
`,
[tableName]
);
return Boolean(result.rows?.[0]?.distributed);
};
const ensureDistributedTable = async (knex, tableName) => {
const exists = await knex.schema.hasTable(tableName);
if (!exists) {
return;
}
const distributed = await isTableDistributed(knex, tableName);
if (distributed) {
return;
}
await knex.raw(`SELECT create_distributed_table('${tableName}', 'tenant', colocate_with => 'tenants')`);
};
exports.up = async function up(knex) {
await ensureTable(knex, 'entra_partner_connections', async () => {
await knex.schema.createTable('entra_partner_connections', (table) => {
table.uuid('tenant').notNullable();
table
.uuid('connection_id')
.defaultTo(knex.raw('gen_random_uuid()'))
.notNullable();
table.text('connection_type').notNullable();
table.text('status').notNullable().defaultTo('disconnected');
table.boolean('is_active').notNullable().defaultTo(false);
table.text('cipp_base_url');
table.text('token_secret_ref');
table.timestamp('connected_at', { useTz: true });
table.timestamp('disconnected_at', { useTz: true });
table.timestamp('last_validated_at', { useTz: true });
table.jsonb('last_validation_error').notNullable().defaultTo(knex.raw(`'{}'::jsonb`));
table.uuid('created_by');
table.uuid('updated_by');
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'connection_id']);
table.foreign('tenant').references('tenants.tenant').onDelete('CASCADE');
table.unique(['tenant', 'connection_id']);
});
});
await ensureTable(knex, 'entra_managed_tenants', async () => {
await knex.schema.createTable('entra_managed_tenants', (table) => {
table.uuid('tenant').notNullable();
table
.uuid('managed_tenant_id')
.defaultTo(knex.raw('gen_random_uuid()'))
.notNullable();
table.text('entra_tenant_id').notNullable();
table.text('display_name');
table.text('primary_domain');
table.integer('source_user_count').notNullable().defaultTo(0);
table.timestamp('discovered_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('last_seen_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.jsonb('metadata').notNullable().defaultTo(knex.raw(`'{}'::jsonb`));
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'managed_tenant_id']);
table.foreign('tenant').references('tenants.tenant').onDelete('CASCADE');
table.unique(['tenant', 'entra_tenant_id']);
});
});
await ensureTable(knex, 'entra_client_tenant_mappings', async () => {
await knex.schema.createTable('entra_client_tenant_mappings', (table) => {
table.uuid('tenant').notNullable();
table
.uuid('mapping_id')
.defaultTo(knex.raw('gen_random_uuid()'))
.notNullable();
table.uuid('managed_tenant_id').notNullable();
table.uuid('client_id');
table.text('mapping_state').notNullable().defaultTo('needs_review');
table.decimal('confidence_score', 5, 2);
table.boolean('is_active').notNullable().defaultTo(true);
table.uuid('decided_by');
table.timestamp('decided_at', { useTz: true });
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'mapping_id']);
table
.foreign(['tenant', 'managed_tenant_id'])
.references(['tenant', 'managed_tenant_id'])
.inTable('entra_managed_tenants')
.onDelete('CASCADE');
table
.foreign(['tenant', 'client_id'])
.references(['tenant', 'client_id'])
.inTable('clients')
.onDelete('RESTRICT');
});
});
await ensureTable(knex, 'entra_sync_settings', async () => {
await knex.schema.createTable('entra_sync_settings', (table) => {
table.uuid('tenant').notNullable();
table
.uuid('settings_id')
.defaultTo(knex.raw('gen_random_uuid()'))
.notNullable();
table.boolean('sync_enabled').notNullable().defaultTo(true);
table.integer('sync_interval_minutes').notNullable().defaultTo(1440);
table.jsonb('field_sync_config').notNullable().defaultTo(knex.raw(`'{}'::jsonb`));
table.jsonb('user_filter_config').notNullable().defaultTo(knex.raw(`'{}'::jsonb`));
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'settings_id']);
table.foreign('tenant').references('tenants.tenant').onDelete('CASCADE');
});
});
await ensureTable(knex, 'entra_sync_runs', async () => {
await knex.schema.createTable('entra_sync_runs', (table) => {
table.uuid('tenant').notNullable();
table
.uuid('run_id')
.defaultTo(knex.raw('gen_random_uuid()'))
.notNullable();
table.text('workflow_id');
table.text('run_type').notNullable().defaultTo('manual');
table.text('status').notNullable().defaultTo('queued');
table.uuid('initiated_by');
table.timestamp('started_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('completed_at', { useTz: true });
table.integer('total_tenants').notNullable().defaultTo(0);
table.integer('processed_tenants').notNullable().defaultTo(0);
table.integer('succeeded_tenants').notNullable().defaultTo(0);
table.integer('failed_tenants').notNullable().defaultTo(0);
table.jsonb('summary').notNullable().defaultTo(knex.raw(`'{}'::jsonb`));
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'run_id']);
table.foreign('tenant').references('tenants.tenant').onDelete('CASCADE');
});
});
await ensureTable(knex, 'entra_sync_run_tenants', async () => {
await knex.schema.createTable('entra_sync_run_tenants', (table) => {
table.uuid('tenant').notNullable();
table
.uuid('run_tenant_id')
.defaultTo(knex.raw('gen_random_uuid()'))
.notNullable();
table.uuid('run_id').notNullable();
table.uuid('managed_tenant_id');
table.uuid('client_id');
table.text('status').notNullable().defaultTo('queued');
table.integer('created_count').notNullable().defaultTo(0);
table.integer('linked_count').notNullable().defaultTo(0);
table.integer('updated_count').notNullable().defaultTo(0);
table.integer('ambiguous_count').notNullable().defaultTo(0);
table.integer('inactivated_count').notNullable().defaultTo(0);
table.text('error_message');
table.timestamp('started_at', { useTz: true });
table.timestamp('completed_at', { useTz: true });
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'run_tenant_id']);
table
.foreign(['tenant', 'run_id'])
.references(['tenant', 'run_id'])
.inTable('entra_sync_runs')
.onDelete('CASCADE');
table
.foreign(['tenant', 'managed_tenant_id'])
.references(['tenant', 'managed_tenant_id'])
.inTable('entra_managed_tenants')
.onDelete('RESTRICT');
table
.foreign(['tenant', 'client_id'])
.references(['tenant', 'client_id'])
.inTable('clients')
.onDelete('RESTRICT');
});
});
await ensureTable(knex, 'entra_contact_links', async () => {
await knex.schema.createTable('entra_contact_links', (table) => {
table.uuid('tenant').notNullable();
table
.uuid('link_id')
.defaultTo(knex.raw('gen_random_uuid()'))
.notNullable();
table.uuid('contact_name_id').notNullable();
table.uuid('client_id');
table.text('entra_tenant_id').notNullable();
table.text('entra_object_id').notNullable();
table.text('link_status').notNullable().defaultTo('active');
table.boolean('is_active').notNullable().defaultTo(true);
table.timestamp('last_seen_at', { useTz: true });
table.timestamp('last_synced_at', { useTz: true });
table.jsonb('metadata').notNullable().defaultTo(knex.raw(`'{}'::jsonb`));
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'link_id']);
table
.foreign(['tenant', 'contact_name_id'])
.references(['tenant', 'contact_name_id'])
.inTable('contacts')
.onDelete('CASCADE');
table
.foreign(['tenant', 'client_id'])
.references(['tenant', 'client_id'])
.inTable('clients')
.onDelete('RESTRICT');
});
});
await ensureTable(knex, 'entra_contact_reconciliation_queue', async () => {
await knex.schema.createTable('entra_contact_reconciliation_queue', (table) => {
table.uuid('tenant').notNullable();
table
.uuid('queue_item_id')
.defaultTo(knex.raw('gen_random_uuid()'))
.notNullable();
table.uuid('managed_tenant_id');
table.uuid('client_id');
table.text('entra_tenant_id').notNullable();
table.text('entra_object_id').notNullable();
table.text('user_principal_name');
table.text('display_name');
table.text('email');
table.jsonb('candidate_contacts').notNullable().defaultTo(knex.raw(`'[]'::jsonb`));
table.text('status').notNullable().defaultTo('open');
table.text('resolution_action');
table.uuid('resolved_contact_id');
table.uuid('resolved_by');
table.timestamp('resolved_at', { useTz: true });
table.jsonb('payload').notNullable().defaultTo(knex.raw(`'{}'::jsonb`));
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'queue_item_id']);
table
.foreign(['tenant', 'managed_tenant_id'])
.references(['tenant', 'managed_tenant_id'])
.inTable('entra_managed_tenants')
.onDelete('RESTRICT');
table
.foreign(['tenant', 'client_id'])
.references(['tenant', 'client_id'])
.inTable('clients')
.onDelete('RESTRICT');
table
.foreign(['tenant', 'resolved_contact_id'])
.references(['tenant', 'contact_name_id'])
.inTable('contacts')
.onDelete('RESTRICT');
});
});
await ensureColumn(knex, 'clients', 'entra_tenant_id', (table) => {
table.text('entra_tenant_id');
});
await ensureColumn(knex, 'clients', 'entra_primary_domain', (table) => {
table.text('entra_primary_domain');
});
await ensureColumn(knex, 'contacts', 'entra_object_id', (table) => {
table.text('entra_object_id');
});
await ensureColumn(knex, 'contacts', 'entra_sync_source', (table) => {
table.text('entra_sync_source');
});
await ensureColumn(knex, 'contacts', 'last_entra_sync_at', (table) => {
table.timestamp('last_entra_sync_at', { useTz: true });
});
await ensureColumn(knex, 'contacts', 'entra_user_principal_name', (table) => {
table.text('entra_user_principal_name');
});
await ensureColumn(knex, 'contacts', 'entra_account_enabled', (table) => {
table.boolean('entra_account_enabled');
});
await ensureColumn(knex, 'contacts', 'entra_sync_status', (table) => {
table.text('entra_sync_status');
});
await ensureColumn(knex, 'contacts', 'entra_sync_status_reason', (table) => {
table.text('entra_sync_status_reason');
});
const inRecovery = await knex.raw(`SELECT pg_is_in_recovery() AS in_recovery`);
if (!inRecovery.rows?.[0]?.in_recovery && await isCitusEnabled(knex)) {
for (const tableName of ENTRA_DISTRIBUTED_TABLES) {
await ensureDistributedTable(knex, tableName);
}
}
await knex.schema.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_entra_partner_connections_active_per_tenant
ON entra_partner_connections (tenant)
WHERE is_active = true
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_partner_connections_tenant_status
ON entra_partner_connections (tenant, status)
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_managed_tenants_tenant_last_seen
ON entra_managed_tenants (tenant, last_seen_at DESC)
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_managed_tenants_tenant_primary_domain
ON entra_managed_tenants (tenant, lower(primary_domain))
`);
await knex.schema.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_entra_client_tenant_mappings_active
ON entra_client_tenant_mappings (tenant, managed_tenant_id)
WHERE is_active = true
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_client_tenant_mappings_client
ON entra_client_tenant_mappings (tenant, client_id, mapping_state)
`);
await knex.schema.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_entra_sync_settings_tenant
ON entra_sync_settings (tenant)
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_sync_runs_tenant_started_at
ON entra_sync_runs (tenant, started_at DESC)
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_sync_run_tenants_run
ON entra_sync_run_tenants (tenant, run_id, status)
`);
await knex.schema.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_entra_contact_links_entra_identity
ON entra_contact_links (tenant, entra_tenant_id, entra_object_id)
`);
await knex.schema.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_entra_contact_links_active_contact
ON entra_contact_links (tenant, contact_name_id)
WHERE is_active = true
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_contact_links_client
ON entra_contact_links (tenant, client_id, link_status)
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_reconciliation_queue_status
ON entra_contact_reconciliation_queue (tenant, status, created_at DESC)
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_entra_reconciliation_queue_identity
ON entra_contact_reconciliation_queue (tenant, entra_tenant_id, entra_object_id)
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_clients_entra_tenant
ON clients (tenant, entra_tenant_id)
`);
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS idx_contacts_entra_object
ON contacts (tenant, entra_object_id)
`);
await knex.raw(`
INSERT INTO entra_sync_settings (
tenant,
settings_id,
sync_enabled,
sync_interval_minutes,
field_sync_config,
user_filter_config,
created_at,
updated_at
)
SELECT
tenants.tenant,
gen_random_uuid(),
true,
1440,
'{}'::jsonb,
'{}'::jsonb,
NOW(),
NOW()
FROM tenants
WHERE NOT EXISTS (
SELECT 1
FROM entra_sync_settings
WHERE entra_sync_settings.tenant = tenants.tenant
)
`);
const dbUserServer = process.env.DB_USER_SERVER;
if (dbUserServer) {
const escapedUser = dbUserServer.replace(/"/g, '""');
await knex.schema.raw(`
GRANT ALL PRIVILEGES ON TABLE entra_partner_connections TO "${escapedUser}";
GRANT ALL PRIVILEGES ON TABLE entra_managed_tenants TO "${escapedUser}";
GRANT ALL PRIVILEGES ON TABLE entra_client_tenant_mappings TO "${escapedUser}";
GRANT ALL PRIVILEGES ON TABLE entra_sync_settings TO "${escapedUser}";
GRANT ALL PRIVILEGES ON TABLE entra_sync_runs TO "${escapedUser}";
GRANT ALL PRIVILEGES ON TABLE entra_sync_run_tenants TO "${escapedUser}";
GRANT ALL PRIVILEGES ON TABLE entra_contact_links TO "${escapedUser}";
GRANT ALL PRIVILEGES ON TABLE entra_contact_reconciliation_queue TO "${escapedUser}";
`);
}
};
exports.down = async function down(knex) {
await knex.schema.dropTableIfExists('entra_contact_reconciliation_queue');
await knex.schema.dropTableIfExists('entra_contact_links');
await knex.schema.dropTableIfExists('entra_sync_run_tenants');
await knex.schema.dropTableIfExists('entra_sync_runs');
await knex.schema.dropTableIfExists('entra_sync_settings');
await knex.schema.dropTableIfExists('entra_client_tenant_mappings');
await knex.schema.dropTableIfExists('entra_managed_tenants');
await knex.schema.dropTableIfExists('entra_partner_connections');
const dropColumnIfExists = async (tableName, columnName) => {
const has = await knex.schema.hasColumn(tableName, columnName);
if (has) {
await knex.schema.alterTable(tableName, (table) => {
table.dropColumn(columnName);
});
}
};
await dropColumnIfExists('clients', 'entra_tenant_id');
await dropColumnIfExists('clients', 'entra_primary_domain');
await dropColumnIfExists('contacts', 'entra_object_id');
await dropColumnIfExists('contacts', 'entra_sync_source');
await dropColumnIfExists('contacts', 'last_entra_sync_at');
await dropColumnIfExists('contacts', 'entra_user_principal_name');
await dropColumnIfExists('contacts', 'entra_account_enabled');
await dropColumnIfExists('contacts', 'entra_sync_status');
await dropColumnIfExists('contacts', 'entra_sync_status_reason');
};
exports.config = { transaction: false };