diff --git a/config.schema.json b/config.schema.json index 7d132a1ab..e42379fd1 100644 --- a/config.schema.json +++ b/config.schema.json @@ -284,6 +284,13 @@ "type": "string" } }, + "eventHandlers": { + "type": "array", + "description": "List of event handler modules that observe GitProxy lifecycle events (push/pull started, completed, error, permission denied). Handlers run asynchronously and are isolated from the request path. Each value is either a file path or a module name.", + "items": { + "type": "string" + } + }, "authorisedList": { "description": "List of repositories that are authorised to be pushed to through the proxy.", "type": "array", diff --git a/package.json b/package.json index 782d645df..485483c8a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ "import": "./dist/src/plugin.js", "require": "./dist/src/plugin.js" }, + "./eventHandlers": { + "types": "./dist/src/eventHandlers/index.d.ts", + "import": "./dist/src/eventHandlers/index.js", + "require": "./dist/src/eventHandlers/index.js" + }, "./proxy": { "types": "./dist/src/proxy/index.d.ts", "import": "./dist/src/proxy/index.js", diff --git a/proxy.config.json b/proxy.config.json index f97332a69..f5c979678 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -140,6 +140,7 @@ "contactEmail": "", "csrfProtection": true, "plugins": [], + "eventHandlers": [], "apiAuthentication": [ { "type": "jwt", diff --git a/src/config/generated/config.ts b/src/config/generated/config.ts index aa0c04e93..d836f5c03 100644 --- a/src/config/generated/config.ts +++ b/src/config/generated/config.ts @@ -55,6 +55,12 @@ export interface GitProxyConfig { * Provide custom URLs for the git proxy interfaces in case it cannot determine its own URL */ domains?: Domains; + /** + * List of event handler modules that observe GitProxy lifecycle events (push/pull started, + * completed, error, permission denied). Handlers run asynchronously and are isolated from + * the request path. Each value is either a file path or a module name. + */ + eventHandlers?: string[]; /** * List of plugins to integrate on GitProxy's push or pull actions. Each value is either a * file path or a module name. @@ -791,6 +797,7 @@ const typeMap: any = { { json: 'cookieSecret', js: 'cookieSecret', typ: u(undefined, '') }, { json: 'csrfProtection', js: 'csrfProtection', typ: u(undefined, true) }, { json: 'domains', js: 'domains', typ: u(undefined, r('Domains')) }, + { json: 'eventHandlers', js: 'eventHandlers', typ: u(undefined, a('')) }, { json: 'plugins', js: 'plugins', typ: u(undefined, a('')) }, { json: 'privateOrganizations', js: 'privateOrganizations', typ: u(undefined, a('any')) }, { json: 'proxyUrl', js: 'proxyUrl', typ: u(undefined, '') }, diff --git a/src/config/index.ts b/src/config/index.ts index 0d0691271..6ccecd2fb 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -304,6 +304,12 @@ export const getPlugins = () => { return config.plugins || []; }; +// Get loadable event handler modules (observe push/pull lifecycle events) +export const getEventHandlers = (): string[] => { + const config = loadFullConfiguration(); + return config.eventHandlers || []; +}; + export const getTLSKeyPemPath = (): string | undefined => { const config = loadFullConfiguration(); return config.tls?.key && config.tls.key !== '' ? config.tls.key : config.sslKeyPemPath; diff --git a/src/eventHandlers/EventHandlerPlugin.ts b/src/eventHandlers/EventHandlerPlugin.ts new file mode 100644 index 000000000..4132c8904 --- /dev/null +++ b/src/eventHandlers/EventHandlerPlugin.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IProxyEventRegistry } from './types'; + +/** + * A plugin which subscribes one or more event handlers to GitProxy lifecycle + * events. Loaded at startup from the `eventHandlers` config key. + * + * Subclasses (or instances constructed with a `register` function) declare + * their subscriptions inside `register(registry)`. The registry is the only + * way handlers are wired in; event handler plugins do not participate in the + * action chain and cannot block or modify operations. + */ +export class EventHandlerPlugin { + isGitProxyPlugin: boolean; + isGitProxyEventHandlerPlugin: boolean; + register: (registry: IProxyEventRegistry) => void; + + constructor(register: (registry: IProxyEventRegistry) => void) { + this.isGitProxyPlugin = true; + this.isGitProxyEventHandlerPlugin = true; + this.register = register; + } +} + +/** + * Checks whether a value looks like an EventHandlerPlugin (loaded from an + * external module). Mirrors `isCompatiblePlugin` in src/plugin.ts but checks + * for `register` rather than `exec`. + */ +export const isEventHandlerPlugin = (obj: unknown): obj is EventHandlerPlugin => { + let cursor: unknown = obj; + while (cursor != null) { + if ( + typeof cursor === 'object' && + Object.prototype.hasOwnProperty.call(cursor, 'isGitProxyEventHandlerPlugin') && + (cursor as { isGitProxyEventHandlerPlugin?: unknown }).isGitProxyEventHandlerPlugin === + true && + typeof (cursor as { register?: unknown }).register === 'function' + ) { + return true; + } + cursor = Object.getPrototypeOf(cursor); + } + return false; +}; diff --git a/src/eventHandlers/builtin/consoleLogger.ts b/src/eventHandlers/builtin/consoleLogger.ts new file mode 100644 index 000000000..67ede12b8 --- /dev/null +++ b/src/eventHandlers/builtin/consoleLogger.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventHandlerPlugin } from '../EventHandlerPlugin'; +import { ActionPhase, EventDetails, IProxyEventRegistry, ProxyOperation } from '../types'; + +const formatLine = (details: EventDetails): string => { + const payload: Record = { + event: 'gitproxy.action', + actionId: details.actionId, + operation: details.operation, + phase: details.phase, + timestamp: new Date(details.timestamp).toISOString(), + repo: details.repository.url, + }; + if (details.user?.username) payload.username = details.user.username; + if (details.user?.email) payload.userEmail = details.user.email; + if (details.error) payload.error = details.error.message; + return JSON.stringify(payload); +}; + +const log = (details: EventDetails): void => { + console.info(formatLine(details)); +}; + +const phases: ActionPhase[] = [ + 'started', + 'completed', + 'pendingReview', + 'error', + 'permissionDenied', +]; +const operations: ProxyOperation[] = ['push', 'pull']; + +/** + * Built-in event handler that emits a structured JSON line for every + * lifecycle event. Always loaded so deployments get an automatic audit trail + * without needing to write or configure a handler. To silence, filter at the + * log aggregator on `"event":"gitproxy.action"`. + */ +export const consoleLoggerEventHandler = new EventHandlerPlugin(function registerConsoleLogger( + registry: IProxyEventRegistry, +) { + for (const op of operations) { + const builder = op === 'push' ? registry.onPush() : registry.onPull(); + for (const phase of phases) { + if (phase === 'started') builder.onStarted(log); + else if (phase === 'completed') builder.onCompleted(log); + else if (phase === 'pendingReview') builder.onPendingReview(log); + else if (phase === 'error') builder.onError(log); + else if (phase === 'permissionDenied') builder.onPermissionDenied(log); + } + } +}); diff --git a/src/eventHandlers/dispatcher.ts b/src/eventHandlers/dispatcher.ts new file mode 100644 index 000000000..a30211e1e --- /dev/null +++ b/src/eventHandlers/dispatcher.ts @@ -0,0 +1,141 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Action } from '../proxy/actions'; +import { ProxyEventRegistry } from './registry'; +import { + ActionEventCallback, + ActionPhase, + EventDetails, + ProxyOperation, + RepositoryContext, + UserContext, +} from './types'; + +const buildRepositoryContext = (action: Action): RepositoryContext => ({ + url: action.url, + project: action.project, + name: action.repoName, +}); + +const buildUserContext = (action: Action): UserContext | undefined => { + if (!action.user && !action.userEmail) { + return undefined; + } + return { username: action.user, email: action.userEmail }; +}; + +/** + * Translates an Action's type string to the ProxyOperation surfaced to + * handlers, or null when the action should not produce an event (e.g. + * "default" actions used for protocol pings). + */ +const operationFor = (action: Action): ProxyOperation | null => { + if (action.type === 'push') return 'push'; + if (action.type === 'pull') return 'pull'; + return null; +}; + +export class EventDispatcher { + private readonly registry: ProxyEventRegistry; + private readonly inflight: Set> = new Set(); + + constructor(registry: ProxyEventRegistry) { + this.registry = registry; + } + + /** + * Invokes registered handlers for a phase. Returns synchronously — handlers + * run on the event loop without delaying the request path. Errors thrown by + * a handler are caught and logged; they never propagate. + */ + public dispatch(action: Action, phase: ActionPhase, error?: Error): void { + const operation = operationFor(action); + if (!operation) return; + + const handlers = this.registry.get(operation, phase); + if (handlers.length === 0) return; + + const details: EventDetails = { + actionId: action.id, + operation, + phase, + timestamp: Date.now(), + repository: buildRepositoryContext(action), + user: buildUserContext(action), + error, + }; + + for (const handler of handlers) { + this.invoke(handler, details); + } + } + + private invoke(handler: ActionEventCallback, details: EventDetails): void { + const handlerName = handler.name || 'anonymous'; + let promise: Promise; + try { + promise = Promise.resolve(handler(details)); + } catch (err) { + this.logHandlerError(handlerName, details, err); + return; + } + + const tracked = promise.catch((err) => this.logHandlerError(handlerName, details, err)); + this.inflight.add(tracked); + tracked.finally(() => this.inflight.delete(tracked)); + } + + private logHandlerError(handlerName: string, details: EventDetails, err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + console.error( + `Event handler "${handlerName}" failed for ${details.operation}.${details.phase}: ${message}`, + ); + } + + /** + * Awaits in-flight handler promises, bounded by `timeoutMs`. Used by + * Proxy.stop() to give handlers a chance to finish before the process + * exits. Handlers still running past the timeout are abandoned with a warning. + */ + async drain(timeoutMs: number): Promise { + if (this.inflight.size === 0) return; + + const inflightCount = this.inflight.size; + const all = Promise.allSettled([...this.inflight]).then(() => undefined); + const timeout = new Promise<'timeout'>((resolve) => + setTimeout(() => resolve('timeout'), timeoutMs), + ); + const result = await Promise.race([all.then(() => 'done' as const), timeout]); + if (result === 'timeout') { + console.warn( + `Event dispatcher drain timed out after ${timeoutMs}ms with ${this.inflight.size}/${inflightCount} handler(s) still running; abandoning.`, + ); + } + } +} + +let activeDispatcher: EventDispatcher | null = null; + +export const setEventDispatcher = (dispatcher: EventDispatcher | null): void => { + activeDispatcher = dispatcher; +}; + +export const getEventDispatcher = (): EventDispatcher | null => activeDispatcher; + +export const resetEventDispatcher = (): void => { + activeDispatcher = null; +}; diff --git a/src/eventHandlers/index.ts b/src/eventHandlers/index.ts new file mode 100644 index 000000000..b23683ce3 --- /dev/null +++ b/src/eventHandlers/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './types'; +export { EventHandlerPlugin, isEventHandlerPlugin } from './EventHandlerPlugin'; diff --git a/src/eventHandlers/loader.ts b/src/eventHandlers/loader.ts new file mode 100644 index 000000000..054dffd2b --- /dev/null +++ b/src/eventHandlers/loader.ts @@ -0,0 +1,105 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { loadPlugin, resolvePlugin } from 'load-plugin'; + +import { handleErrorAndLog } from '../utils/errors'; +import { EventHandlerPlugin, isEventHandlerPlugin } from './EventHandlerPlugin'; +import { IProxyEventRegistry } from './types'; + +/** + * Loads event handler modules listed in the `eventHandlers` config key and + * registers their handlers on the supplied registry. Mirrors the structure of + * `PluginLoader` in src/plugin.ts so deployers see the same module-resolution + * behaviour as for chain plugins. + */ +export class EventHandlerLoader { + targets: string[]; + plugins: EventHandlerPlugin[] = []; + + constructor(targets: string[]) { + this.targets = targets; + if (targets.length === 0) { + console.log('No event handlers configured'); + } + } + + async load(): Promise { + try { + const moduleResults = await Promise.allSettled( + this.targets.map((target) => + this.loadModule(target).catch((error) => { + console.error(`Failed to load event handler: ${error}`); + return Promise.reject(error); + }), + ), + ); + + const modules = moduleResults + .filter( + (r): r is PromiseFulfilledResult => + r.status === 'fulfilled' && r.value !== null && r.value !== undefined, + ) + .map((r) => r.value); + + console.log(`Found ${modules.length} event handler module(s)`); + + for (const mod of modules) { + this.collect(mod); + } + + this.plugins.forEach((p) => + console.log(`Loaded event handler: ${p.constructor?.name ?? 'EventHandlerPlugin'}`), + ); + } catch (error: unknown) { + handleErrorAndLog(error, 'Error loading event handlers'); + } + } + + registerAll(registry: IProxyEventRegistry): void { + for (const plugin of this.plugins) { + try { + plugin.register(registry); + } catch (error: unknown) { + handleErrorAndLog( + error, + `Failed to register event handler ${plugin.constructor?.name ?? 'EventHandlerPlugin'}`, + ); + } + } + } + + private async loadModule(target: string): Promise { + const resolved = await resolvePlugin(target); + return loadPlugin(resolved); + } + + private collect(mod: unknown): void { + if (mod === null || typeof mod !== 'object') return; + + if (isEventHandlerPlugin(mod)) { + this.plugins.push(mod); + return; + } + + for (const key of Object.keys(mod as Record)) { + const value = (mod as Record)[key]; + if (isEventHandlerPlugin(value)) { + this.plugins.push(value); + } + } + } +} diff --git a/src/eventHandlers/registry.ts b/src/eventHandlers/registry.ts new file mode 100644 index 000000000..71fa21ced --- /dev/null +++ b/src/eventHandlers/registry.ts @@ -0,0 +1,89 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ActionEventCallback, + ActionErrorEventCallback, + ActionPhase, + IActionEventHandler, + IProxyEventRegistry, + ProxyOperation, +} from './types'; + +type HandlerKey = `${ProxyOperation}:${ActionPhase}`; + +const keyFor = (op: ProxyOperation, phase: ActionPhase): HandlerKey => `${op}:${phase}`; + +class ActionEventHandlerBuilder implements IActionEventHandler { + private readonly registry: ProxyEventRegistry; + private readonly operation: ProxyOperation; + + constructor(registry: ProxyEventRegistry, operation: ProxyOperation) { + this.registry = registry; + this.operation = operation; + } + + onStarted(handler: ActionEventCallback): IActionEventHandler { + this.registry.add(this.operation, 'started', handler); + return this; + } + + onCompleted(handler: ActionEventCallback): IActionEventHandler { + this.registry.add(this.operation, 'completed', handler); + return this; + } + + onPendingReview(handler: ActionEventCallback): IActionEventHandler { + this.registry.add(this.operation, 'pendingReview', handler); + return this; + } + + onError(handler: ActionErrorEventCallback): IActionEventHandler { + this.registry.add(this.operation, 'error', handler); + return this; + } + + onPermissionDenied(handler: ActionEventCallback): IActionEventHandler { + this.registry.add(this.operation, 'permissionDenied', handler); + return this; + } +} + +export class ProxyEventRegistry implements IProxyEventRegistry { + private readonly handlers: Map = new Map(); + + onPush(): IActionEventHandler { + return new ActionEventHandlerBuilder(this, 'push'); + } + + onPull(): IActionEventHandler { + return new ActionEventHandlerBuilder(this, 'pull'); + } + + add(operation: ProxyOperation, phase: ActionPhase, handler: ActionEventCallback): void { + const key = keyFor(operation, phase); + const existing = this.handlers.get(key); + if (existing) { + existing.push(handler); + } else { + this.handlers.set(key, [handler]); + } + } + + get(operation: ProxyOperation, phase: ActionPhase): ActionEventCallback[] { + return this.handlers.get(keyFor(operation, phase)) ?? []; + } +} diff --git a/src/eventHandlers/types.ts b/src/eventHandlers/types.ts new file mode 100644 index 000000000..0400814d1 --- /dev/null +++ b/src/eventHandlers/types.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Logical operations exposed to event handlers. Mirrors Action.type for the + * subset that is meaningful to integrations. `pull` covers all git-upload-pack + * operations (fetch / clone / pull) — git-proxy does not distinguish them. + */ +export type ProxyOperation = 'push' | 'pull'; + +/** + * Lifecycle phase of an action. + * + * - `started`: parseAction has succeeded; the chain is about to run. User + * identity may not yet be resolved at this point. + * - `completed`: the chain ran to its terminal step and reached a resolved + * outcome — the push was approved (allowPush=true) or auto-approved / + * auto-rejected by the system. + * - `pendingReview`: the chain ran to its terminal step and the push was + * blocked awaiting manual approval. This is not a denial or an error; the + * push sits in the approval queue until a reviewer acts on it. + * - `error`: an unhandled exception or step error aborted the chain. + * - `permissionDenied`: the user lacks push permission for the repo. + */ +export type ActionPhase = 'started' | 'completed' | 'pendingReview' | 'error' | 'permissionDenied'; + +export interface RepositoryContext { + url: string; + project: string; + name: string; +} + +export interface UserContext { + username?: string; + email?: string; +} + +export interface EventDetails { + actionId: string; + operation: ProxyOperation; + phase: ActionPhase; + timestamp: number; + repository: RepositoryContext; + user?: UserContext; + error?: Error; +} + +export type ActionEventCallback = (details: EventDetails) => void | Promise; +export type ActionErrorEventCallback = (details: EventDetails) => void | Promise; + +/** + * Builder returned by IProxyEventRegistry.onPush() / .onPull(). + * Methods return `this` to allow chaining multiple phase handlers. + */ +export interface IActionEventHandler { + onStarted(handler: ActionEventCallback): IActionEventHandler; + onCompleted(handler: ActionEventCallback): IActionEventHandler; + onPendingReview(handler: ActionEventCallback): IActionEventHandler; + onError(handler: ActionErrorEventCallback): IActionEventHandler; + onPermissionDenied(handler: ActionEventCallback): IActionEventHandler; +} + +/** + * Registry through which integrations subscribe to git-proxy lifecycle events. + * + * Handlers are observers only - they cannot block, modify, or reject git + * operations. For blocking/policy logic, write a chain plugin instead. + * + * Handlers run asynchronously, fire-and-forget. Errors thrown by a handler + * are caught and logged but never propagate to the git response. + */ +export interface IProxyEventRegistry { + onPush(): IActionEventHandler; + /** + * Subscribes to git-upload-pack operations (fetch / clone / pull). + * git-proxy does not distinguish these three at the protocol level. + */ + onPull(): IActionEventHandler; +} diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index 38bc9c5bd..245a5b28f 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -35,6 +35,7 @@ class Action { errorMessage?: string | null; blocked: boolean = false; blockedMessage?: string | null; + permissionDenied: boolean = false; allowPush: boolean = false; authorised: boolean = false; canceled: boolean = false; diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index ab63f1f8d..d8bb13254 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -21,6 +21,8 @@ import { Action } from './actions'; import * as proc from './processors'; import { attemptAutoApproval, attemptAutoRejection } from './actions/autoActions'; import { handleErrorAndLog } from '../utils/errors'; +import { getEventDispatcher } from '../eventHandlers/dispatcher'; +import { ActionPhase } from '../eventHandlers/types'; const pushActionChain: ((req: Request, action: Action) => Promise)[] = [ proc.push.parsePush, @@ -56,6 +58,7 @@ export const executeChain = async (req: Request, _res: Response): Promise { + const dispatcher = getEventDispatcher(); + if (!dispatcher) return; + // Precedence matters: permissionDenied also flips action.error (the chain + // step sets step.error to break the chain), so check it first. A push that + // was blocked for manual approval (action.blocked) surfaces as + // `pendingReview`, unless the system already auto-resolved it — in which + // case it is a resolved outcome and surfaces as `completed`. + let phase: ActionPhase; + let error: Error | undefined; + if (action.permissionDenied) { + phase = 'permissionDenied'; + } else if (action.error) { + phase = 'error'; + error = action.errorMessage ? new Error(action.errorMessage) : new Error('Chain error'); + } else if (action.blocked && !action.autoApproved && !action.autoRejected) { + phase = 'pendingReview'; + } else { + phase = 'completed'; + } + dispatcher.dispatch(action, phase, error); +}; + /** * The plugin loader used for the GitProxy chain. * @type {import('../plugin').PluginLoader} diff --git a/src/proxy/index.ts b/src/proxy/index.ts index a15a594fb..a1e7aa70b 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -21,6 +21,7 @@ import fs from 'fs'; import { getRouter } from './routes'; import { getAuthorisedList, + getEventHandlers, getPlugins, getTLSKeyPemPath, getTLSCertPemPath, @@ -31,6 +32,13 @@ import { PluginLoader } from '../plugin'; import chain from './chain'; import { Repo } from '../db/types'; import { serverConfig } from '../config/env'; +import { ProxyEventRegistry } from '../eventHandlers/registry'; +import { + EventDispatcher, + getEventDispatcher, + setEventDispatcher, +} from '../eventHandlers/dispatcher'; +import { EventHandlerLoader } from '../eventHandlers/loader'; const { GIT_PROXY_SERVER_PORT: proxyHttpPort, GIT_PROXY_HTTPS_SERVER_PORT: proxyHttpsPort } = serverConfig; @@ -73,6 +81,13 @@ export class Proxy { const pluginLoader = new PluginLoader(plugins); await pluginLoader.load(); chain.chainPluginLoader = pluginLoader; + + const eventRegistry = new ProxyEventRegistry(); + const eventHandlerLoader = new EventHandlerLoader(getEventHandlers()); + await eventHandlerLoader.load(); + eventHandlerLoader.registerAll(eventRegistry); + setEventDispatcher(new EventDispatcher(eventRegistry)); + // Check to see if the default repos are in the repo list const defaultAuthorisedRepoList = getAuthorisedList(); const allowedList: Repo[] = await getRepos(); @@ -124,7 +139,14 @@ export class Proxy { return this.expressApp; } - public stop(): Promise { + public async stop(): Promise { + // Give in-flight event handlers a bounded chance to finish before tearing + // down the process. Handlers still running past the timeout are abandoned. + const dispatcher = getEventDispatcher(); + if (dispatcher) { + await dispatcher.drain(5000); + } + const closePromises: Promise[] = []; // Close HTTP server if it exists @@ -161,6 +183,6 @@ export class Proxy { ); } - return Promise.all(closePromises).then(() => {}); + await Promise.all(closePromises); } } diff --git a/src/proxy/processors/push-action/checkUserPushPermission.ts b/src/proxy/processors/push-action/checkUserPushPermission.ts index 934176586..96bd5e381 100644 --- a/src/proxy/processors/push-action/checkUserPushPermission.ts +++ b/src/proxy/processors/push-action/checkUserPushPermission.ts @@ -76,6 +76,7 @@ const validateUser = async (userEmail: string, action: Action, step: Step): Prom `${action.url})`, ); action.addStep(step); + action.permissionDenied = true; return action; } diff --git a/test/eventHandlerLoader.test.ts b/test/eventHandlerLoader.test.ts new file mode 100644 index 000000000..518c02683 --- /dev/null +++ b/test/eventHandlerLoader.test.ts @@ -0,0 +1,178 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Action } from '../src/proxy/actions'; +import { ProxyEventRegistry } from '../src/eventHandlers/registry'; +import { EventDispatcher } from '../src/eventHandlers/dispatcher'; +import { EventHandlerPlugin } from '../src/eventHandlers/EventHandlerPlugin'; +import { consoleLoggerEventHandler } from '../src/eventHandlers/builtin/consoleLogger'; + +// Module-level map of "configured target" -> "loaded module value", used by +// the load-plugin mock below to simulate proxy.config.json's eventHandlers +// array resolving to handler modules at runtime. +const fakeModules = new Map(); + +vi.mock('load-plugin', () => ({ + resolvePlugin: async (target: string) => target, + loadPlugin: async (target: string) => { + if (!fakeModules.has(target)) { + throw new Error(`No fake module registered for "${target}"`); + } + return fakeModules.get(target); + }, +})); + +// Imported AFTER vi.mock so the loader picks up the mocked load-plugin. +import { EventHandlerLoader } from '../src/eventHandlers/loader'; + +const buildPushAction = (): Action => { + const a = new Action( + 'cfg-action', + 'push', + 'POST', + Date.now(), + 'https://github.com/finos/git-proxy.git', + ); + a.user = 'alice'; + a.userEmail = 'alice@example.com'; + return a; +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('EventHandlerLoader (configured via proxy.config.json eventHandlers)', () => { + let infoSpy: ReturnType; + + beforeEach(() => { + fakeModules.clear(); + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + }); + + afterEach(() => { + infoSpy.mockRestore(); + }); + + it('loads the built-in consoleLogger when listed in config and fires its handlers', async () => { + // Simulate `eventHandlers: ["@finos/git-proxy/eventHandlers/consoleLogger"]` + // in proxy.config.json by mapping the configured target to the actual + // shipped EventHandlerPlugin instance. + const configuredTarget = '@finos/git-proxy/eventHandlers/consoleLogger'; + fakeModules.set(configuredTarget, consoleLoggerEventHandler); + + const registry = new ProxyEventRegistry(); + const loader = new EventHandlerLoader([configuredTarget]); + await loader.load(); + loader.registerAll(registry); + + expect(loader.plugins).toHaveLength(1); + expect(loader.plugins[0]).toBe(consoleLoggerEventHandler); + + // The registered handler must actually fire on dispatched events. + const dispatcher = new EventDispatcher(registry); + dispatcher.dispatch(buildPushAction(), 'completed'); + await dispatcher.drain(100); + + expect(infoSpy).toHaveBeenCalledTimes(1); + const logged = JSON.parse(infoSpy.mock.calls[0][0] as string); + expect(logged).toMatchObject({ + event: 'gitproxy.action', + operation: 'push', + phase: 'completed', + repo: 'https://github.com/finos/git-proxy.git', + username: 'alice', + userEmail: 'alice@example.com', + }); + }); + + it('loads multiple handlers from one module (named exports)', async () => { + const a = vi.fn(); + const b = vi.fn(); + const moduleObject = { + pushObserver: new EventHandlerPlugin((r) => r.onPush().onCompleted(a)), + pullObserver: new EventHandlerPlugin((r) => r.onPull().onCompleted(b)), + }; + fakeModules.set('./fake-multi-export', moduleObject); + + const registry = new ProxyEventRegistry(); + const loader = new EventHandlerLoader(['./fake-multi-export']); + await loader.load(); + loader.registerAll(registry); + + expect(loader.plugins).toHaveLength(2); + + const dispatcher = new EventDispatcher(registry); + dispatcher.dispatch(buildPushAction(), 'completed'); + + const pullAction = new Action('p', 'pull', 'GET', 0, 'https://github.com/x/y.git'); + dispatcher.dispatch(pullAction, 'completed'); + await dispatcher.drain(100); + + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('skips modules that do not export an EventHandlerPlugin', async () => { + fakeModules.set('./not-a-plugin', { hello: 'world' }); + + const registry = new ProxyEventRegistry(); + const loader = new EventHandlerLoader(['./not-a-plugin']); + await loader.load(); + loader.registerAll(registry); + + expect(loader.plugins).toHaveLength(0); + expect(registry.get('push', 'completed')).toEqual([]); + }); + + it('continues loading when one configured target fails to resolve', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const survivor = vi.fn(); + fakeModules.set('./ok', new EventHandlerPlugin((r) => r.onPush().onStarted(survivor))); + // './missing' is intentionally not registered — loadPlugin will throw. + + const registry = new ProxyEventRegistry(); + const loader = new EventHandlerLoader(['./missing', './ok']); + await loader.load(); + loader.registerAll(registry); + + expect(loader.plugins).toHaveLength(1); + + const dispatcher = new EventDispatcher(registry); + dispatcher.dispatch(buildPushAction(), 'started'); + await sleep(0); + expect(survivor).toHaveBeenCalledTimes(1); + + errSpy.mockRestore(); + }); + + it('isolates a register() that throws so other handlers still register', () => { + const ok = vi.fn(); + const bad = new EventHandlerPlugin(() => { + throw new Error('register boom'); + }); + const good = new EventHandlerPlugin((r) => r.onPush().onCompleted(ok)); + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const registry = new ProxyEventRegistry(); + const loader = new EventHandlerLoader([]); + loader.plugins.push(bad, good); + + expect(() => loader.registerAll(registry)).not.toThrow(); + expect(registry.get('push', 'completed')).toEqual([ok]); + errSpy.mockRestore(); + }); +}); diff --git a/test/eventHandlers.test.ts b/test/eventHandlers.test.ts new file mode 100644 index 000000000..b5d0c308a --- /dev/null +++ b/test/eventHandlers.test.ts @@ -0,0 +1,261 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Action } from '../src/proxy/actions'; +import { ProxyEventRegistry } from '../src/eventHandlers/registry'; +import { + EventDispatcher, + getEventDispatcher, + resetEventDispatcher, + setEventDispatcher, +} from '../src/eventHandlers/dispatcher'; +import { EventHandlerPlugin, isEventHandlerPlugin } from '../src/eventHandlers/EventHandlerPlugin'; +import { consoleLoggerEventHandler } from '../src/eventHandlers/builtin/consoleLogger'; +import { EventDetails } from '../src/eventHandlers/types'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const buildPushAction = (overrides: Partial = {}): Action => { + const a = new Action( + 'action-1', + 'push', + 'POST', + Date.now(), + 'https://github.com/finos/git-proxy.git', + ); + a.user = 'alice'; + a.userEmail = 'alice@example.com'; + return Object.assign(a, overrides); +}; + +describe('ProxyEventRegistry', () => { + it('chains phase subscriptions and stores them per operation', () => { + const registry = new ProxyEventRegistry(); + const a = vi.fn(); + const b = vi.fn(); + const c = vi.fn(); + registry.onPush().onCompleted(a).onError(b).onPendingReview(c); + expect(registry.get('push', 'completed')).toEqual([a]); + expect(registry.get('push', 'error')).toEqual([b]); + expect(registry.get('push', 'pendingReview')).toEqual([c]); + expect(registry.get('pull', 'completed')).toEqual([]); + }); + + it('keeps push and pull handlers separate', () => { + const registry = new ProxyEventRegistry(); + const onPush = vi.fn(); + const onPull = vi.fn(); + registry.onPush().onStarted(onPush); + registry.onPull().onStarted(onPull); + expect(registry.get('push', 'started')).toEqual([onPush]); + expect(registry.get('pull', 'started')).toEqual([onPull]); + }); +}); + +describe('EventDispatcher.dispatch', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('returns synchronously even when handlers are async', () => { + const registry = new ProxyEventRegistry(); + let resolved = false; + registry.onPush().onCompleted(async () => { + await sleep(20); + resolved = true; + }); + const dispatcher = new EventDispatcher(registry); + + const before = Date.now(); + dispatcher.dispatch(buildPushAction(), 'completed'); + const elapsed = Date.now() - before; + expect(elapsed).toBeLessThan(10); + expect(resolved).toBe(false); + }); + + it('passes EventDetails with action fields to handlers', async () => { + const registry = new ProxyEventRegistry(); + const captured: EventDetails[] = []; + registry.onPush().onCompleted((d) => { + captured.push(d); + }); + const dispatcher = new EventDispatcher(registry); + + dispatcher.dispatch(buildPushAction(), 'completed'); + await dispatcher.drain(100); + + expect(captured).toHaveLength(1); + expect(captured[0]).toMatchObject({ + operation: 'push', + phase: 'completed', + repository: { + url: 'https://github.com/finos/git-proxy.git', + project: 'finos', + name: 'git-proxy.git', + }, + user: { username: 'alice', email: 'alice@example.com' }, + }); + }); + + it('skips dispatch entirely for default-typed actions', async () => { + const registry = new ProxyEventRegistry(); + const handler = vi.fn(); + registry.onPush().onCompleted(handler); + registry.onPull().onCompleted(handler); + const dispatcher = new EventDispatcher(registry); + + const action = new Action('id', 'default', 'GET', 0, 'https://github.com/x/y.git'); + dispatcher.dispatch(action, 'completed'); + await dispatcher.drain(50); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('isolates handler errors from each other and does not throw', async () => { + const registry = new ProxyEventRegistry(); + const ok = vi.fn(); + registry + .onPush() + .onCompleted(() => { + throw new Error('sync boom'); + }) + .onCompleted(async () => { + throw new Error('async boom'); + }) + .onCompleted(ok); + const dispatcher = new EventDispatcher(registry); + + expect(() => dispatcher.dispatch(buildPushAction(), 'completed')).not.toThrow(); + await dispatcher.drain(100); + + expect(ok).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('drain awaits in-flight handler promises', async () => { + const registry = new ProxyEventRegistry(); + let finished = false; + registry.onPush().onCompleted(async () => { + await sleep(50); + finished = true; + }); + const dispatcher = new EventDispatcher(registry); + + dispatcher.dispatch(buildPushAction(), 'completed'); + await dispatcher.drain(500); + expect(finished).toBe(true); + }); + + it('drain warns and returns when timeout expires before handlers settle', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const registry = new ProxyEventRegistry(); + registry.onPush().onCompleted(async () => { + await sleep(200); + }); + const dispatcher = new EventDispatcher(registry); + + dispatcher.dispatch(buildPushAction(), 'completed'); + await dispatcher.drain(20); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('module-level dispatcher singleton', () => { + afterEach(() => { + resetEventDispatcher(); + }); + + it('set / get / reset round-trip', () => { + const d = new EventDispatcher(new ProxyEventRegistry()); + expect(getEventDispatcher()).toBeNull(); + setEventDispatcher(d); + expect(getEventDispatcher()).toBe(d); + resetEventDispatcher(); + expect(getEventDispatcher()).toBeNull(); + }); +}); + +describe('EventHandlerPlugin', () => { + it('marks instances as compatible plugins', () => { + const plugin = new EventHandlerPlugin(() => {}); + expect(isEventHandlerPlugin(plugin)).toBe(true); + expect(plugin.isGitProxyPlugin).toBe(true); + expect(plugin.isGitProxyEventHandlerPlugin).toBe(true); + }); + + it('rejects unrelated objects', () => { + expect(isEventHandlerPlugin({})).toBe(false); + expect(isEventHandlerPlugin({ isGitProxyEventHandlerPlugin: true })).toBe(false); + expect(isEventHandlerPlugin(null)).toBe(false); + }); + + it('register() is invoked with the registry', () => { + const fn = vi.fn(); + const plugin = new EventHandlerPlugin(fn); + const registry = new ProxyEventRegistry(); + plugin.register(registry); + expect(fn).toHaveBeenCalledWith(registry); + }); +}); + +describe('consoleLoggerEventHandler', () => { + let infoSpy: ReturnType; + + beforeEach(() => { + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + }); + + afterEach(() => { + infoSpy.mockRestore(); + }); + + it('logs a structured JSON line for every push phase', async () => { + const registry = new ProxyEventRegistry(); + consoleLoggerEventHandler.register(registry); + const dispatcher = new EventDispatcher(registry); + + dispatcher.dispatch(buildPushAction(), 'started'); + dispatcher.dispatch(buildPushAction(), 'completed'); + dispatcher.dispatch(buildPushAction(), 'pendingReview'); + dispatcher.dispatch(buildPushAction(), 'permissionDenied'); + dispatcher.dispatch(buildPushAction(), 'error', new Error('boom')); + await dispatcher.drain(100); + + expect(infoSpy).toHaveBeenCalledTimes(5); + const logged = infoSpy.mock.calls.map((c) => JSON.parse(c[0] as string)); + expect(logged.map((l) => l.phase)).toEqual([ + 'started', + 'completed', + 'pendingReview', + 'permissionDenied', + 'error', + ]); + expect(logged[4].error).toBe('boom'); + for (const line of logged) { + expect(line.event).toBe('gitproxy.action'); + expect(line.operation).toBe('push'); + } + }); +}); diff --git a/test/testProxy.test.ts b/test/testProxy.test.ts index 4f4883c16..e72a7608e 100644 --- a/test/testProxy.test.ts +++ b/test/testProxy.test.ts @@ -53,6 +53,7 @@ vi.mock('../src/config', () => ({ getTLSKeyPemPath: vi.fn(), getTLSCertPemPath: vi.fn(), getPlugins: vi.fn(), + getEventHandlers: vi.fn(() => []), getAuthorisedList: vi.fn(), })); diff --git a/website/docs/architecture/architecture.md b/website/docs/architecture/architecture.md index 97da3ce38..06314c1e3 100644 --- a/website/docs/architecture/architecture.md +++ b/website/docs/architecture/architecture.md @@ -414,6 +414,46 @@ Defines a list of plugins to integrate on GitProxy's push or pull actions. Accep See the [plugin guide](../development/plugins) for more setup details. +#### `eventHandlers` + +Defines a list of event handler modules that observe GitProxy lifecycle events. Like [`plugins`](#plugins), each value is either a file path or a module name, and modules are resolved the same way. + +Unlike chain plugins, event handlers are **observers only** — they cannot block, modify or reject a Git operation. They run asynchronously, fire-and-forget, off the request path: a handler that is slow or throws will never delay or fail a push/pull. Use them to react to GitProxy activity, e.g. emitting metrics, sending notifications or writing to an external audit log. + +Sample values: + +```json +"eventHandlers": [ + "./my-event-handler.js", + "@my-org/gitproxy-notifier" +] +``` + +A handler module exports one or more `EventHandlerPlugin` instances. Each one receives a registry through which it subscribes to events for `push` and/or `pull` operations. The following lifecycle phases are emitted: + +- **`started`**: the chain is about to run. User identity may not be resolved yet. +- **`completed`**: the chain finished at a resolved outcome — the operation was approved (`allowPush`) or auto-approved/auto-rejected by the system. +- **`pendingReview`**: the chain finished and the push was blocked awaiting manual approval. It now sits in the approval queue until a reviewer acts on it. This is not a denial or an error. +- **`error`**: an unhandled exception or step error aborted the chain. +- **`permissionDenied`**: the user lacks push permission for the repository. + +Example handler subscribing to push events: + +```js +const { EventHandlerPlugin } = require('@finos/git-proxy/eventHandlers'); + +module.exports.notifier = new EventHandlerPlugin((registry) => { + registry + .onPush() + .onPendingReview((details) => { + console.log(`Push ${details.actionId} on ${details.repository.url} awaits review`); + }) + .onError((details) => { + console.error(`Push ${details.actionId} failed: ${details.error?.message}`); + }); +}); +``` + #### `authorisedList` Defines a initial list of repositories that are allowed to be pushed to through the proxy. Note that **repositories can also be added through the UI, API or by manually editing the database**.