/** * Import Wasabi IAM credentials into Passportal * Creates one credential per bucket entry under each client's existing Veeam subfolder * * Usage: * npx tsx scripts/passportal-import-wasabi.ts --discover # List templates, clients, folders * npx tsx scripts/passportal-import-wasabi.ts --dry-run # Preview what will be created * npx tsx scripts/passportal-import-wasabi.ts # Run the import */ import { createHmac } from 'crypto'; import { readFileSync } from 'fs'; import * as path from 'path'; import * as dotenv from 'dotenv'; dotenv.config({ path: path.resolve(process.cwd(), '.env') }); // ── Config ──────────────────────────────────────────────────────────────────── const BASE_URL = 'https://us-clover.passportalmsp.com'; const HMAC_CONTENT = 'aUa&&XUQBJXz2x&'; const VEEAM_FOLDER_NAME = 'Veeam'; // case-insensitive match const CREDS_FILE = path.resolve(process.cwd(), 'dev/WasabiIAMCredentials_20260220_172028.txt'); // These Wulf-internal buckets have no matching client — skip them const SKIP_CLIENT_SLUGS = new Set(['internal', 'vbr', 'veeam', 'clients']); // ── Types ───────────────────────────────────────────────────────────────────── interface WasabiEntry { bucket: string; username: string; accessKey: string; secretKey: string; endpoint: string; clientSlug: string; } interface PassportalToken { access_token: string; refresh_token: string; expiry_time: number; } interface PassportalClient { id: number | string; name: string; } interface PassportalFolder { id: number | string; name: string; clientId?: number | string; } interface PassportalTemplate { id: number | string; name: string; fields?: Array<{ name: string; type: string }>; } // ── Parse credentials file ──────────────────────────────────────────────────── function parseCredentialsFile(filePath: string): WasabiEntry[] { const content = readFileSync(filePath, 'utf-8'); const entries: WasabiEntry[] = []; // Parse line-by-line to handle any line endings / blank-line variations const current: Record = {}; const flush = () => { const bucket = current['bucket'] ?? ''; const username = current['username'] ?? ''; const accessKey = current['access_key'] ?? ''; const secretKey = current['secret_key'] ?? ''; const endpoint = current['endpoint'] || 'https://s3.wasabisys.com'; if (bucket && accessKey && secretKey) { // wulf..veeam[365].immutable[1] → const match = bucket.match(/^wulf\.(.+?)\.veeam/); const clientSlug = match ? match[1] : bucket; entries.push({ bucket, username, accessKey, secretKey, endpoint, clientSlug }); } for (const k of Object.keys(current)) delete current[k]; }; for (const rawLine of content.split('\n')) { const line = rawLine.replace(/\r$/, ''); // strip CR for CRLF files if (line.trim() === '') { flush(); continue; } const colonIdx = line.indexOf(':'); if (colonIdx < 0) continue; // header separator lines (===) const rawKey = line.slice(0, colonIdx).trim().toLowerCase().replace(/\s+/g, '_'); const value = line.slice(colonIdx + 1).trim(); // Skip header lines that don't look like credential fields if (!['bucket', 'username', 'access_key', 'secret_key', 'endpoint'].includes(rawKey)) continue; current[rawKey] = value; } flush(); // handle last block if file doesn't end with blank line return entries; } // ── Auth ────────────────────────────────────────────────────────────────────── function computeHmac(secretKey: string): string { const hmac = createHmac('sha256', secretKey); hmac.update(HMAC_CONTENT); return hmac.digest('hex'); } async function authenticate(scope = 'docs_api'): Promise { const keyId = process.env.ACCESS_KEY_ID; const secret = process.env.SECRET_ACCESS_KEY; if (!keyId || !secret) { throw new Error('ACCESS_KEY_ID and SECRET_ACCESS_KEY must be set in .env'); } const hash = computeHmac(secret); const res = await fetch(`${BASE_URL}/api/v2/auth/client_token`, { method: 'POST', headers: { 'x-key': keyId, 'x-hash': hash, 'content-type': 'application/json', }, body: JSON.stringify({ scope, content: HMAC_CONTENT }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Auth failed ${res.status}: ${text}`); } const data: PassportalToken = await res.json(); return data.access_token; } // ── API helpers ─────────────────────────────────────────────────────────────── async function apiGet(token: string, endpoint: string): Promise { const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, { headers: { 'x-access-token': token, 'content-type': 'application/json', }, }); if (!res.ok) { const text = await res.text(); throw new Error(`GET ${endpoint} failed ${res.status}: ${text}`); } return res.json() as Promise; } async function apiPost(token: string, endpoint: string, body: unknown): Promise { const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, { method: 'POST', headers: { 'x-access-token': token, 'content-type': 'application/json', }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text(); throw new Error(`POST ${endpoint} failed ${res.status}: ${text}`); } return res.json() as Promise; } // ── Client / folder matching ────────────────────────────────────────────────── function decodeHtmlEntities(str: string): string { return str .replace(/�*39;/g, "'") .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))); } function slugify(name: string): string { return decodeHtmlEntities(name).toLowerCase().replace(/[^a-z0-9]/g, ''); } function findClient(slug: string, clients: PassportalClient[]): PassportalClient | undefined { const target = slugify(slug); // Exact slug match first, then prefix match return ( clients.find(c => slugify(c.name) === target) || clients.find(c => slugify(c.name).includes(target)) || clients.find(c => target.includes(slugify(c.name))) ); } function findVeeamFolder(folders: PassportalFolder[]): PassportalFolder | undefined { return folders.find(f => f.name.toLowerCase().includes(VEEAM_FOLDER_NAME.toLowerCase())); } // ── Discover mode ───────────────────────────────────────────────────────────── async function tryGet(token: string, label: string, endpoints: string[]): Promise<{ endpoint: string; data: unknown } | null> { for (const ep of endpoints) { try { const data = await apiGet(token, ep); console.log(` ✓ ${label} → ${ep}`); return { endpoint: ep, data }; } catch (e) { console.log(` ✗ ${ep}: ${(e as Error).message}`); } } return null; } interface PassportalDocument { id: number; client_id: number; clientName: string; templateId: number; templateName: string; type: string; label: string; folder_id?: number; folderName?: string; [key: string]: unknown; } async function fetchAllDocuments(token: string): Promise { const all: PassportalDocument[] = []; let page = 1; while (true) { const res = await apiGet<{ results?: PassportalDocument[]; success?: boolean } | PassportalDocument[]>( token, `/documents?page=${page}&limit=100` ); const batch = Array.isArray(res) ? res : (res as { results?: PassportalDocument[] }).results ?? []; all.push(...batch); if (batch.length < 100) break; // last page page++; } return all; } async function fetchFolders(token: string, clientId: number | string): Promise { // The /folders endpoint requires the raw HMAC auth (x-key + x-hash), not the JWT const keyId = process.env.ACCESS_KEY_ID!; const secret = process.env.SECRET_ACCESS_KEY!; const hash = computeHmac(secret); for (const endpoint of [ `/folders?clientId=${clientId}`, `/folders?client_id=${clientId}`, ]) { try { const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, { headers: { 'x-key': keyId, 'x-hash': hash, 'x-access-token': token, 'content-type': 'application/json', }, }); if (!res.ok) continue; const data = await res.json() as { results?: PassportalFolder[] } | PassportalFolder[]; return Array.isArray(data) ? data : (data as { results?: PassportalFolder[] }).results ?? []; } catch { /* try next */ } } return []; } async function runDiscover(token: string, entries: WasabiEntry[]) { console.log('\n=== FETCHING ALL DOCUMENTS ==='); const docs = await fetchAllDocuments(token); console.log(` Total documents: ${docs.length}`); // Unique templates const templates = new Map(); for (const d of docs) { if (!templates.has(d.templateId)) { templates.set(d.templateId, { name: d.templateName, type: d.type }); } } console.log(`\n=== TEMPLATES (${templates.size} unique) ===`); for (const [id, t] of [...templates.entries()].sort((a, b) => a[0] - b[0])) { console.log(` templateId=${id} type=${t.type.padEnd(20)} name="${t.name}"`); } // Look for folder fields in any document const docWithFolder = docs.find(d => d.folder_id || d.folderName); if (docWithFolder) { console.log('\n=== SAMPLE DOC WITH FOLDER FIELDS ==='); console.log(JSON.stringify(docWithFolder, null, 2)); } else { console.log('\n No folder_id/folderName found in document list response'); } // Try fetching a single document's full detail to see if it has more fields if (docs.length > 0) { console.log(`\n=== SINGLE DOCUMENT DETAIL (id=${docs[0].id}) ===`); try { const detail = await apiGet(token, `/documents/${docs[0].id}`); console.log(JSON.stringify(detail, null, 2)); } catch (e) { console.log(' Could not fetch detail:', (e as Error).message); } } // Unique clients from documents const clientMap = new Map(); for (const d of docs) { if (!clientMap.has(d.client_id)) clientMap.set(d.client_id, d.clientName); } console.log(`\n=== CLIENTS IN DOCUMENTS (${clientMap.size}) ===`); for (const [id, name] of [...clientMap.entries()].sort((a, b) => decodeHtmlEntities(a[1]).localeCompare(decodeHtmlEntities(b[1])))) { console.log(` client_id=${id} name="${decodeHtmlEntities(name)}"`); } // Try folders endpoint with both JWT and raw auth console.log('\n=== FOLDER ENDPOINT PROBE ==='); if (clientMap.size > 0) { const firstClientId = [...clientMap.keys()][0]; const folders = await fetchFolders(token, firstClientId); if (folders.length > 0) { console.log(` ✓ Got ${folders.length} folder(s) for client ${firstClientId}:`); console.log(JSON.stringify(folders.slice(0, 5), null, 2)); } else { console.log(` ✗ No folders returned for client ${firstClientId}`); // Try raw endpoint probe const keyId = process.env.ACCESS_KEY_ID!; const hash = computeHmac(process.env.SECRET_ACCESS_KEY!); for (const ep of ['/folders', `/folders?clientId=${firstClientId}`, '/passwords', '/credentials']) { try { const res = await fetch(`${BASE_URL}/api/v2${ep}`, { headers: { 'x-key': keyId, 'x-hash': hash, 'content-type': 'application/json' }, }); const text = await res.text(); console.log(` raw-auth ${ep} → ${res.status}: ${text.slice(0, 200)}`); } catch (e) { console.log(` raw-auth ${ep} → error: ${(e as Error).message}`); } } } } // Show client slug matching preview const slugs = [...new Set(entries.map(e => e.clientSlug))].filter(s => !SKIP_CLIENT_SLUGS.has(s)); console.log(`\n=== CLIENT SLUG → CLIENT MATCH PREVIEW ===`); const clients: PassportalClient[] = [...clientMap.entries()].map(([id, name]) => ({ id, name })); for (const slug of slugs) { const match = findClient(slug, clients); if (match) { console.log(` ✓ "${slug}" → "${decodeHtmlEntities(match.name)}" (id=${match.id})`); } else { console.log(` ✗ "${slug}" → NO MATCH`); } } } // ── Main import ─────────────────────────────────────────────────────────────── async function runImport(token: string, entries: WasabiEntry[], dryRun: boolean) { // 1. Build client list from documents endpoint (client API returns 500) console.log('Fetching documents to build client list...'); const allDocs = await fetchAllDocuments(token); console.log(` Found ${allDocs.length} documents`); const clientMap = new Map(); for (const d of allDocs) { if (!clientMap.has(d.client_id)) clientMap.set(d.client_id, d.clientName); } const clients: PassportalClient[] = [...clientMap.entries()].map(([id, name]) => ({ id, name })); console.log(` Found ${clients.length} unique clients`); // 2. Fetch templates and find best fit console.log('Fetching templates...'); let templateUid: string | number | undefined; try { const res = await apiGet(token, '/templates'); const templates: PassportalTemplate[] = Array.isArray(res) ? res : ((res as { data?: PassportalTemplate[] }).data ?? []); // Prefer a template named something like "Username & Password" or "AWS" or "S3" const preferred = templates.find(t => /username|password|credential|aws|s3|wasabi/i.test(t.name) ) ?? templates[0]; if (preferred) { templateUid = preferred.id; console.log(` Using template: "${preferred.name}" (${preferred.id})`); } } catch (e) { console.warn(' Could not fetch templates — templateUid will be omitted'); } // 3. Group entries by client slug, skip internal buckets const bySlug = new Map(); for (const entry of entries) { if (SKIP_CLIENT_SLUGS.has(entry.clientSlug)) continue; const list = bySlug.get(entry.clientSlug) ?? []; list.push(entry); bySlug.set(entry.clientSlug, list); } const skipped = entries.filter(e => SKIP_CLIENT_SLUGS.has(e.clientSlug)); if (skipped.length > 0) { console.log(`\nSkipping ${skipped.length} internal bucket(s): ${skipped.map(e => e.bucket).join(', ')}`); } // 4. Process each client slug let created = 0; let failed = 0; const unmatched: string[] = []; for (const [slug, slugEntries] of bySlug) { const client = findClient(slug, clients); if (!client) { console.warn(` ✗ No Passportal client found for slug "${slug}" — skipping ${slugEntries.length} entry/entries`); unmatched.push(slug); continue; } // Fetch folders for this client let folders: PassportalFolder[] = []; for (const endpoint of [ `/folders?clientId=${client.id}`, `/clients/${client.id}/folders`, ]) { try { const res = await apiGet(token, endpoint); folders = Array.isArray(res) ? res : ((res as { data?: PassportalFolder[] }).data ?? []); if (folders.length > 0) break; } catch { /* try next */ } } const veeamFolder = findVeeamFolder(folders); if (!veeamFolder) { console.warn(` ✗ No "${VEEAM_FOLDER_NAME}" folder found for client "${client.name}" — skipping`); failed += slugEntries.length; continue; } // Create one credential per entry for (const entry of slugEntries) { const title = `Wasabi S3 - ${entry.bucket}`; const doc = { ...(templateUid !== undefined ? { templateUid } : {}), clientId: client.id, folderId: veeamFolder.id, title, // Common username/password fields (field names vary by template) username: entry.accessKey, password: entry.secretKey, // Extra context fields notes: [ `Bucket: ${entry.bucket}`, `IAM User: ${entry.username}`, `Access Key: ${entry.accessKey}`, `Endpoint: ${entry.endpoint}`, ].join('\n'), // Template-specific aliases access_key: entry.accessKey, secret_key: entry.secretKey, url: entry.endpoint, application_name: `Wasabi S3 - ${entry.bucket}`, }; if (dryRun) { console.log(` [DRY RUN] Would create "${title}" under ${client.name} / ${veeamFolder.name}`); } else { try { await apiPost(token, '/documents', [doc]); console.log(` ✓ Created "${title}" under ${client.name} / ${veeamFolder.name}`); created++; } catch (e) { console.error(` ✗ Failed to create "${title}": ${(e as Error).message}`); failed++; } } } } // 5. Summary console.log('\n=== Summary ==='); if (dryRun) { const total = [...bySlug.values()].reduce((n, v) => n + v.length, 0); console.log(`Would create: ${total} credentials`); } else { console.log(`Created: ${created}`); console.log(`Failed: ${failed}`); } if (unmatched.length > 0) { console.log(`\nUnmatched client slugs (${unmatched.length}) — no Passportal client found:`); unmatched.forEach(s => console.log(` - ${s}`)); console.log('\nTip: run --discover to see the full client list and adjust matching manually.'); } } // ── Entry point ─────────────────────────────────────────────────────────────── async function main() { const args = process.argv.slice(2); const isDiscover = args.includes('--discover'); const isDryRun = args.includes('--dry-run'); console.log('=== Passportal Wasabi IAM Import ==='); if (isDiscover) console.log('Mode: DISCOVER'); else if (isDryRun) console.log('Mode: DRY RUN (no changes)'); else console.log('Mode: LIVE IMPORT'); const entries = parseCredentialsFile(CREDS_FILE); console.log(`\nParsed ${entries.length} credential entries from file`); const slugCounts = new Map(); for (const e of entries) { slugCounts.set(e.clientSlug, (slugCounts.get(e.clientSlug) ?? 0) + 1); } console.log(`Unique client slugs: ${[...slugCounts.keys()].join(', ')}`); console.log('\nAuthenticating...'); const token = await authenticate(); console.log('✓ Authenticated'); if (isDiscover) { await runDiscover(token, entries); } else { await runImport(token, entries, isDryRun); } } main().catch(err => { console.error('\nFatal:', err.message); process.exit(1); });