Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion controlplane/src/core/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
}
}
},
);
Comment on lines +257 to +287
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Post-auth Sentry context enrichment looks correct; confirm PII/data-handling expectations

The Sentry enrichment hook is defensively implemented:

  • It’s gated on opts.sentry.enabled && opts.sentry.dsn.
  • All Sentry calls are in a try/catch with debug logging on failure, so instrumentation can’t break auth.
  • Fields used from authContext (userId, userDisplayName, organizationId, organizationSlug, apiKeyName, auth) match the AuthContext shape and are guarded where optional.

Functionally this achieves the goal of enriching Sentry with user/org/auth context. The main consideration is that you’re now sending user identifiers (userId, userDisplayName) and org-identifying data (organizationId, organizationSlug, apiKeyName) to Sentry on every authenticated request. Please double-check that this aligns with your privacy/PII and data-retention policies for production Sentry projects (e.g., whether user display names are acceptable, or if they should be pseudonymized).


🏁 Script executed:

#!/bin/bash
# Verify the code snippet in build-server.ts and check surrounding context
echo "=== Lines 257-287 in build-server.ts ==="
sed -n '257,287p' controlplane/src/core/build-server.ts

echo -e "\n=== Lines around 257-287 for context ==="
sed -n '250,295p' controlplane/src/core/build-server.ts

Length of output: 2624


🏁 Script executed:

#!/bin/bash
# Find AuthContext type definition and check field optionality
echo "=== Searching for AuthContext type definition ==="
rg -n "type AuthContext|interface AuthContext" --type ts

echo -e "\n=== Context around AuthContext definition ==="
rg -nA 15 "type AuthContext|interface AuthContext" --type ts

Length of output: 1116


🏁 Script executed:

#!/bin/bash
# Search for PII, privacy, or data-retention related documentation
echo "=== Searching for PII/privacy related comments or docs ==="
rg -n "PII|private identif|data.retention|privacy" --type ts --type md -i controlplane/

echo -e "\n=== Searching for Sentry configuration or documentation ==="
fd -e md -e txt -e json | xargs rg -l "sentry|PII" 2>/dev/null | head -10

Length of output: 438


🏁 Script executed:

#!/bin/bash
# Check if this code is new or existing, and verify field guards
echo "=== Checking for optional field guards around organizationSlug and apiKeyName ==="
sed -n '275,283p' controlplane/src/core/build-server.ts

Length of output: 371


🏁 Script executed:

#!/bin/bash
# Examine the Sentry configuration to understand PII handling
echo "=== sentry.config.ts full content ==="
cat controlplane/src/core/sentry.config.ts

echo -e "\n=== Check env.schema.ts for SENTRY_SEND_DEFAULT_PII definition ==="
sed -n '195,210p' controlplane/src/core/env.schema.ts

Length of output: 2269


🏁 Script executed:

#!/bin/bash
# Search for how enrichment context is used elsewhere and if PII flag is checked
echo "=== Check if enrichment callback checks SENTRY_SEND_DEFAULT_PII anywhere ==="
rg -n "SENTRY_SEND_DEFAULT_PII|authContext.*setUser|authContext.*setTag" --type ts controlplane/src/core/

echo -e "\n=== Check Sentry docs or comments about user/tag PII ==="
rg -n "setUser|setTag" --type ts -B 2 -A 2 controlplane/src/core/build-server.ts

Length of output: 1105



🏁 Script executed:

#!/bin/bash
# Find where opts.sentry is constructed/initialized
echo "=== Search for where opts.sentry is created ==="
rg -n "opts\.sentry\s*=" --type ts -B 3 -A 3 controlplane/src/core/build-server.ts | head -40

echo -e "\n=== Search for opts object initialization ==="
rg -n "const opts|opts\s*:" --type ts controlplane/src/core/build-server.ts | head -20

Length of output: 155


🏁 Script executed:

#!/bin/bash
# Check the full opts.sentry structure to see if sendDefaultPii is available
echo "=== Look for sentry configuration in opts object ==="
sed -n '1,256p' controlplane/src/core/build-server.ts | grep -n "sentry" -A 2 -B 2

echo -e "\n=== Check if opts includes sendDefaultPii anywhere ==="
rg -n "sendDefaultPii" --type ts controlplane/src/core/

Length of output: 483


🏁 Script executed:

#!/bin/bash
# Understand the type of opts parameter
echo "=== Search for opts parameter type definition ==="
rg -n "function buildServer|async function.*opts|opts.*:" --type ts controlplane/src/core/build-server.ts | head -5

