diff --git a/controlplane/src/core/build-server.ts b/controlplane/src/core/build-server.ts index 4ecb4e8173..bdc6096e6b 100644 --- a/controlplane/src/core/build-server.ts +++ b/controlplane/src/core/build-server.ts @@ -8,6 +8,7 @@ import { compressionBrotli, compressionGzip } from '@connectrpc/connect-node'; import fastifyGracefulShutdown from 'fastify-graceful-shutdown'; import { App } from 'octokit'; import { Worker } from 'bullmq'; +import * as Sentry from '@sentry/node'; import routes from './routes.js'; import fastifyHealth from './plugins/health.js'; import fastifyMetrics, { MetricsPluginOptions } from './plugins/metrics.js'; @@ -136,6 +137,10 @@ export interface BuildConfig { key?: string; // e.g. string or '/path/to/my/client-key.pem' }; }; + sentry: { + enabled: boolean; + dsn?: string; + }; } export interface MetricsOptions { @@ -249,7 +254,37 @@ export default async function build(opts: BuildConfig) { const webAuth = new WebSessionAuthenticator(opts.auth.secret, userRepo); const graphKeyAuth = new GraphApiTokenAuthenticator(opts.auth.secret); const accessTokenAuth = new AccessTokenAuthenticator(organizationRepository, authUtils); - const authenticator = new Authentication(webAuth, apiKeyAuth, accessTokenAuth, graphKeyAuth, organizationRepository); + const authenticator = new Authentication( + webAuth, + apiKeyAuth, + accessTokenAuth, + graphKeyAuth, + organizationRepository, + (authContext) => { + if (opts.sentry.enabled && opts.sentry.dsn) { + try { + Sentry.setUser({ + id: authContext.userId, + username: authContext.userDisplayName, + }); + + Sentry.setTag('org.id', authContext.organizationId); + + if (authContext.organizationSlug) { + Sentry.setTag('org.slug', authContext.organizationSlug); + } + + if (authContext.apiKeyName) { + Sentry.setTag('api.key', authContext.apiKeyName); + } + + Sentry.setTag('auth.kind', authContext.auth); + } catch (error) { + logger.debug({ err: error }, 'Failed to enrich Sentry user context'); + } + } + }, + ); const authorizer = new Authorization(logger, opts.stripe?.defaultPlanId); diff --git a/controlplane/src/core/sentry.config.ts b/controlplane/src/core/sentry.config.ts index 923a609f2a..4242b88f9c 100644 --- a/controlplane/src/core/sentry.config.ts +++ b/controlplane/src/core/sentry.config.ts @@ -1,3 +1,4 @@ +import { createRequire } from 'node:module'; import * as Sentry from '@sentry/node'; import { nodeProfilingIntegration } from '@sentry/profiling-node'; import { eventLoopBlockIntegration } from '@sentry/node-native'; @@ -16,8 +17,18 @@ const { } = envVariables.parse(process.env); if (SENTRY_ENABLED && SENTRY_DSN) { + const require = createRequire(import.meta.url); + let release: string | undefined; + try { + const pkg = require('../../package.json') as { version?: string }; + release = pkg.version; + } catch (error) { + console.debug('Sentry: failed to read package.json version for release', error); + } + Sentry.init({ dsn: SENTRY_DSN, + release, integrations: [ fastifyIntegration(), eventLoopBlockIntegration({ threshold: SENTRY_EVENT_LOOP_BLOCK_THRESHOLD_MS }), diff --git a/controlplane/src/core/services/Authentication.ts b/controlplane/src/core/services/Authentication.ts index f1c1ec0efb..da0a3ce2b1 100644 --- a/controlplane/src/core/services/Authentication.ts +++ b/controlplane/src/core/services/Authentication.ts @@ -12,6 +12,8 @@ import { RBACEvaluator } from './RBACEvaluator.js'; // The maximum time to cache the user auth context for the web session authentication. const maxAuthCacheTtl = 30 * 1000; // 30 seconds +export type PostAuthHook = (authContext: AuthContext) => void; + export interface Authenticator { authenticate(headers: Headers): Promise; authenticateRouter(headers: Headers): Promise; @@ -26,6 +28,7 @@ export class Authentication implements Authenticator { private accessTokenAuth: AccessTokenAuthenticator, private graphKeyAuth: GraphApiTokenAuthenticator, private orgRepo: OrganizationRepository, + private postAuthHook?: PostAuthHook, ) {} /** @@ -44,11 +47,19 @@ export class Authentication implements Authenticator { const authorization = headers.get('authorization'); if (authorization) { const token = authorization.replace(/^bearer\s+/i, ''); + let authContext: AuthContext; if (token.startsWith('cosmo')) { - return await this.keyAuth.authenticate(token); + authContext = await this.keyAuth.authenticate(token); + } else { + const organizationSlug = headers.get('cosmo-org-slug'); + authContext = await this.accessTokenAuth.authenticate(token, organizationSlug); + } + + if (this.postAuthHook) { + this.postAuthHook(authContext); } - const organizationSlug = headers.get('cosmo-org-slug'); - return await this.accessTokenAuth.authenticate(token, organizationSlug); + + return authContext; } /** @@ -66,6 +77,10 @@ export class Authentication implements Authenticator { const cachedUserContext = this.#cache.get(cacheKey); if (cachedUserContext) { + if (this.postAuthHook) { + this.postAuthHook(cachedUserContext); + } + return cachedUserContext; } @@ -100,6 +115,10 @@ export class Authentication implements Authenticator { userDisplayName: user.userDisplayName, }; + if (this.postAuthHook) { + this.postAuthHook(userContext); + } + this.#cache.set(cacheKey, userContext); return userContext; diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index a34d7577cb..57ffbd68bd 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -158,6 +158,10 @@ const options: BuildConfig = { } : undefined, }, + sentry: { + enabled: SENTRY_ENABLED, + dsn: SENTRY_DSN, + }, }; if (STRIPE_SECRET_KEY) { diff --git a/controlplane/test/authentication.test.ts b/controlplane/test/authentication.test.ts index 7e7cf3b4a7..222553a0d8 100644 --- a/controlplane/test/authentication.test.ts +++ b/controlplane/test/authentication.test.ts @@ -72,6 +72,10 @@ describe('Authentication', (ctx) => { admissionWebhook: { secret: 'secret', }, + sentry: { + enabled: false, + dsn: '', + } }); testContext.onTestFailed(async () => {