// src/commands/offboardUser.js // // /offboarduser — guided offboarding flow. // // 1. Resolves the Webex person by email (via WebexClient so the call shares // the bot's auth mutex + 401-retry path). // 2. Lists matching MDM CORP devices for the same email. // 3. Posts a confirmation adaptive card with everything that will happen. // 4. On confirm, runs in parallel: // - Revokes every Webex OAuth authorization for the user // (POST /authorizations + DELETE /authorizations/{id}) // - Enterprise-wipes each MDM CORP device. // Each step's outcome is reported individually in the success message. // // Note on "Hide from directory search": // The Webex `Hide from search` setting is Control Hub-only — it is NOT // exposed via the People or SCIM 2.0 APIs (confirmed by Cisco docs and // community). We therefore do not promise it in the card or success // message, and instead include a hint that operators must toggle it // manually in Control Hub if needed. import { logger } from '../utils/logger.js'; import webex from '../integrations/webex/WebexClient.js'; import { findDevicesByEmail, enterpriseWipe } from '../integrations/mdmcorp/client.js'; import { pendingOffboards } from '../utils/pendingOffboards.js'; import { extractRequester, describeRequester } from '../utils/requester.js'; // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── // Distinguish "your service app is missing a scope / role" from generic errors // so the operator can act on the message. function explainWebexAdminError(err) { const status = err?.response?.status; const apiMsg = err?.response?.data?.message || err?.response?.data?.errors?.[0]?.description || err?.message || String(err); if (status === 401 || status === 403) { return ( `${apiMsg} (HTTP ${status}). Verify the Webex service app has the ` + `\`identity:tokens_read\` and \`identity:tokens_write\` scopes and that ` + `the authorizing admin has Full / User / Device Admin role.` ); } return status ? `${apiMsg} (HTTP ${status})` : apiMsg; } /** * Revoke every Webex OAuth authorization belonging to a user. Deleting a * refresh token revokes all access tokens issued from it, so this effectively * kicks the user out of every signed-in client. * * Always returns a structured result instead of throwing — the caller wants to * report partial outcomes side-by-side with the device wipes. * * @param {string} personId — Webex personId from /v1/people * @returns {Promise<{ok: boolean, attempted: number, succeeded: number, failed: Array<{authorizationId: string, error: string}>, error?: string}>} */ export async function revokeUserAuthorizations(personId) { let items; try { const list = await webex.listAuthorizations(personId); items = Array.isArray(list?.items) ? list.items : []; } catch (err) { const reason = explainWebexAdminError(err); return { ok: false, attempted: 0, succeeded: 0, failed: [], error: reason }; } if (items.length === 0) { return { ok: true, attempted: 0, succeeded: 0, failed: [] }; } const results = await Promise.allSettled( items.map((a) => webex.deleteAuthorization(a.id)), ); const failed = []; let succeeded = 0; results.forEach((r, i) => { if (r.status === 'fulfilled') { succeeded += 1; } else { failed.push({ authorizationId: items[i].id, error: explainWebexAdminError(r.reason), }); } }); return { ok: failed.length === 0, attempted: items.length, succeeded, failed, }; } function renderTokenRevocationLine(result) { if (result.error) { return `❌ Webex token revocation failed: ${result.error}`; } if (result.attempted === 0) { return '• No active Webex authorizations found to revoke'; } if (result.failed.length === 0) { return `✅ Revoked ${result.succeeded} Webex authorization${result.succeeded === 1 ? '' : 's'}`; } return ( `⚠️ Revoked ${result.succeeded}/${result.attempted} Webex authorizations; ` + `${result.failed.length} failed (first: ${result.failed[0].error})` ); } async function runWipesInParallel(devices) { const labelled = devices.map((dev) => ({ id: dev.id || dev.SerialNumber || dev.Uuid || dev.DeviceId, name: dev.DeviceFriendlyName || dev.SerialNumber || dev.id || 'Unknown', })); const settled = await Promise.allSettled( labelled.map(({ id }) => enterpriseWipe(id)), ); return settled.map((r, i) => { const { name } = labelled[i]; if (r.status === 'fulfilled') return `✅ ${name}`; const msg = r.reason?.message || String(r.reason); return `❌ ${name} (${msg})`; }); } // ───────────────────────────────────────────────────────────────────────────── // Public entry points used by index.js // ───────────────────────────────────────────────────────────────────────────── /** * Run the actual offboard work once the user clicks "Confirm Offboard" on * the adaptive card. * * @param {object} bot - webex-node-bot-framework bot instance * @param {object} offboardData - value previously stored in pendingOffboards * @param {string} [_roomId] - retained for signature compatibility; the * framework's `bot` is already room-scoped * and bot.say() routes to its own room. * Passing a 3rd positional arg into bot.say * gets concatenated via util.format and * leaks into the message body. * @param {object} [requester] - { email, displayName, source } for audit log */ export async function applyOffboardConfirmation(bot, offboardData, _roomId, requester) { logger( 'offboard:audit', `CONFIRMED offboard for ${offboardData.email} by ${describeRequester(requester)}`, ); // Run Webex token revocation in parallel with MDM wipes — they're // independent and we want to minimize wall-clock time on a destructive op. const tokenPromise = offboardData.webexUserId ? revokeUserAuthorizations(offboardData.webexUserId) : Promise.resolve({ ok: false, attempted: 0, succeeded: 0, failed: [], error: 'No Webex personId stored with offboard card', }); const wipePromise = Array.isArray(offboardData.mdmDevices) && offboardData.mdmDevices.length > 0 ? runWipesInParallel(offboardData.mdmDevices) : Promise.resolve(null); const [tokenResult, wipeLines] = await Promise.all([tokenPromise, wipePromise]); // Build the user-facing summary const tokenLine = renderTokenRevocationLine(tokenResult); const wipeBlock = wipeLines === null ? '• No MDM CORP devices found to wipe' : `**MDM CORP device wipes:**\n${wipeLines.join('\n')}`; const successMsg = `✅ **Offboard completed for ${offboardData.email}**\n\n` + `${tokenLine}\n\n` + `${wipeBlock}\n\n` + `_Reminder: \`Hide from search\` is Control Hub-only and is **not** ` + `toggled automatically. Set it manually in Control Hub > Users > ${offboardData.email} ` + `> Security > Hide from search if required._`; await bot.say('markdown', successMsg); // Audit footer — final outcome captured for log scraping logger( 'offboard:audit', `COMPLETED offboard for ${offboardData.email}: ` + `tokens=${tokenResult.succeeded}/${tokenResult.attempted}` + `${tokenResult.error ? ' (error)' : ''}, ` + `wipes=${wipeLines === null ? 0 : wipeLines.filter((l) => l.startsWith('✅')).length}` + `/${wipeLines === null ? 0 : wipeLines.length}`, ); } /** * Cancel the pending offboard card with a user-visible confirmation message. * * Note: `_roomId` is intentionally unused — see applyOffboardConfirmation for * the bot.say(..., roomId) footgun explanation. */ export async function cancelOffboardCard(bot, offboardData, _roomId, requester) { await bot.say( 'markdown', `❌ Offboard cancelled for ${offboardData.email}. No changes were made.`, ); logger( 'offboard:audit', `CANCELLED offboard for ${offboardData.email} by ${describeRequester(requester)}`, ); } export async function handleOffboardUser(bot, trigger) { logger('offboard:user', 'Handler entered'); const args = trigger.args || []; const query = trigger.query || {}; const email = (args[0] || query.email || query.user || '').trim().toLowerCase(); if (!email || !email.includes('@')) { await bot.say('markdown', '**Usage:** `/offboardUser user@domain.com`'); return; } const requester = extractRequester(trigger); logger( 'offboard:audit', `REQUESTED offboard card for ${email} by ${describeRequester(requester)}`, ); try { const user = await webex.findPersonByEmail(email); if (!user) { await bot.say('markdown', `❌ No Webex user found for **${email}**.`); return; } const mdmDevices = await findDevicesByEmail(email); const cardId = `offboard-${Date.now()}`; pendingOffboards.set(cardId, { email, webexUserId: user.id, webexUserDisplayName: user.displayName || email, mdmDevices, roomId: trigger.roomId || trigger.message?.roomId, requester, }); let deviceList = 'No devices found.'; if (mdmDevices.length > 0) { deviceList = mdmDevices.map((d, i) => { const model = d.Model || d.DeviceReportedName || 'Unknown Model'; const serial = d.SerialNumber || d.Udid || d.id || '—'; return `${i+1}. ${model} • SN: ${serial}`; }).join('\n'); } const adaptiveCard = { type: "AdaptiveCard", version: "1.3", body: [ { type: "TextBlock", text: "⚠️ OFFBOARD USER CONFIRMATION", weight: "Bolder", size: "Large", color: "Attention" }, { type: "ColumnSet", columns: [ { type: "Column", width: "auto", items: [{ type: "Image", url: user.avatar || "https://www.webex.com/content/dam/wbx/us/images/icon/avatar-placeholder.png", size: "medium", style: "person" }] }, { type: "Column", width: "stretch", items: [ { type: "TextBlock", text: `**${user.displayName || email}**`, wrap: true }, { type: "TextBlock", text: `Email: ${email}`, wrap: true, size: "Small" }, user.title ? { type: "TextBlock", text: `Title: ${user.title}`, wrap: true, size: "Small" } : null, user.department ? { type: "TextBlock", text: `Department: ${user.department}`, wrap: true, size: "Small" } : null ].filter(Boolean) } ] }, { type: "TextBlock", text: `**MDM CORP devices to be wiped (${mdmDevices.length}):**`, weight: "Bolder", spacing: "Medium" }, { type: "TextBlock", text: deviceList, wrap: true, size: "Small" }, { type: "TextBlock", text: "**This will:**", weight: "Bolder", spacing: "Medium" }, { type: "TextBlock", text: "• Revoke all of the user's active Webex OAuth authorizations " + "(signs them out of every Webex client)\n" + "• Enterprise-wipe every listed MDM CORP device", wrap: true, color: "Attention" }, { type: "TextBlock", text: "ℹ️ `Hide from search` is **not** part of this action — it is a " + "Control Hub-only setting and is not exposed via any Webex API. " + "Set it manually in Control Hub if your offboarding policy requires it.", wrap: true, size: "Small", isSubtle: true, }, ].filter(Boolean), actions: [ { type: "Action.Submit", title: "✅ Confirm Offboard", data: { action: "confirm_offboard", cardId } }, { type: "Action.Submit", title: "❌ Cancel", data: { action: "cancel_offboard", cardId } } ] }; await bot.say({ markdown: "Please review and confirm the offboard action:", attachments: [{ contentType: "application/vnd.microsoft.card.adaptive", content: adaptiveCard }] }); } catch (err) { logger('offboard:user', `Error during lookup for ${email}: ${err.message}`, 'error'); await bot.say('markdown', `❌ Error looking up user **${email}**: ${err.message}`); } }