echo -e "\n=== Look at function signature of buildServer ==="
sed -n '1,50p' controlplane/src/core/build-server.ts

Length of output: 3259



🏁 Script executed:

#!/bin/bash
# Find BuildConfig type definition
echo "=== Search for BuildConfig type definition ==="
rg -n "type BuildConfig|interface BuildConfig" --type ts

echo -e "\n=== Get full BuildConfig definition ==="
rg -nA 50 "type BuildConfig|interface BuildConfig" --type ts

Length of output: 3447


🏁 Script executed:

#!/bin/bash
# Verify sendDefaultPii is not passed to enrichment context
echo "=== Check how Authentication class receives the enrichment callback ==="
rg -n "class Authentication|constructor.*enrichment|enrichmentContext" --type ts -A 5 controlplane/src/services/Authentication.ts | head -40

Length of output: 155



🏁 Script executed:

#!/bin/bash
# Find Authentication class file
echo "=== Search for Authentication class ==="
find controlplane/src -name "*.ts" | xargs rg -l "class Authentication" 2>/dev/null

echo -e "\n=== Alternative: search by pattern ==="
rg -n "export.*class Authentication" --type ts controlplane/src/

Length of output: 373


🏁 Script executed:

#!/bin/bash
# Confirm opts.sentry structure doesn't include sendDefaultPii
echo "=== Full BuildConfig.sentry structure ==="
sed -n '140,145p' controlplane/src/core/build-server.ts

Length of output: 101


Enrichment hook bypasses Sentry's sendDefaultPii security setting; PII is sent unconditionally

The code is defensively implemented (try-catch, gated on opts.sentry.enabled && opts.sentry.dsn), and field guards are correct for optional fields. However, verification reveals a PII handling inconsistency:

  • Global Sentry config (sentry.config.ts) sets sendDefaultPii: SENTRY_SEND_DEFAULT_PII, which defaults to false
  • BuildConfig.sentry only exposes { enabled: boolean; dsn?: string } — the sendDefaultPii flag is not accessible to the enrichment callback
  • The hook sends userId, userDisplayName, and organizationId to Sentry without checking the sendDefaultPii setting
  • Result: user identifiers and org data are sent to Sentry regardless of the global privacy configuration

The enrichment hook should either:

  1. Have access to and respect the sendDefaultPii flag before calling Sentry.setUser/setTag, or
  2. Document why this data is acceptable to send unconditionally, or
  3. Avoid sending user identifiers entirely if PII minimization is intended
🤖 Prompt for AI Agents
In controlplane/src/core/build-server.ts around lines 257 to 287, the enrichment
callback unconditionally calls Sentry.setUser and Sentry.setTag with user/org
identifiers which bypasses the global sendDefaultPii privacy setting; change the
code so the callback respects that flag by obtaining sendDefaultPii from the
sentry config or BuildConfig (extend BuildConfig.sentry to include
sendDefaultPii if needed and propagate it to opts), then wrap Sentry.setUser and
any tags that contain PII (userId, userDisplayName, organizationId, apiKeyName)
in a conditional that only runs when sendDefaultPii is true; non-PII tags such
as auth.kind may remain unconditional or be explicitly filtered, and update
affected types/interfaces and tests to reflect the added sentry flag.


const authorizer = new Authorization(logger, opts.stripe?.defaultPlanId);

Expand Down
11 changes: 11 additions & 0 deletions controlplane/src/core/sentry.config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 }),
Expand Down
25 changes: 22 additions & 3 deletions controlplane/src/core/services/Authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthContext>;
authenticateRouter(headers: Headers): Promise<GraphKeyAuthContext>;
Expand All @@ -26,6 +28,7 @@ export class Authentication implements Authenticator {
private accessTokenAuth: AccessTokenAuthenticator,
private graphKeyAuth: GraphApiTokenAuthenticator,
private orgRepo: OrganizationRepository,
private postAuthHook?: PostAuthHook,
) {}

/**
Expand All @@ -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;
}

/**
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions controlplane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ const options: BuildConfig = {
}
: undefined,
},
sentry: {
enabled: SENTRY_ENABLED,
dsn: SENTRY_DSN,
},
};

if (STRIPE_SECRET_KEY) {
Expand Down
4 changes: 4 additions & 0 deletions controlplane/test/authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ describe('Authentication', (ctx) => {
admissionWebhook: {
secret: 'secret',
},
sentry: {
enabled: false,
dsn: '',
}
});

testContext.onTestFailed(async () => {
Expand Down
Loading