UNPKG

okta-mcp-server

Version:

Model Context Protocol (MCP) server for Okta API operations with support for bulk operations and caching

298 lines 11.8 kB
import { logger } from '../../utils/logger.js'; import { buildQueryParams } from './utils.js'; /** * Common event types for filtering */ export const EVENT_TYPES = { // User events USER_LOGIN: 'user.session.start', USER_LOGOUT: 'user.session.end', USER_CREATED: 'user.lifecycle.create', USER_ACTIVATED: 'user.lifecycle.activate', USER_DEACTIVATED: 'user.lifecycle.deactivate', USER_SUSPENDED: 'user.lifecycle.suspend', USER_UNSUSPENDED: 'user.lifecycle.unsuspend', USER_DELETED: 'user.lifecycle.delete', USER_PASSWORD_CHANGED: 'user.account.update_password', USER_PASSWORD_RESET: 'user.account.reset_password', USER_MFA_ENROLLED: 'user.mfa.factor.enroll', USER_MFA_CHALLENGED: 'user.mfa.factor.verify', // Application events APP_CREATED: 'application.lifecycle.create', APP_UPDATED: 'application.lifecycle.update', APP_DELETED: 'application.lifecycle.delete', APP_USER_ASSIGNED: 'application.user_membership.add', APP_USER_REMOVED: 'application.user_membership.remove', // Group events GROUP_CREATED: 'group.lifecycle.create', GROUP_UPDATED: 'group.lifecycle.update', GROUP_DELETED: 'group.lifecycle.delete', GROUP_USER_ADDED: 'group.user_membership.add', GROUP_USER_REMOVED: 'group.user_membership.remove', // Policy events POLICY_CREATED: 'policy.lifecycle.create', POLICY_UPDATED: 'policy.lifecycle.update', POLICY_DELETED: 'policy.lifecycle.delete', POLICY_ACTIVATED: 'policy.lifecycle.activate', POLICY_DEACTIVATED: 'policy.lifecycle.deactivate', // Security events SECURITY_THREAT_DETECTED: 'security.threat.detected', SECURITY_SUSPICIOUS_ACTIVITY: 'security.suspicious_activity', SECURITY_RATE_LIMIT: 'security.rate_limit.violation', // Admin events ADMIN_ACCESS: 'admin.access', ADMIN_PRIVILEGE_GRANT: 'admin.privilege.grant', ADMIN_PRIVILEGE_REVOKE: 'admin.privilege.revoke', }; /** * Parses the URI to extract query parameters for events */ function parseEventsUri(uri) { const url = new URL(uri, 'okta://'); const params = url.searchParams; return { since: params.get('since') || undefined, until: params.get('until') || undefined, filter: params.get('filter') || undefined, q: params.get('q') || undefined, limit: params.get('limit') ? parseInt(params.get('limit'), 10) : 100, sortOrder: params.get('sortOrder') || 'DESCENDING', after: params.get('after') || undefined, eventType: params.getAll('eventType').length > 0 ? params.getAll('eventType') : undefined, severity: params.getAll('severity').length > 0 ? params.getAll('severity') : undefined, actorId: params.get('actorId') || undefined, targetId: params.get('targetId') || undefined, outcome: params.get('outcome') || undefined, }; } /** * Build SCIM filter from options */ function buildEventFilter(options) { const filters = []; if (options.eventType && options.eventType.length > 0) { const eventTypeFilter = options.eventType.map((type) => `eventType eq "${type}"`).join(' or '); filters.push(`(${eventTypeFilter})`); } if (options.severity && options.severity.length > 0) { const severityFilter = options.severity.map((sev) => `severity eq "${sev}"`).join(' or '); filters.push(`(${severityFilter})`); } if (options.actorId) { filters.push(`actor.id eq "${options.actorId}"`); } if (options.targetId) { filters.push(`target.id eq "${options.targetId}"`); } if (options.outcome) { filters.push(`outcome.result eq "${options.outcome}"`); } // Combine with existing filter if provided if (options.filter) { filters.push(`(${options.filter})`); } return filters.length > 0 ? filters.join(' and ') : undefined; } /** * Async generator for streaming audit events efficiently */ async function* streamEvents(okta, options) { const pageSize = Math.min(options.limit || 100, 1000); let after = options.after; let totalFetched = 0; const maxToFetch = options.limit || Infinity; // Default to last 7 days if no time range specified const since = options.since || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const until = options.until || new Date().toISOString(); // Build the combined filter const filter = buildEventFilter(options); try { while (totalFetched < maxToFetch) { const currentPageSize = Math.min(pageSize, maxToFetch - totalFetched); logger.debug(`Fetching events page with size ${currentPageSize}, after: ${after || 'start'}`); const response = await okta.getLogs(buildQueryParams({ since, until, filter, q: options.q, limit: currentPageSize, sortOrder: options.sortOrder, after, })); const events = response.data; // Yield the current page of events yield events; totalFetched += events.length; // Check if there are more pages if (!response.headers?.link || events.length < currentPageSize) { break; } // Parse the 'after' cursor from the Link header const linkHeader = response.headers.link; const afterMatch = linkHeader.match(/after=([^&>]+)/); if (!afterMatch) { break; } after = afterMatch[1]; } } catch (error) { logger.error('Error streaming events:', error); throw error; } } /** * Handle audit events resource with advanced filtering and analytics */ export default async function handleEventsResource(uri, container) { const okta = container.resolve('okta'); const options = parseEventsUri(uri); logger.info(`Handling events resource with options:`, options); try { // Check if this is an analytics request if (uri.includes('analytics=true')) { return handleEventAnalytics(uri, container, options); } // For regular event retrieval, fetch the first page const firstPageSize = Math.min(options.limit || 100, 1000); const since = options.since || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const until = options.until || new Date().toISOString(); const filter = buildEventFilter(options); const firstPageResponse = await okta.getLogs(buildQueryParams({ since, until, filter, q: options.q, limit: firstPageSize, sortOrder: options.sortOrder, after: options.after, })); const events = firstPageResponse.data; // Build the response content const content = { uri, mimeType: 'application/json', text: JSON.stringify({ events, metadata: { count: events.length, timeRange: { since, until, }, filters: { filter: options.filter, q: options.q, eventType: options.eventType, severity: options.severity, actorId: options.actorId, targetId: options.targetId, outcome: options.outcome, sortOrder: options.sortOrder, }, commonEventTypes: EVENT_TYPES, }, }, null, 2), }; // TODO: Add support for pagination and analytics links when MCP supports it return content; } catch (error) { logger.error('Failed to handle events resource:', error); throw error; } } /** * Handle event analytics for security and compliance reporting */ async function handleEventAnalytics(uri, container, options) { const analytics = { totalEvents: 0, eventTypes: {}, severityBreakdown: {}, actorBreakdown: {}, targetBreakdown: {}, outcomeBreakdown: {}, timeSeriesData: [], topFailedLogins: [], suspiciousActivities: [], }; const failedLogins = new Map(); // Stream through all events for analytics for await (const eventBatch of streamEvents(container.resolve('okta'), options)) { for (const event of eventBatch) { analytics.totalEvents++; // Count by event type analytics.eventTypes[event.eventType] = (analytics.eventTypes[event.eventType] || 0) + 1; // Count by severity analytics.severityBreakdown[event.severity] = (analytics.severityBreakdown[event.severity] || 0) + 1; // Count by actor if (event.actor) { const actorId = event.actor.id; if (!analytics.actorBreakdown[actorId]) { analytics.actorBreakdown[actorId] = { count: 0 }; if (event.actor.displayName) { analytics.actorBreakdown[actorId].displayName = event.actor.displayName; } } analytics.actorBreakdown[actorId].count++; } // Count by target if (event.target && event.target.length > 0) { for (const target of event.target) { const targetId = target.id; if (!analytics.targetBreakdown[targetId]) { analytics.targetBreakdown[targetId] = { count: 0, type: target.type }; } analytics.targetBreakdown[targetId].count++; } } // Count by outcome if (event.outcome) { analytics.outcomeBreakdown[event.outcome.result] = (analytics.outcomeBreakdown[event.outcome.result] || 0) + 1; } // Track failed logins if (event.eventType === 'user.session.start' && event.outcome?.result === 'FAILURE') { const actorId = event.actor?.id || 'unknown'; const existing = failedLogins.get(actorId) || { count: 0, lastAttempt: '' }; existing.count++; existing.lastAttempt = event.published; failedLogins.set(actorId, existing); } // Detect suspicious activities if (event.severity === 'WARN' || event.severity === 'ERROR' || event.eventType.includes('security') || event.eventType.includes('threat')) { analytics.suspiciousActivities.push(event); } } } // Prepare top failed logins analytics.topFailedLogins = Array.from(failedLogins.entries()) .map(([actor, data]) => ({ actor, ...data })) .sort((a, b) => b.count - a.count) .slice(0, 10); // Limit suspicious activities to top 20 analytics.suspiciousActivities = analytics.suspiciousActivities.slice(0, 20); return { uri, mimeType: 'application/json', text: JSON.stringify(analytics, null, 2), }; } /** * Stream all events efficiently for bulk operations * This is an additional export for programmatic use */ export async function* streamAllEvents(container, options = {}) { const okta = container.resolve('okta'); for await (const eventBatch of streamEvents(okta, options)) { for (const event of eventBatch) { yield event; } } } //# sourceMappingURL=events.js.map