PSA/ee/scripts/backfill-sso-links.ts
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

204 lines
5.7 KiB
TypeScript

#!/usr/bin/env tsx
import { exit } from "process";
import logger from "@alga-psa/core/logger";
import { getAdminConnection } from "@alga-psa/db/admin";
import type { Knex } from "knex";
import type { OAuthLinkProvider } from "@ee/lib/auth/oauthAccountLinks";
import {
previewBulkSsoAssignment,
executeBulkSsoAssignment,
} from "@ee/lib/actions/ssoActions";
interface CliOptions {
provider: OAuthLinkProvider;
domains: string[];
dryRun: boolean;
userType: "internal" | "client";
tenant: string;
}
function parseArgs(argv: string[]): CliOptions | null {
let provider: OAuthLinkProvider | undefined;
const domains: string[] = [];
let dryRun = false;
let userType: "internal" | "client" = "internal";
let tenant: string | undefined;
for (const arg of argv) {
if (arg === "--dry-run") {
dryRun = true;
continue;
}
if (arg.startsWith("--provider=")) {
const value = arg.split("=", 2)[1]?.trim().toLowerCase();
if (value === "google" || value === "microsoft") {
provider = value === "google" ? "google" : "microsoft";
}
continue;
}
if (arg.startsWith("--domain=")) {
const value = arg.split("=", 2)[1];
if (value) {
value
.split(",")
.map((part) => part.trim().toLowerCase())
.filter(Boolean)
.forEach((domain) => domains.push(domain));
}
continue;
}
if (arg.startsWith("--user-type=")) {
const value = arg.split("=", 2)[1]?.trim().toLowerCase();
if (value === "client") {
userType = "client";
}
continue;
}
if (arg.startsWith("--tenant=")) {
tenant = arg.split("=", 2)[1]?.trim();
continue;
}
}
if (!provider || domains.length === 0 || !tenant) {
return null;
}
return { provider, domains, dryRun, userType, tenant };
}
function printUsage(): void {
console.log(`\nBackfill SSO account links\n`);
console.log(`Usage:`);
console.log(
` pnpm tsx ee/scripts/backfill-sso-links.ts --provider=<google|microsoft> --domain=example.com[,another.com] --tenant=<tenant-uuid> [--user-type=client] [--dry-run]`
);
console.log(``);
console.log(`Examples:`);
console.log(
` pnpm tsx ee/scripts/backfill-sso-links.ts --provider=google --domain=example.com --tenant=00000000-0000-0000-0000-000000000000`
);
console.log(
` pnpm tsx ee/scripts/backfill-sso-links.ts --provider=microsoft --domain=contoso.com,fabrikam.com --tenant=00000000-0000-0000-0000-000000000000 --user-type=client --dry-run`
);
}
async function findUserIdsForDomains(
knex: Knex,
tenant: string,
domains: string[],
userType: "internal" | "client"
): Promise<{ userIds: string[]; emails: Record<string, string> }> {
const domainPatterns = domains.map((domain) => `%@${domain.toLowerCase()}`);
if (domainPatterns.length === 0) {
return { userIds: [], emails: {} };
}
const rows = await knex('users')
.select('user_id', 'email')
.where({ tenant, user_type: userType })
.andWhere((builder) => {
builder.whereRaw('lower(email) like ?', [domainPatterns[0]]);
for (const pattern of domainPatterns.slice(1)) {
builder.orWhereRaw('lower(email) like ?', [pattern]);
}
});
const emails: Record<string, string> = {};
const userIds = rows.map((row) => {
emails[row.user_id] = row.email;
return row.user_id;
});
return { userIds, emails };
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (!options) {
printUsage();
exit(1);
}
const knex = await getAdminConnection();
try {
console.log(
`\n🔍 Searching for ${options.userType} users in tenant ${options.tenant} with domains: ${options.domains.join(", ")}`
);
const { userIds } = await findUserIdsForDomains(knex, options.tenant, options.domains, options.userType);
if (userIds.length === 0) {
console.log(`No ${options.userType} users matched the provided domains within tenant ${options.tenant}.`);
return;
}
const payload = {
providers: [options.provider],
userIds,
userType: options.userType,
};
console.log(`Found ${userIds.length} eligible user(s) in tenant ${options.tenant}.`);
const result = await (options.dryRun
? previewBulkSsoAssignment(payload, {
adminDb: knex,
source: 'script',
preview: true,
tenant: options.tenant,
})
: executeBulkSsoAssignment(payload, {
adminDb: knex,
source: 'script',
preview: false,
tenant: options.tenant,
}));
if (result.summary.scannedUsers === 0) {
console.log("No matching users found.");
return;
}
if (options.dryRun) {
result.details
.filter((detail) => detail.status === "would_link")
.forEach((detail) => {
console.log(
`DRY RUN: Would link ${detail.email} (${detail.userId}) to ${detail.provider}`
);
});
} else {
result.details
.filter((detail) => detail.status === "linked")
.forEach((detail) => {
console.log(`Linked ${detail.email} to ${detail.provider}`);
});
}
const providerSummary = result.summary.providers[0];
console.log("\nSummary");
console.log(` Scanned users: ${result.summary.scannedUsers}`);
console.log(` Skipped (inactive): ${providerSummary.skippedInactive}`);
console.log(` Already linked: ${providerSummary.alreadyLinked}`);
console.log(
` ${options.dryRun ? "Would link" : "Linked"}: ${providerSummary.linked}`
);
} catch (error) {
logger.error('[backfill-sso-links] Failed to run migration script', error);
exit(1);
} finally {
await knex.destroy();
}
}
main();