UNPKG

@cloud-copilot/iam-lens

Version:

Visibility in IAM in and across AWS accounts

1,087 lines 50.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.WhoCanProcessor = void 0; const job_1 = require("@cloud-copilot/job"); const worker_threads_1 = require("worker_threads"); const collect_js_1 = require("../collect/collect.js"); const resources_js_1 = require("../resources.js"); const arn_js_1 = require("../utils/arn.js"); const workerScript_js_1 = require("../utils/workerScript.js"); const SharedArrayBufferMainCache_js_1 = require("../workers/SharedArrayBufferMainCache.js"); const StreamingWorkQueue_js_1 = require("../workers/StreamingWorkQueue.js"); const WhoCanMainThreadWorker_js_1 = require("./WhoCanMainThreadWorker.js"); const principalScope_js_1 = require("./principalScope.js"); const whoCan_js_1 = require("./whoCan.js"); const iam_utils_1 = require("@cloud-copilot/iam-utils"); const principalArnFilter_js_1 = require("./principalArnFilter.js"); const untrustingActions_js_1 = require("./untrustingActions.js"); const log_1 = require("@cloud-copilot/log"); // ────────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────────── let nextRequestId = 0; /** * Generates a unique request ID for a new request. * * @returns a unique string ID */ function generateRequestId() { return `req-${++nextRequestId}`; } /** * Get the number of worker threads to use, defaulting to number of CPUs - 1. * * @param overrideValue the override value, if any * @returns the override value if provided, otherwise number of CPUs - 1 */ function getNumberOfWorkers(overrideValue) { if (typeof overrideValue === 'number' && overrideValue >= 0) { return Math.floor(overrideValue); } return Math.max(0, (0, job_1.numberOfCpus)() - 1); } /** * A queue-first bulk processor that accepts many whoCan requests, expands * scenarios on the main thread, and feeds a shared simulation scheduler used * by worker threads and an optional main-thread runner. * * Results are delivered through the {@link WhoCanProcessorConfig.onRequestSettled} * callback as each request completes — they are not stored inside the processor. * * Use {@link enqueueWhoCan} to submit requests, then {@link waitForIdle} to * wait for all work to complete. Call {@link shutdown} when done to terminate * worker threads. */ class WhoCanProcessor { workers; collectClient; config; isShutdown = false; workersDead = false; // Admission state pendingRequests = []; activeRequestOrder = []; requestStates = new Map(); admissionPumpRunning = false; draining = false; // Preparation queue preparationQueue; // Idle / drain tracking idleWaiters = []; // Main thread simulation runner mainThreadWorker; // Processor-fatal error fatalError; // Tracks a single in-progress shutdown so repeated calls are safe shutdownPromise; constructor(workers, collectClient, config, preparationQueue) { this.workers = workers; this.collectClient = collectClient; this.config = config; this.preparationQueue = preparationQueue; } /** * Waits for every worker to post a `{ type: 'ready' }` message. If any * worker fails (error, non-zero exit, or explicit `startupError` message) * the remaining workers are terminated and the returned promise rejects. * * @param workers - The worker instances to wait on. */ static async awaitWorkersReady(workers) { // Each entry resolves on 'ready' or rejects on failure. const perWorker = workers.map((worker) => new Promise((resolve, reject) => { const onMessage = (msg) => { if (msg.type === 'ready') { cleanup(); resolve(); } else if (msg.type === 'startupError') { cleanup(); reject(new Error(`Worker startup failed: ${msg.error}`)); } }; const onError = (err) => { cleanup(); reject(new Error(`Worker error during startup: ${err.message}`)); }; const onExit = (code) => { cleanup(); if (code !== 0) { reject(new Error(`Worker exited with code ${code} during startup`)); } }; const cleanup = () => { worker.off('message', onMessage); worker.off('error', onError); worker.off('exit', onExit); }; worker.on('message', onMessage); worker.on('error', onError); worker.on('exit', onExit); })); try { await Promise.all(perWorker); } catch (err) { // At least one worker failed — terminate all workers so none are // left orphaned (no processor instance will be returned to the caller). await Promise.allSettled(workers.map((w) => w.terminate())); throw err; } } /** * Creates a new WhoCanProcessor with worker threads, a shared cache, and * lifetime-scoped message routing. The processor is ready to accept requests * immediately after creation. * * @param config - The configuration for the processor, including collect configs, * partition, simulation options, tuning, and the onRequestSettled callback. * @returns a new WhoCanProcessor instance */ static async create(config) { const numWorkers = getNumberOfWorkers(config.tuning?.workerThreads); const perWorkerConcurrency = config.tuning?.perWorkerConcurrency ?? 50; const mainThreadConcurrency = config.tuning?.mainThreadConcurrency ?? 50; const workerPath = (0, workerScript_js_1.getWorkerScriptPath)('whoCan/WhoCanWorkerThreadWorker.js'); if ((!workerPath || numWorkers === 0) && mainThreadConcurrency <= 0) { throw new Error('WhoCanProcessor: no worker script found and mainThreadConcurrency is 0 — no work can be processed'); } const workers = !workerPath ? [] : new Array(numWorkers).fill(undefined).map(() => { return new worker_threads_1.Worker(workerPath, { workerData: { collectConfigs: config.collectConfigs, partition: config.partition, concurrency: perWorkerConcurrency, s3AbacOverride: config.s3AbacOverride, collectGrantDetails: config.collectGrantDetails, clientFactoryPlugin: config.clientFactoryPlugin, workerBootstrapPlugin: config.workerBootstrapPlugin } }); }); // Install the shared-cache bridge before waiting for workers to become // ready. Workers may issue getCache messages during their getCollectClient() // call, which runs concurrently with the ready handshake. If the cache // listeners are not installed yet those messages are dropped and the worker // deadlocks waiting for a cache response. const sharedCache = new SharedArrayBufferMainCache_js_1.SharedArrayBufferMainCache(workers); // Wait for all workers to complete bootstrap and signal ready before // proceeding. This ensures bootstrap failures are caught reliably and // all workers are fully initialized before work is dispatched. if (workers.length > 0) { await WhoCanProcessor.awaitWorkersReady(workers); } const collectClient = await (0, collect_js_1.getCollectClient)(config.collectConfigs, config.partition, { cacheProvider: sharedCache, clientFactoryPlugin: config.clientFactoryPlugin }); const preparationConcurrency = config.tuning?.preparationConcurrency ?? Math.min(50, Math.max(1, (0, job_1.numberOfCpus)() * 2)); const preparationQueue = new job_1.StreamingJobQueue(preparationConcurrency, console, async () => { }); const processor = new WhoCanProcessor(workers, collectClient, config, preparationQueue); processor.installLifetimeWorkerListeners(); // Signal workers to start pulling tasks. This must happen after // installLifetimeWorkerListeners() so the main thread's requestTask // handler is installed before the workers begin emitting requestTask // messages. for (const worker of workers) { worker.postMessage({ type: 'start' }); } processor.createMainThreadRunner(); return processor; } /** * Enqueues a whoCan request for processing. Returns a unique request ID * that will appear in the corresponding {@link WhoCanSettledEvent}. * * This method never activates a request directly — it appends to * pendingRequests and signals the admission pump. * * @param request - The whoCan request parameters. * @returns the unique request ID assigned to this request. * @throws if the processor is shut down or draining via waitForIdle. */ enqueueWhoCan(request) { if (this.isShutdown) { throw new Error('WhoCanProcessor has been shut down'); } if (this.draining) { throw new Error('Cannot enqueue while draining — waitForIdle() is in progress'); } const requestId = generateRequestId(); this.pendingRequests.push({ requestId, request }); this.wakeAdmissionPump(); return requestId; } /** * Returns a promise that resolves when all pending and active work has * completed and all onRequestSettled callbacks have finished. * * While draining, new calls to {@link enqueueWhoCan} will throw. Once * the drain completes, the processor re-opens for new enqueues. * * @returns a promise that resolves when idle, or rejects if a worker crashes. */ async waitForIdle() { if (this.isShutdown) { throw new Error('WhoCanProcessor has been shut down'); } // If already idle, return immediately if (this.isIdle()) { return; } this.draining = true; try { await new Promise((resolve, reject) => { this.idleWaiters.push({ resolve, reject }); }); } finally { // Only clear draining when the last waiter has been notified if (this.idleWaiters.length === 0) { this.draining = false; } } } /** * Shuts down the processor by rejecting all pending requests, waiting for * active requests to settle, and terminating all worker threads. * * This method is idempotent — calling it multiple times is safe. */ async shutdown() { // If already shutting down or shut down, return the existing promise if (this.shutdownPromise) { return this.shutdownPromise; } this.shutdownPromise = this.executeShutdown(); return this.shutdownPromise; } /** * Internal shutdown implementation. Rejects pending requests, waits for * active requests to drain, then terminates workers. */ async executeShutdown() { this.isShutdown = true; // Reject all pending requests that haven't been admitted while (this.pendingRequests.length > 0) { const submitted = this.pendingRequests.shift(); const event = { status: 'rejected', requestId: submitted.requestId, request: submitted.request, error: new Error('WhoCanProcessor was shut down before this request was processed') }; try { await this.config.onRequestSettled(event); } catch (err) { await this.handleSettlementFailure(event, err); } } // Wait for active requests to finish naturally (includes draining in-flight work) if (this.activeRequestOrder.length > 0) { await new Promise((resolve) => { if (this.activeRequestOrder.length === 0) { resolve(); } else { this.idleWaiters.push({ resolve, reject: () => resolve() }); } }); } if (this.workersDead) { return; } // Drain main thread worker if (this.mainThreadWorker) { await this.mainThreadWorker.finishAllWork(); this.mainThreadWorker = undefined; } // Gracefully shut down workers const workerPromises = this.workers.map((worker) => { return new Promise((resolve) => { worker.on('message', (msg) => { if (msg.type === 'finished') { worker.terminate().then(() => resolve()); } }); worker.on('error', () => { worker .terminate() .then(() => resolve()) .catch(() => resolve()); }); worker.postMessage({ type: 'finishWork' }); }); }); await Promise.all(workerPromises); this.workersDead = true; } // ────────────────────────────────────────────────────────────────────────── // Lifetime worker listeners // ────────────────────────────────────────────────────────────────────────── /** * Installs lifetime-scoped message, error, and exit listeners on all workers. * Message listeners route simulation results and deny-detail checks to the * correct request state using requestId. Error/exit listeners detect crashes * and mark the processor as fatally failed. */ installLifetimeWorkerListeners() { for (const worker of this.workers) { worker.on('message', (msg) => { this.handleWorkerMessage(msg, worker); }); worker.on('error', (err) => { if (!this.isShutdown) { this.handleWorkerFailure(new Error(`Worker error: ${err.message}`)); } }); worker.on('exit', (code) => { if (!this.isShutdown && code !== 0) { this.handleWorkerFailure(new Error(`Worker exited unexpectedly with code ${code}`)); } }); } } /** * Routes a message from a worker thread to the appropriate handler based * on message type and requestId. * * @param msg - The message received from the worker. * @param worker - The worker that sent the message. */ handleWorkerMessage(msg, worker) { if (msg.type === 'requestTask') { const task = this.dequeueNextScenario(); worker.postMessage({ type: 'task', workerId: msg.workerId, task }); } else if (msg.type === 'result') { this.handleSimulationResult(msg.requestId, msg.result, !!msg.denyDetailsCheckWillFollow); } else if (msg.type === 'checkDenyDetails') { this.handleCheckDenyDetails(msg.requestId, msg.checkId, msg.lightAnalysis, worker); } else if (msg.type === 'denyDetailsResult') { this.handleDenyDetailsResult(msg.requestId, msg.denyDetail); } } /** * Creates the main-thread simulation runner if mainThreadConcurrency > 0. * The runner pulls from the FIFO scheduler and routes results by requestId. */ createMainThreadRunner() { const mainThreadConcurrency = this.config.tuning?.mainThreadConcurrency ?? 50; if (mainThreadConcurrency <= 0) { return; } const { collectGrantDetails, s3AbacOverride } = this.config; this.mainThreadWorker = (0, WhoCanMainThreadWorker_js_1.createMainThreadStreamingWorkQueue)(() => this.dequeueNextScenario(), (requestId, result) => this.handleSimulationResult(requestId, result), (requestId, lightAnalysis) => { const state = this.requestStates.get(requestId); if (state && !state.settled) { return state.denyDetailsCallback?.(lightAnalysis) ?? false; } return false; }, (requestId, detail) => this.handleDenyDetailsResult(requestId, detail), this.collectClient, s3AbacOverride, collectGrantDetails ?? false, mainThreadConcurrency); } // ────────────────────────────────────────────────────────────────────────── // FIFO queue-of-queues scheduler // ────────────────────────────────────────────────────────────────────────── /** * Dequeues the next simulation scenario using FIFO request priority. * Prefers the oldest active request that has ready scenarios. If the oldest * is temporarily empty (still preparing), falls back to the next request * with ready scenarios so cores do not idle. * * @returns the next work item, or undefined if no scenarios are ready. */ dequeueNextScenario() { for (const requestId of this.activeRequestOrder) { const state = this.requestStates.get(requestId); if (!state || state.settled) continue; const item = state.scenarios.dequeue(); if (item) { return { ...item, requestId }; } } return undefined; } /** * Notifies all simulation consumers (workers and main thread) that new * work may be available in the scheduler. */ notifySimulationConsumers() { this.mainThreadWorker?.notifyWorkAvailable(); for (const worker of this.workers) { worker.postMessage({ type: 'workAvailable' }); } } // ────────────────────────────────────────────────────────────────────────── // Admission pump // ────────────────────────────────────────────────────────────────────────── /** * Wakes the admission pump to process pending requests. If the pump is * already running, this is a no-op — the running pump will pick up new * pending requests on its next iteration. */ wakeAdmissionPump() { if (this.admissionPumpRunning) return; this.admissionPumpRunning = true; // Run asynchronously so enqueueWhoCan returns immediately void this.runAdmissionPump(); } /** * The admission pump loop. Drains pendingRequests into active processing * up to maxRequestsInProgress. Only one instance of this loop runs at a time, * guarded by admissionPumpRunning. */ async runAdmissionPump() { const maxActive = this.config.tuning?.maxRequestsInProgress ?? 30; try { while (this.pendingRequests.length > 0 && this.activeRequestOrder.length < maxActive) { if (this.isShutdown) break; const submitted = this.pendingRequests.shift(); const state = this.createRequestState(submitted); this.requestStates.set(submitted.requestId, state); this.activeRequestOrder.push(submitted.requestId); // Enqueue the root preparation job for this request this.enqueueRootPreparation(state); } } finally { this.admissionPumpRunning = false; } // After admitting, check if we became idle this.checkIdle(); } /** * Creates a fresh RequestState for an admitted request. * * @param submitted - The submitted request to create state for. * @returns the new RequestState. */ createRequestState(submitted) { return { requestId: submitted.requestId, request: submitted.request, allScenariosCreated: false, scenarios: new StreamingWorkQueue_js_1.StreamingWorkQueue(), created: 0, completed: 0, pendingPreparationJobs: 0, allowed: [], principalsNotFound: [], accountsNotFound: [], organizationsNotFound: [], organizationalUnitsNotFound: [], allAccountsChecked: false, denyDetails: [], simulationCount: 0, denyDetailsCallback: submitted.request.denyDetailsCallback, pendingDenyDetailsChecks: 0, settled: false, callbackInvoked: false, simulationErrors: [] }; } // ────────────────────────────────────────────────────────────────────────── // Preparation // ────────────────────────────────────────────────────────────────────────── /** * Enqueues the root preparation job for a request. This job performs resource * account resolution, resource policy lookup, action expansion, principal scope * handling, and then enqueues follow-up preparation jobs to enumerate principals. * * @param state - The request state to prepare. */ enqueueRootPreparation(state) { state.pendingPreparationJobs++; this.preparationQueue.enqueue({ properties: {}, execute: async () => { try { await this.executeRootPreparation(state); } catch (err) { this.settleRequestAsError(state, err instanceof Error ? err : new Error(String(err))); } finally { state.pendingPreparationJobs--; this.checkRequestCompletion(state); } } }); } /** * Executes the root preparation for a request: resolves the resource account, * fetches the resource policy, expands actions, determines which accounts and * principals to check, and enqueues follow-up preparation jobs. * * @param state - The request state to prepare. */ async executeRootPreparation(state) { if (state.settled) return; const { request } = state; const { resource } = request; const collectClient = this.collectClient; if (!request.resourceAccount && !request.resource) { throw new Error('Either resourceAccount or resource must be provided in the request.'); } const resourceAccount = request.resourceAccount || (await (0, resources_js_1.getAccountIdForResource)(collectClient, resource)); if (!resourceAccount) { throw new Error(`Could not determine account ID for resource ${resource}. Please use a different ARN or specify resourceAccount.`); } const actions = await (0, whoCan_js_1.actionsForWhoCan)({ actions: request.actions, resource: request.resource }); if (!actions || actions.length === 0) { throw new Error('No valid actions provided or found for the resource.'); } const untrustingActions = await (0, untrustingActions_js_1.actionsThatDoNotAutomaticallyTrustTheCurrentAccount)(); let resourcePolicy = undefined; if (resource) { resourcePolicy = await (0, resources_js_1.getResourcePolicyForResource)(collectClient, resource, resourceAccount); const resourceArn = new arn_js_1.Arn(resource); if ((resourceArn.matches({ service: 'iam', resourceType: 'role' }) || resourceArn.matches({ service: 'kms', resourceType: 'key' })) && !resourcePolicy) { throw new Error(`Unable to find resource policy for ${resource}. Cannot determine who can access the resource.`); } if (resourcePolicy && resourcePolicy.errors.length > 0) { log_1.log.error({ iamLensResourcePolicyValidationErrors: true, errors: resourcePolicy.errors, resource: resource }); throw new Error(`Resource policy for ${resource} has validation errors and cannot be used for simulation.`); } } const accountsToCheck = await (0, whoCan_js_1.accountsToCheckBasedOnResourcePolicy)(resourcePolicy, resourceAccount); const principalArnFilter = (0, principalArnFilter_js_1.buildPrincipalArnFilter)(resourcePolicy); const uniqueAccounts = await (0, whoCan_js_1.uniqueAccountsToCheck)(collectClient, accountsToCheck); // Store not-found arrays on the request state state.accountsNotFound = uniqueAccounts.accountsNotFound; state.organizationsNotFound = uniqueAccounts.organizationsNotFound; state.organizationalUnitsNotFound = uniqueAccounts.organizationalUnitsNotFound; state.allAccountsChecked = request.principalScope ? false : accountsToCheck.allAccounts; // The resource account is always a candidate for search — for non-untrusting // actions same-account principals can access via identity policy alone. Include // it in the base accounts list so the principalScope intersection can see it. const baseAccounts = uniqueAccounts.accounts.includes(resourceAccount) ? uniqueAccounts.accounts : [...uniqueAccounts.accounts, resourceAccount]; let accountsForSearch = baseAccounts; let principalsForSearch = accountsToCheck.specificPrincipals; let scopeIncludesResourceAccount = true; if (request.principalScope) { const resolved = await (0, principalScope_js_1.resolvePrincipalScope)(collectClient, request.principalScope); const intersection = (0, principalScope_js_1.intersectWithPrincipalScope)(baseAccounts, accountsToCheck.specificPrincipals, accountsToCheck.allAccounts, resolved.accounts, resolved.principals); accountsForSearch = intersection.accounts; principalsForSearch = intersection.principals; scopeIncludesResourceAccount = resolved.accounts.has(resourceAccount); } // Principals explicitly named in the resource policy are enqueued via the // specific-principals path (which skips the PrincipalArn filter). Track them // so the account-enumeration paths can skip duplicates without needing to // store all enumerated principals in memory. const specificPrincipalSet = new Set(principalsForSearch); // Enqueue follow-up preparation jobs for account/principal enumeration const principalIndexExists = !this.config.ignorePrincipalIndex && (await collectClient.principalIndexExists()); if (principalIndexExists) { // Use the principal index to find relevant principals directly state.pendingPreparationJobs++; this.preparationQueue.enqueue({ properties: {}, execute: async () => { try { if (state.settled) return; // Pre-compute an accounts list without the resource account for // untrusting actions where the policy does not trust it. const accountsWithoutResourceAccount = scopeIncludesResourceAccount && !accountsToCheck.resourceAccountTrustedByPolicy ? accountsForSearch.filter((a) => a !== resourceAccount) : accountsForSearch; for (const action of actions) { const isUntrusting = untrustingActions.has(action.toLowerCase()); const allFromAccount = scopeIncludesResourceAccount && accountsToCheck.checkAllFromResourceAccount ? resourceAccount : undefined; const actionAccountsForSearch = isUntrusting ? accountsWithoutResourceAccount : accountsForSearch; const indexedPrincipals = await collectClient.getPrincipalsWithActionAllowed(allFromAccount, actionAccountsForSearch, action); for (const principal of indexedPrincipals || []) { if (specificPrincipalSet.has(principal)) continue; if (principalArnFilter && !(0, iam_utils_1.isServicePrincipal)(principal) && !(0, principalArnFilter_js_1.principalMatchesFilter)(principal, action, resourceAccount, principalArnFilter)) { continue; } state.scenarios.enqueue({ resource, action, principal, resourceAccount, strictContextKeys: state.request.strictContextKeys, collectDenyDetails: !!state.denyDetailsCallback }); state.created++; } } this.notifySimulationConsumers(); } catch (err) { this.settleRequestAsError(state, err instanceof Error ? err : new Error(String(err))); } finally { state.pendingPreparationJobs--; this.checkRequestCompletion(state); } } }); } else { // No principal index — enumerate all principals per account for (const account of accountsForSearch) { state.pendingPreparationJobs++; this.preparationQueue.enqueue({ properties: {}, execute: async () => { try { if (state.settled) return; const principals = await collectClient.getAllPrincipalsInAccount(account); for (const principal of principals) { if (specificPrincipalSet.has(principal)) continue; const skipFilter = !principalArnFilter || (0, iam_utils_1.isServicePrincipal)(principal); for (const action of actions) { if (!skipFilter && !(0, principalArnFilter_js_1.principalMatchesFilter)(principal, action, resourceAccount, principalArnFilter)) { continue; } state.scenarios.enqueue({ resource, action, principal, resourceAccount, strictContextKeys: state.request.strictContextKeys, collectDenyDetails: !!state.denyDetailsCallback }); state.created++; } } this.notifySimulationConsumers(); } catch (err) { this.settleRequestAsError(state, err instanceof Error ? err : new Error(String(err))); } finally { state.pendingPreparationJobs--; this.checkRequestCompletion(state); } } }); } } // Enqueue specific principals from resource policy (iterate the Set to // deduplicate — the same principal can appear in the list more than once // when multiple statements reference it, e.g. an explicit Principal element // and a StringEquals aws:PrincipalArn condition). for (const principal of specificPrincipalSet) { state.pendingPreparationJobs++; this.preparationQueue.enqueue({ properties: {}, execute: async () => { try { if (state.settled) return; if ((0, iam_utils_1.isServicePrincipal)(principal)) { for (const action of actions) { state.scenarios.enqueue({ resource, action, principal, resourceAccount, strictContextKeys: state.request.strictContextKeys, collectDenyDetails: !!state.denyDetailsCallback }); state.created++; } } else if ((0, iam_utils_1.isIamUserArn)(principal) || (0, iam_utils_1.isIamRoleArn)(principal) || (0, iam_utils_1.isAssumedRoleArn)(principal)) { const principalExists = await collectClient.principalExists(principal); if (!principalExists) { state.principalsNotFound.push(principal); } else { for (const action of actions) { state.scenarios.enqueue({ resource, action, principal, resourceAccount, strictContextKeys: state.request.strictContextKeys, collectDenyDetails: !!state.denyDetailsCallback }); state.created++; } } } else { state.principalsNotFound.push(principal); } this.notifySimulationConsumers(); } catch (err) { this.settleRequestAsError(state, err instanceof Error ? err : new Error(String(err))); } finally { state.pendingPreparationJobs--; this.checkRequestCompletion(state); } } }); } // All follow-up prep jobs have been enqueued. Mark scenarios as fully specified // once the root prep and all follow-ups complete (tracked by pendingPreparationJobs). state.allScenariosCreated = true; // Notify consumers that scenarios may be available this.notifySimulationConsumers(); } // ────────────────────────────────────────────────────────────────────────── // Simulation result handling // ────────────────────────────────────────────────────────────────────────── /** * Handles a simulation result from a worker or the main thread runner. * Routes the result to the correct request state and checks for completion. * * @param requestId - The ID of the request this result belongs to. * @param result - The simulation job result. */ handleSimulationResult(requestId, result, denyDetailsCheckWillFollow = false) { const state = this.requestStates.get(requestId); if (!state) return; state.completed++; if (denyDetailsCheckWillFollow) { state.pendingDenyDetailsChecks++; } if (state.settled) { // Request already settled (e.g., failed). Still count the result so // the drain check can fire, but discard the actual data. this.checkRequestCompletion(state); return; } state.simulationCount++; if (result.status === 'fulfilled' && result.value) { state.allowed.push(result.value); } else if (result.status === 'rejected') { log_1.log.error('Error running simulation', { error: result.reason }); state.simulationErrors.push(result); } this.checkRequestCompletion(state); } /** * Handles a checkDenyDetails request from a worker thread. Looks up the * request's denyDetailsCallback and responds. * * @param requestId - The ID of the request. * @param checkId - The unique check ID for this deny-details round trip. * @param lightAnalysis - The light analysis to pass to the callback. * @param worker - The worker to respond to. */ handleCheckDenyDetails(requestId, checkId, lightAnalysis, worker) { const state = this.requestStates.get(requestId); const shouldInclude = state && !state.settled ? (state.denyDetailsCallback?.(lightAnalysis) ?? false) : false; if (!shouldInclude && state) { // No denyDetailsResult message will follow — decrement the counter state.pendingDenyDetailsChecks--; this.checkRequestCompletion(state); } worker.postMessage({ type: 'denyDetailsCheckResult', checkId, shouldInclude }); } /** * Handles a deny details result from a worker thread. Decrements the * pending deny-details counter and checks for request completion. * * @param requestId - The ID of the request. * @param denyDetail - The deny detail to store. */ handleDenyDetailsResult(requestId, denyDetail) { const state = this.requestStates.get(requestId); if (!state) return; if (state.pendingDenyDetailsChecks > 0) { state.pendingDenyDetailsChecks--; } if (!state.settled) { state.denyDetails.push(denyDetail); } this.checkRequestCompletion(state); } // ────────────────────────────────────────────────────────────────────────── // Request completion and settlement // ────────────────────────────────────────────────────────────────────────── /** * Checks whether a request has completed all preparation and simulation work. * If so, settles the request as successful. * * @param state - The request state to check. */ checkRequestCompletion(state) { if (state.settled) { this.checkRequestDrain(state); return; } if (!state.allScenariosCreated) return; if (state.pendingPreparationJobs > 0) return; if (state.created !== state.completed) return; if (state.pendingDenyDetailsChecks > 0) return; // All work done — settle as success if (state.simulationErrors.length > 0) { this.settleRequestAsError(state, new Error(`Completed with ${state.simulationErrors.length} simulation errors. See previous logs.`)); } else { this.settleRequestAsSuccess(state); } } /** * Settles a request as successful: builds the WhoCanResponse, awaits * onRequestSettled, removes the request from active state, and wakes * the admission pump. * * @param state - The request state to settle. */ settleRequestAsSuccess(state) { if (state.settled) return; state.settled = true; const result = { simulationCount: state.simulationCount, allowed: state.allowed, allAccountsChecked: state.allAccountsChecked, accountsNotFound: state.accountsNotFound, organizationsNotFound: state.organizationsNotFound, organizationalUnitsNotFound: state.organizationalUnitsNotFound, principalsNotFound: state.principalsNotFound, denyDetails: state.denyDetailsCallback ? state.denyDetails : undefined }; if (state.request.sort) { (0, whoCan_js_1.sortWhoCanResults)(result); } void this.invokeSettledCallbackAndCleanup(state, { status: 'fulfilled', requestId: state.requestId, request: state.request, result }); } /** * Settles a request as failed: invokes onRequestSettled with the error * immediately, but keeps the request in active state until all in-flight * work drains (created === completed). Late results for settled requests * are discarded but still counted so the drain completes. * * @param state - The request state to settle. * @param error - The error that caused the failure. */ settleRequestAsError(state, error) { if (state.settled) return; state.settled = true; // Await the callback (backpressure), then mark it done so checkRequestDrain // can free the slot once all in-flight work also completes. void (async () => { await this.invokeSettledCallback({ status: 'rejected', requestId: state.requestId, request: state.request, error }); state.callbackInvoked = true; this.checkRequestDrain(state); })(); } /** * Invokes the onRequestSettled callback and routes any errors through * {@link handleSettlementFailure}. * * @param event - The settlement event to deliver. */ async invokeSettledCallback(event) { try { await this.config.onRequestSettled(event); } catch (err) { await this.handleSettlementFailure(event, err); } } /** * Awaits the onRequestSettled callback, then removes the request from * active state and wakes the admission pump. Used for successful settlements * where all work is already complete. * * @param state - The request state being settled. * @param event - The settlement event to deliver. */ async invokeSettledCallbackAndCleanup(state, event) { await this.invokeSettledCallback(event); this.removeFromActiveState(state); } /** * Checks whether a settled request has fully drained: the onRequestSettled * callback has been awaited, all preparation jobs have finished, all * simulation results have been received, and all deny-detail round trips * have completed. Only then is the request removed from active state. * * @param state - The request state to check. */ checkRequestDrain(state) { if (!state.settled) return; if (!state.callbackInvoked) return; if (state.pendingPreparationJobs > 0) return; if (state.created !== state.completed) return; if (state.pendingDenyDetailsChecks > 0) return; this.removeFromActiveState(state); } /** * Removes a request from active state, wakes the admission pump to fill * the freed slot, and checks if the processor is now idle. * * @param state - The request state to remove. */ removeFromActiveState(state) { const idx = this.activeRequestOrder.indexOf(state.requestId); if (idx !== -1) { this.activeRequestOrder.splice(idx, 1); } this.requestStates.delete(state.requestId); this.wakeAdmissionPump(); this.checkIdle(); } // ────────────────────────────────────────────────────────────────────────── // Idle checking // ────────────────────────────────────────────────────────────────────────── /** * Returns true if the processor has no pending, active, or in-flight work. * * @returns true if fully idle. */ isIdle() { return this.pendingRequests.length === 0 && this.activeRequestOrder.length === 0; } /** * Checks whether the processor has become idle and resolves or rejects the * waitForIdle promise if so. */ checkIdle() { if (!this.isIdle()) return; if (this.idleWaiters.length === 0) return; const waiters = this.idleWaiters.splice(0); if (this.fatalError) { for (const waiter of waiters) { waiter.reject(this.fatalError); } } else { for (const waiter of waiters) { waiter.resolve(); } } } /** * Handles an error thrown by the `onRequestSettled` callback. If the * `onSettlementFailure` callback is defined, it is invoked with the event * and error; any error it throws is silently ignored. If not defined, a * warning is logged. * * @param event - The settlement event that was being delivered. * @param err - The raw error thrown by the `onRequestSettled` callback. */ async handleSettlementFailure(event, err) { const error = err instanceof Error ? err : new Error(String(err)); if (this.config.onSettlementFailure) { try { await this.config.onSettlementFailure(event, error); } catch { // onSettlementFailure itself failed — silently ignored } } else { log_1.log.warn('onRequestSettled callback failed and no onSettlementFailure handler is defined', { requestId: event.requestId, error }); } } /** * Handles an unexpected worker failure by marking the processor as dead, * terminating remaining workers, and rejecting all active and pending requests. * * @param error - The error that caused the worker failure. */ handleWorkerFailure(error) { this.workersDead = true; this.isShutdown = true; this.fatalError = error; // Terminate remaining workers (fire-and-forget) for (const worker of this.workers) { worker.terminate().catch(() => { }); } // Settle all active requests as failed for (const requestId of [...this.activeRequestOrder]) { const state = this.requestStates.get(requestId); if (state && !state.settled) { this.settleRequestAsError(state, error); } } // Reject all pending requests while (this.pendingRequests.length > 0) { const submitted = this.pendingRequests.shift(); const event = { status: 'rejected', requestId: submitted.requestId, request: submitted.request, error }; void this.config .onRequestSettled(event) .catch((err) => this.handleSettlementFailure(event, err)) .catch(() => { }); } // Reject all idle waiters const waiters = this.idleWaiters.splice(0); for (const waiter of waiters) { waiter.reject(error); } } } exports.WhoCanProcessor = WhoCanProcessor; //# sourceMappingURL=WhoCanProcessor.js.map