From 74442b88191532e5f24c57226e772e6f1045aebc Mon Sep 17 00:00:00 2001 From: Piotr Karpala Date: Tue, 14 Jul 2026 14:49:33 -0400 Subject: [PATCH 1/2] fix(billing): surface unmatched billing usernames + optional EMU alias mapping (#432) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbf27d66-9aca-451b-85ee-3a32e0775d52 --- app/components/BillingCreditsViewer.vue | 51 +++++++++++++++++++++++ server/api/billing-credits-by-user.get.ts | 25 +++++++---- server/api/billing-credits.get.ts | 1 + server/services/billing-credit-reader.ts | 29 ++++++++++++- shared/utils/billing-user-identity.ts | 39 +++++++++++++++++ tests/billing-credit-reader.spec.ts | 46 ++++++++++++++++++++ 6 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 shared/utils/billing-user-identity.ts diff --git a/app/components/BillingCreditsViewer.vue b/app/components/BillingCreditsViewer.vue index 2bdb783d..f925020b 100644 --- a/app/components/BillingCreditsViewer.vue +++ b/app/components/BillingCreditsViewer.vue @@ -219,6 +219,42 @@ are hidden until GitHub starts returning attributed data. + +
+ {{ unmatchedBillingUsernamesList.length }} billing usernames + with spend did not match the loaded Metrics API logins +
+
+ This can happen on EMU enterprises when GitHub's metrics and billing feeds use + different handles for the same person. Configure NUXT_BILLING_USER_ALIASES + to map billing usernames to metrics logins. +
+ + + + Show unmatched billing usernames + + + + {{ username }} + + + + +
@@ -516,6 +552,7 @@ export default defineComponent({ interface BillingAgg { credits: number; grossAmount: number; netAmount: number; models: Set; display: string } const billingByLogin = reactive(new Map()); const loadedLogins = reactive(new Set()); + const unmatchedBillingUsernames = reactive(new Set()); const perUserLoading = ref(false); // When the user switches month or toggles month view, drop cached @@ -523,6 +560,7 @@ export default defineComponent({ watch([selectedMonth, monthView, () => props.queryParams.since, () => props.queryParams.until], () => { billingByLogin.clear(); loadedLogins.clear(); + unmatchedBillingUsernames.clear(); }); async function loadBillingForLogins(logins: string[]): Promise { @@ -552,10 +590,15 @@ export default defineComponent({ const qp: Record = { ...parent, logins: chunk.join(',') }; try { const resp = await $fetch('/api/billing-credits-by-user', { query: qp }); + for (const username of resp.unmatchedBillingUsernames ?? []) { + const trimmed = username.trim(); + if (trimmed) unmatchedBillingUsernames.add(trimmed); + } for (const it of resp.usageItems ?? []) { const u = (it.user || '').trim(); if (!u) continue; const key = u.toLowerCase(); + unmatchedBillingUsernames.delete(u); const prev = billingByLogin.get(key) || { credits: 0, grossAmount: 0, netAmount: 0, models: new Set(), display: u }; prev.credits += Number.isFinite(it.netQuantity) ? it.netQuantity : 0; prev.credits += Number.isFinite(it.discountQuantity) ? it.discountQuantity : 0; @@ -646,6 +689,9 @@ export default defineComponent({ }); }); const loadedLoginsCount = computed(() => loadedLogins.size); + const unmatchedBillingUsernamesList = computed(() => + [...unmatchedBillingUsernames].sort((a, b) => a.localeCompare(b)) + ); // True when we've loaded at least one page of users AND the aggregate // totals show non-zero spend AND zero per-user attribution has come back. @@ -683,6 +729,10 @@ export default defineComponent({ billingByLogin.set(key, prev); loadedLogins.add(key); } + for (const username of data.value?.unmatchedBillingUsernames ?? []) { + const trimmed = username.trim(); + if (trimmed) unmatchedBillingUsernames.add(trimmed); + } }); // Distinguish "our admin gate" 403 from "GitHub billing API" 403 from @@ -832,6 +882,7 @@ export default defineComponent({ errorReason, headers, perUserRows, perUserHeaders, perUserLoading, loadedLoginsCount, onTableOptions, noPerUserAttribution, + unmatchedBillingUsernamesList, topSpendersChartData, topSpendersChartOptions, topTokensChartData, topTokensChartOptions, dataSourceBadge, diff --git a/server/api/billing-credits-by-user.get.ts b/server/api/billing-credits-by-user.get.ts index 089c9108..fea5f605 100644 --- a/server/api/billing-credits-by-user.get.ts +++ b/server/api/billing-credits-by-user.get.ts @@ -35,6 +35,10 @@ import { resolveWindow, aggregateForBillingByUser, } from '../services/billing-credit-reader'; +import { + billingUsernamesForMetricsLogins, + parseBillingUserAliases, +} from '../../shared/utils/billing-user-identity'; // eslint-disable-next-line @typescript-eslint/no-explicit-any import mockBilling from '../../public/mock-data/billing-credits.json'; @@ -189,13 +193,14 @@ export default defineEventHandler(async (event): Promise }); const tagged: BillingUsageItem[] = []; + const aliases = parseBillingUserAliases(process.env.NUXT_BILLING_USER_ALIASES); let timePeriod: BillingCreditsResponse['timePeriod'] = { year: 0, month: 0 }; let orgSlug: string | undefined; let entSlug: string | undefined; let failures = 0; - async function fetchOne(login: string): Promise { - const params = new URLSearchParams({ ...forwardParams, user: login }); + async function fetchOne(metricsLogin: string, billingUsername: string): Promise { + const params = new URLSearchParams({ ...forwardParams, user: billingUsername }); const url = `${apiUrl}?${params.toString()}`; try { const resp = await $fetch(url, { headers: billingHeaders }); @@ -203,23 +208,27 @@ export default defineEventHandler(async (event): Promise orgSlug = resp.organization || orgSlug; entSlug = resp.enterprise || entSlug; for (const it of resp.usageItems ?? []) { - tagged.push({ ...it, user: login }); + tagged.push({ ...it, user: metricsLogin }); } } catch (err) { failures++; - logger.warn(`billing-credits-by-user: ${login} fan-out failed`, err); + logger.warn(`billing-credits-by-user: ${billingUsername} fan-out failed`, err); } } + const fetchTargets = requestedLogins.flatMap(login => + billingUsernamesForMetricsLogins([login], aliases).map(billingUsername => ({ login, billingUsername })) + ); + let cursor = 0; async function worker(): Promise { - while (cursor < requestedLogins.length) { + while (cursor < fetchTargets.length) { const i = cursor++; - const login = requestedLogins[i]; - if (login) await fetchOne(login); + const target = fetchTargets[i]; + if (target) await fetchOne(target.login, target.billingUsername); } } - await Promise.all(Array.from({ length: Math.min(CONCURRENCY, requestedLogins.length) }, () => worker())); + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, fetchTargets.length) }, () => worker())); if (failures > 0 && tagged.length === 0) { throw createError({ diff --git a/server/api/billing-credits.get.ts b/server/api/billing-credits.get.ts index 68b3d6c1..16bb8dbd 100644 --- a/server/api/billing-credits.get.ts +++ b/server/api/billing-credits.get.ts @@ -64,6 +64,7 @@ export interface BillingCreditsResponse { organization?: string; enterprise?: string; user?: string; + unmatchedBillingUsernames?: string[]; usageItems: BillingUsageItem[]; } diff --git a/server/services/billing-credit-reader.ts b/server/services/billing-credit-reader.ts index 15b41295..61a815d4 100644 --- a/server/services/billing-credit-reader.ts +++ b/server/services/billing-credit-reader.ts @@ -30,6 +30,11 @@ import { getPool } from '../storage/db'; import type { BillingCreditsResponse, BillingUsageItem } from '../api/billing-credits.get'; +import { + billingUsernamesForMetricsLogins, + normalizeBillingUsername, + parseBillingUserAliases, +} from '../../shared/utils/billing-user-identity'; export interface CoverageDecision { source: 'db' | 'live'; @@ -284,6 +289,8 @@ export async function aggregateForBillingByUser( } const pool = getPool(); + const aliases = parseBillingUserAliases(process.env.NUXT_BILLING_USER_ALIASES); + const matchedBillingUsernames = billingUsernamesForMetricsLogins(logins, aliases); const conds: string[] = [ 'enterprise = $1', @@ -294,7 +301,7 @@ export async function aggregateForBillingByUser( enterprise, window.startDate, window.endDate, - logins.map(l => l.toLowerCase()), + matchedBillingUsernames, ]; const push = (col: string, val: string | undefined) => { if (val === undefined || val === '') return; @@ -327,12 +334,30 @@ export async function aggregateForBillingByUser( const { rows } = await pool.query(sql, params); const usageItems: BillingUsageItem[] = rows.map(r => ({ ...mapAggregateRowToItem(r), - user: r.username || undefined, + user: r.username ? normalizeBillingUsername(r.username, aliases) : undefined, })); + const unmatchedConds = [...conds]; + const unmatchedParams = [...params]; + unmatchedConds[2] = 'LOWER(username) <> ALL($4::text[])'; + unmatchedConds.push('(quantity <> 0 OR gross_amount <> 0 OR net_amount <> 0)'); + const unmatchedSql = ` + SELECT DISTINCT username + FROM billing_credit_usage + WHERE ${unmatchedConds.join(' AND ')} + AND username IS NOT NULL + AND username <> '' + ORDER BY username + `; + const unmatchedResult = await pool.query(unmatchedSql, unmatchedParams); + const unmatchedBillingUsernames = (unmatchedResult?.rows ?? []) + .map((r: { username?: string }) => (r.username || '').trim()) + .filter(Boolean); + return { timePeriod: window.timePeriod, enterprise, + ...(unmatchedBillingUsernames.length ? { unmatchedBillingUsernames } : {}), usageItems, }; } diff --git a/shared/utils/billing-user-identity.ts b/shared/utils/billing-user-identity.ts new file mode 100644 index 00000000..a2f74d19 --- /dev/null +++ b/shared/utils/billing-user-identity.ts @@ -0,0 +1,39 @@ +export type BillingUserAliases = Record; + +function canonicalUserKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function parseBillingUserAliases(raw: string | undefined): BillingUserAliases { + if (!raw?.trim()) return {}; + try { + const parsed = JSON.parse(raw) as Record; + const aliases: BillingUserAliases = {}; + for (const [billingUsername, metricsLogin] of Object.entries(parsed)) { + if (typeof metricsLogin !== 'string') continue; + const billingKey = canonicalUserKey(billingUsername); + const metricsKey = canonicalUserKey(metricsLogin); + if (billingKey && metricsKey) aliases[billingKey] = metricsKey; + } + return aliases; + } catch { + return {}; + } +} + +export function normalizeBillingUsername(username: string, aliases: BillingUserAliases): string { + const key = canonicalUserKey(username); + return aliases[key] || key; +} + +export function billingUsernamesForMetricsLogins( + logins: string[], + aliases: BillingUserAliases, +): string[] { + const metricsKeys = new Set(logins.map(canonicalUserKey).filter(Boolean)); + const usernames = new Set(metricsKeys); + for (const [billingUsername, metricsLogin] of Object.entries(aliases)) { + if (metricsKeys.has(metricsLogin)) usernames.add(billingUsername); + } + return [...usernames]; +} diff --git a/tests/billing-credit-reader.spec.ts b/tests/billing-credit-reader.spec.ts index 15cca240..60ecbcbb 100644 --- a/tests/billing-credit-reader.spec.ts +++ b/tests/billing-credit-reader.spec.ts @@ -28,6 +28,7 @@ import { beforeEach(() => { mockQuery.mockReset(); + delete process.env.NUXT_BILLING_USER_ALIASES; }); describe('resolveWindow', () => { @@ -259,6 +260,51 @@ describe('aggregateForBillingByUser', () => { expect(mockQuery).not.toHaveBeenCalled(); }); + it('includes configured EMU billing aliases in the DB username filter and normalizes returned users', async () => { + process.env.NUXT_BILLING_USER_ALIASES = JSON.stringify({ + readable_emu: 'opaquehash_emu', + }); + mockQuery + .mockResolvedValueOnce({ + rows: [{ + username: 'readable_emu', + product: 'copilot', + sku: 'copilot_ai_credit', + model: 'gpt-4o', + unit_type: 'credits', + price_per_unit: 0.01, + gross_quantity: 100, + gross_amount: 1, + discount_amount: 0, + net_amount: 1, + }], + }) + .mockResolvedValueOnce({ rows: [] }); + + const resp = await aggregateForBillingByUser('ent', { + startDate: '2026-06-01', endDate: '2026-06-30', timePeriod: { year: 2026, month: 6 }, + }, ['opaquehash_emu']); + + expect(mockQuery.mock.calls[0]![1][3]).toEqual(['opaquehash_emu', 'readable_emu']); + expect(resp.usageItems[0]!.user).toBe('opaquehash_emu'); + }); + + it('surfaces billing usernames with spend that are not matched by requested metrics logins or aliases', async () => { + process.env.NUXT_BILLING_USER_ALIASES = JSON.stringify({ + readable_emu: 'opaquehash_emu', + }); + mockQuery + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ username: 'unmapped_emu' }] }); + + const resp = await aggregateForBillingByUser('ent', { + startDate: '2026-06-01', endDate: '2026-06-30', timePeriod: { year: 2026, month: 6 }, + }, ['opaquehash_emu']); + + expect(resp.unmatchedBillingUsernames).toEqual(['unmapped_emu']); + expect(mockQuery.mock.calls[1]![0]).toMatch(/LOWER\(username\) <> ALL/); + }); + it('groups by username and tags each item with the user field (case-insensitive match)', async () => { mockQuery.mockResolvedValueOnce({ rows: [ From eec4e83c387c3e5a8a382a36e10dc70d59655955 Mon Sep 17 00:00:00 2001 From: Piotr Karpala Date: Tue, 14 Jul 2026 14:52:54 -0400 Subject: [PATCH 2/2] feat(security): opt-in structured audit log for auth/authz/admin/data-access events (#436) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbf27d66-9aca-451b-85ee-3a32e0775d52 --- nuxt.config.ts | 1 + server/api/admin/sync.post.ts | 35 +++++- server/api/billing-credits-by-user.get.ts | 18 +++ server/api/billing-credits.get.ts | 16 +++ server/api/my-usage.get.ts | 10 ++ server/routes/auth/auth0.get.ts | 9 ++ server/routes/auth/github.get.ts | 9 ++ server/routes/auth/google.get.ts | 9 ++ server/routes/auth/keycloak.get.ts | 9 ++ server/routes/auth/microsoft.get.ts | 9 ++ server/utils/audit.ts | 127 ++++++++++++++++++++ server/utils/authorization.ts | 17 ++- server/utils/proxy-agent.ts | 15 ++- server/utils/usage-admin.ts | 12 +- tests/audit-call-sites.spec.ts | 135 ++++++++++++++++++++++ tests/audit.spec.ts | 117 +++++++++++++++++++ 16 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 server/utils/audit.ts create mode 100644 tests/audit-call-sites.spec.ts create mode 100644 tests/audit.spec.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 289fe7f4..594abfc3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -125,6 +125,7 @@ export default defineNuxtConfig({ // Server-only admin allowlist for the per-user billing breakdown tab // (NUXT_USAGE_ADMINS). Closed-by-default: empty list = nobody is admin. usageAdmins: '', + auditLogEnabled: process.env.NUXT_AUDIT_LOG_ENABLED || 'false', public: { isDataMocked: false, // can be overridden by NUXT_PUBLIC_IS_DATA_MOCKED environment variable scope: 'organization', // can be overridden by NUXT_PUBLIC_SCOPE environment variable diff --git a/server/api/admin/sync.post.ts b/server/api/admin/sync.post.ts index a648f90a..8ac23877 100644 --- a/server/api/admin/sync.post.ts +++ b/server/api/admin/sync.post.ts @@ -8,6 +8,7 @@ import { clearFailedSyncsForScope, getFailedSyncsForScope } from '../../storage/ import { Options } from '@/model/Options'; import { isMockMode } from '../../services/github-copilot-usage-api-mock'; import { requireUsageAdmin } from '../../utils/usage-admin'; +import { emitAuditEvent } from '../../utils/audit'; import { createBillingCsvJob, cancelInFlightBillingCsvJobs, @@ -55,6 +56,20 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 401, statusMessage: 'Authorization header required' }); } + const identifier = options.githubOrg || options.githubEnt || 'unknown'; + await emitAuditEvent('admin.sync.triggered', { + action, + outcome: 'allow', + target: identifier, + detail: { + scope: options.scope, + team: options.githubTeam, + date, + since: options.since, + until: options.until, + }, + }, event); + // Handle different sync actions switch (action) { case 'sync-date': { @@ -66,7 +81,7 @@ export default defineEventHandler(async (event) => { logger.info(`Syncing metrics for ${date}`); const result = await syncMetricsForDate({ scope: options.scope!, - identifier: options.githubOrg || options.githubEnt || 'unknown', + identifier, date, teamSlug: options.githubTeam, headers @@ -84,7 +99,7 @@ export default defineEventHandler(async (event) => { logger.info(`Syncing metrics from ${options.since} to ${options.until}`); const results = await syncMetricsForDateRange( options.scope!, - options.githubOrg || options.githubEnt || 'unknown', + identifier, options.since, options.until, headers, @@ -112,7 +127,7 @@ export default defineEventHandler(async (event) => { logger.info(`Syncing gaps from ${options.since} to ${options.until}`); const { results, gapsDetected, outsideWindow } = await syncGaps( options.scope!, - options.githubOrg || options.githubEnt || 'unknown', + identifier, options.since, options.until, headers, @@ -138,7 +153,7 @@ export default defineEventHandler(async (event) => { logger.info(`Running bulk sync for ${options.scope}:${options.githubOrg || options.githubEnt}`); const bulkResult = await syncBulk( options.scope!, - options.githubOrg || options.githubEnt || 'unknown', + identifier, headers, options.githubTeam ); @@ -151,7 +166,6 @@ export default defineEventHandler(async (event) => { case 'retry-failed': { // Re-attempt every sync_status row in 'failed' state for this scope. - const identifier = options.githubOrg || options.githubEnt || 'unknown'; const failed = await getFailedSyncsForScope(options.scope!, identifier, options.githubTeam); if (failed.length === 0) { @@ -240,6 +254,17 @@ export default defineEventHandler(async (event) => { throw e; } + await emitAuditEvent('admin.billing_csv.triggered', { + action, + outcome: 'allow', + target: enterprise, + detail: { + jobId: job.id, + startDate, + endDate, + }, + }, event); + // Fire-and-forget. The ingester catches all errors and records them // on the job row; we just need to make sure unhandled rejections // don't crash the process. diff --git a/server/api/billing-credits-by-user.get.ts b/server/api/billing-credits-by-user.get.ts index fea5f605..21f3c4b2 100644 --- a/server/api/billing-credits-by-user.get.ts +++ b/server/api/billing-credits-by-user.get.ts @@ -28,6 +28,7 @@ import { Options } from '@/model/Options'; import { requireUsageAdmin } from '../utils/usage-admin'; +import { emitAuditEvent } from '../utils/audit'; import { buildBillingApiUrl } from '../utils/billing-url'; import type { BillingCreditsResponse, BillingUsageItem } from './billing-credits.get'; import { @@ -82,6 +83,23 @@ export default defineEventHandler(async (event): Promise }); } + await emitAuditEvent('billing.per_user.viewed', { + action: 'view', + outcome: 'allow', + target: options.githubOrg || options.githubEnt || 'unknown', + detail: { + scope: options.scope, + requestedLogins, + requestedCount: requestedLogins.length, + year: query.year, + month: query.month, + day: query.day, + model: query.model, + product: query.product, + costCenterId: query.cost_center_id, + }, + }, event); + // ── DB-first read path (Phase B) ─────────────────────────────────────────── // Same coverage check as /api/billing-credits: if a completed CSV ingest // job covers the requested window, serve the per-user aggregate from diff --git a/server/api/billing-credits.get.ts b/server/api/billing-credits.get.ts index 16bb8dbd..50693385 100644 --- a/server/api/billing-credits.get.ts +++ b/server/api/billing-credits.get.ts @@ -32,6 +32,7 @@ import { Options } from '@/model/Options'; import { requireUsageAdmin } from '../utils/usage-admin'; +import { emitAuditEvent } from '../utils/audit'; import { buildBillingApiUrl } from '../utils/billing-url'; import { decideSource, @@ -88,6 +89,21 @@ export default defineEventHandler(async (event): Promise // exercise the tab without configuring NUXT_USAGE_ADMINS. if (!options.isDataMocked) { await requireUsageAdmin(event); + await emitAuditEvent('billing.viewed', { + action: 'view', + outcome: 'allow', + target: options.githubOrg || options.githubEnt || 'unknown', + detail: { + scope: options.scope, + year: query.year, + month: query.month, + day: query.day, + model: query.model, + product: query.product, + costCenterId: query.cost_center_id, + user: query.user, + }, + }, event); } // ── Mock mode ────────────────────────────────────────────────────────────── diff --git a/server/api/my-usage.get.ts b/server/api/my-usage.get.ts index d2b28209..b9d3fb0e 100644 --- a/server/api/my-usage.get.ts +++ b/server/api/my-usage.get.ts @@ -41,6 +41,7 @@ import { import { buildBillingApiUrl } from '../utils/billing-url'; import { aggregateBillingSpend, type BillingUsageItem } from '../utils/billing-spend-aggregator'; import { isUsageAdminForEvent } from '../utils/usage-admin'; +import { emitAuditEvent } from '../utils/audit'; // eslint-disable-next-line @typescript-eslint/no-explicit-any import mockUsersOrg28Day from '../../public/mock-data/new-api/organization-users-28-day-report.json'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -164,6 +165,15 @@ export default defineEventHandler(async (event): Promise => { myLogin = requestedLogin; myEmail = undefined; viewingAsAdmin = true; + await emitAuditEvent('user_data.viewed', { + action: 'view', + outcome: 'allow', + target: requestedLogin, + detail: { + scope: options.scope, + requestedLogin, + }, + }, event); } } diff --git a/server/routes/auth/auth0.get.ts b/server/routes/auth/auth0.get.ts index a17d39b6..df60218a 100644 --- a/server/routes/auth/auth0.get.ts +++ b/server/routes/auth/auth0.get.ts @@ -1,3 +1,5 @@ +import { emitAuditEvent } from '../../utils/audit' + export default defineOAuthAuth0EventHandler({ async onSuccess(event, { user }) { const email: string = user.email || '' @@ -13,6 +15,13 @@ export default defineOAuthAuth0EventHandler({ } }) + await emitAuditEvent('auth.login.success', { + action: 'login', + outcome: 'allow', + target: user.nickname || email, + detail: { provider: 'auth0' }, + }, event) + const config = useRuntimeConfig(event) const defaultOrg = config.public.githubOrg || config.public.githubEnt return sendRedirect(event, defaultOrg ? getAppBaseURL(event) : appURL('/select-org', event)) diff --git a/server/routes/auth/github.get.ts b/server/routes/auth/github.get.ts index 7d3f9057..b11add2c 100644 --- a/server/routes/auth/github.get.ts +++ b/server/routes/auth/github.get.ts @@ -1,3 +1,5 @@ +import { emitAuditEvent } from '../../utils/audit'; + export default defineOAuthGitHubEventHandler({ config: { // Default scopes: read:user for profile, read:org for org membership (used by org picker). @@ -29,6 +31,13 @@ export default defineOAuthGitHubEventHandler({ } }) + await emitAuditEvent('auth.login.success', { + action: 'login', + outcome: 'allow', + target: user.login, + detail: { provider: 'github' }, + }, event) + // If a default org/ent is pinned via env var, go straight to the home page. const defaultOrg = config.public.githubOrg || config.public.githubEnt if (defaultOrg) { diff --git a/server/routes/auth/google.get.ts b/server/routes/auth/google.get.ts index 8b81267b..eec1c36c 100644 --- a/server/routes/auth/google.get.ts +++ b/server/routes/auth/google.get.ts @@ -1,3 +1,5 @@ +import { emitAuditEvent } from '../../utils/audit' + export default defineOAuthGoogleEventHandler({ async onSuccess(event, { user }) { if (!isUserAuthorized(event, { email: user.email })) { @@ -12,6 +14,13 @@ export default defineOAuthGoogleEventHandler({ } }) + await emitAuditEvent('auth.login.success', { + action: 'login', + outcome: 'allow', + target: user.email, + detail: { provider: 'google' }, + }, event) + // If no default org is configured, let the user pick via the org picker const config = useRuntimeConfig(event) const defaultOrg = config.public.githubOrg || config.public.githubEnt diff --git a/server/routes/auth/keycloak.get.ts b/server/routes/auth/keycloak.get.ts index 1050d500..c3f2d6d8 100644 --- a/server/routes/auth/keycloak.get.ts +++ b/server/routes/auth/keycloak.get.ts @@ -1,3 +1,5 @@ +import { emitAuditEvent } from '../../utils/audit' + export default defineOAuthKeycloakEventHandler({ async onSuccess(event, { user }) { const email: string = user.email || '' @@ -13,6 +15,13 @@ export default defineOAuthKeycloakEventHandler({ } }) + await emitAuditEvent('auth.login.success', { + action: 'login', + outcome: 'allow', + target: user.preferred_username || email, + detail: { provider: 'keycloak' }, + }, event) + const config = useRuntimeConfig(event) const defaultOrg = config.public.githubOrg || config.public.githubEnt return sendRedirect(event, defaultOrg ? getAppBaseURL(event) : appURL('/select-org', event)) diff --git a/server/routes/auth/microsoft.get.ts b/server/routes/auth/microsoft.get.ts index adf0ffa2..b5309b87 100644 --- a/server/routes/auth/microsoft.get.ts +++ b/server/routes/auth/microsoft.get.ts @@ -1,3 +1,5 @@ +import { emitAuditEvent } from '../../utils/audit' + export default defineOAuthMicrosoftEventHandler({ async onSuccess(event, { user }) { const email: string = user.mail || user.userPrincipalName || '' @@ -13,6 +15,13 @@ export default defineOAuthMicrosoftEventHandler({ } }) + await emitAuditEvent('auth.login.success', { + action: 'login', + outcome: 'allow', + target: email, + detail: { provider: 'microsoft' }, + }, event) + const config = useRuntimeConfig(event) const defaultOrg = config.public.githubOrg || config.public.githubEnt return sendRedirect(event, defaultOrg ? getAppBaseURL(event) : appURL('/select-org', event)) diff --git a/server/utils/audit.ts b/server/utils/audit.ts new file mode 100644 index 00000000..a5d1fbd8 --- /dev/null +++ b/server/utils/audit.ts @@ -0,0 +1,127 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + +export interface AuditEventDetails { + action: string; + outcome: 'allow' | 'deny'; + target?: string; + detail?: Record; +} + +const SENSITIVE_DETAIL_KEY = /token|secret|password|cookie/i; + +export async function emitAuditEvent( + eventName: string, + details: AuditEventDetails, + event?: H3Event +): Promise { + try { + const config = useRuntimeConfig(event); + if (((config.auditLogEnabled as string | undefined) ?? 'false') !== 'true') { + return; + } + + const actor = await resolveActor(event); + const record: Record = { + audit: true, + timestamp: new Date().toISOString(), + event: eventName, + action: details.action, + outcome: details.outcome, + actor, + sourceIp: resolveSourceIp(event), + userAgent: readHeader(event, 'user-agent'), + requestId: resolveRequestId(event), + target: details.target, + }; + + if (details.detail) { + record.detail = sanitizeDetail(details.detail) as Record; + } + + console.log(JSON.stringify(removeUndefined(record))); + } catch (error) { + console.error('[audit] Failed to emit audit event:', error); + } +} + +async function resolveActor(event?: H3Event): Promise { + if (event) { + const session = await getUserSession(event).catch(() => null); + const user = session?.user as { login?: string; email?: string } | undefined; + if (user?.login) return user.login; + if (user?.email) return user.email; + } + + if (readHeader(event, 'authorization')) { + return 'token-mode'; + } + + return 'anonymous'; +} + +function resolveSourceIp(event?: H3Event): string | undefined { + const forwardedFor = readHeader(event, 'x-forwarded-for'); + if (forwardedFor) { + const first = forwardedFor.split(',')[0]?.trim(); + if (first) return first; + } + + return readHeader(event, 'x-real-ip') + || readHeader(event, 'cf-connecting-ip') + || readHeader(event, 'true-client-ip') + || readHeader(event, 'fastly-client-ip') + || event?.node?.req?.socket?.remoteAddress; +} + +function resolveRequestId(event?: H3Event): string | undefined { + const context = event?.context as Record | undefined; + return readHeader(event, 'x-request-id') + || readHeader(event, 'x-correlation-id') + || readHeader(event, 'request-id') + || (typeof context?.requestId === 'string' ? context.requestId : undefined); +} + +function readHeader(event: H3Event | undefined, name: string): string | undefined { + if (!event) return undefined; + + try { + const value = getRequestHeader(event, name); + if (value) return value; + } catch { + // Fall back to test/plain-H3 shapes below. + } + + const lower = name.toLowerCase(); + const contextHeaders = (event.context as { headers?: Headers | Record } | undefined)?.headers; + if (contextHeaders instanceof Headers) { + return contextHeaders.get(name) ?? undefined; + } + if (contextHeaders && typeof contextHeaders === 'object') { + const direct = contextHeaders[lower] ?? contextHeaders[name]; + if (typeof direct === 'string') return direct; + } + + const nodeHeaders = event.node?.req?.headers as Record | undefined; + const raw = nodeHeaders?.[lower]; + if (Array.isArray(raw)) return raw.join(', '); + return raw; +} + +function sanitizeDetail(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(item => sanitizeDetail(item)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, nested]) => [ + key, + SENSITIVE_DETAIL_KEY.test(key) ? '[redacted]' : sanitizeDetail(nested), + ]) + ); + } + return value; +} + +function removeUndefined(record: Record): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); +} diff --git a/server/utils/authorization.ts b/server/utils/authorization.ts index d6c076a3..266c1f4e 100644 --- a/server/utils/authorization.ts +++ b/server/utils/authorization.ts @@ -11,6 +11,8 @@ * - NUXT_AUTHORIZED_EMAIL_DOMAINS comma-separated domains, e.g. "company.com,corp.org" */ +import { emitAuditEvent } from './audit'; + export interface AuthorizedIdentity { /** GitHub login or generic username (lowercase for comparison) */ login?: string @@ -79,5 +81,18 @@ export function isUserAuthorized( const config = useRuntimeConfig(event) const authorizedUsers = (config.authorizedUsers as string | undefined) ?? '' const authorizedEmailDomains = (config.authorizedEmailDomains as string | undefined) ?? '' - return checkAuthorization(identity, authorizedUsers, authorizedEmailDomains) + const allowed = checkAuthorization(identity, authorizedUsers, authorizedEmailDomains) + if (!allowed) { + void emitAuditEvent('auth.login.denied', { + action: 'login', + outcome: 'deny', + target: identity.login || identity.email || 'unknown', + detail: { + reason: 'allowlist', + hasAuthorizedUsers: !!authorizedUsers.trim(), + hasAuthorizedEmailDomains: !!authorizedEmailDomains.trim(), + }, + }, event) + } + return allowed } diff --git a/server/utils/proxy-agent.ts b/server/utils/proxy-agent.ts index 1328d7d9..2514fd51 100644 --- a/server/utils/proxy-agent.ts +++ b/server/utils/proxy-agent.ts @@ -42,7 +42,7 @@ export function initializeProxyAgent(exitOnError = false): ProxyAgent | null { }); setGlobalDispatcher(proxyAgent); - console.info(`[proxy-agent] Proxy initialized: ${process.env.HTTP_PROXY}`); + console.info(`[proxy-agent] Proxy initialized: ${redactProxyCredentials(process.env.HTTP_PROXY)}`); return proxyAgent; } catch (error) { @@ -51,3 +51,16 @@ export function initializeProxyAgent(exitOnError = false): ProxyAgent | null { throw error; } } + +function redactProxyCredentials(proxyUrl: string): string { + try { + const parsed = new URL(proxyUrl); + if (parsed.username || parsed.password) { + parsed.username = '***'; + parsed.password = '***'; + } + return parsed.toString(); + } catch { + return proxyUrl.replace(/\/\/([^/@:\s]+):([^/@\s]+)@/, '//***:***@'); + } +} diff --git a/server/utils/usage-admin.ts b/server/utils/usage-admin.ts index 18b7247e..49506315 100644 --- a/server/utils/usage-admin.ts +++ b/server/utils/usage-admin.ts @@ -37,6 +37,7 @@ import type { H3Event, EventHandlerRequest } from 'h3' import type { AuthorizedIdentity } from './authorization' +import { emitAuditEvent } from './audit' /** * Pure check — accepts an explicit allowlist string so it can be unit-tested @@ -135,11 +136,21 @@ export async function requireUsageAdmin( ): Promise { const ok = await isUsageAdminForEvent(event) if (!ok) { + await emitAuditEvent('authz.admin.denied', { + action: 'usage-admin', + outcome: 'deny', + target: 'usage-admin', + }, event) throw createError({ statusCode: 403, statusMessage: 'Forbidden: your account is not on the NUXT_USAGE_ADMINS allowlist.' }) } + await emitAuditEvent('authz.admin.granted', { + action: 'usage-admin', + outcome: 'allow', + target: 'usage-admin', + }, event) } /** @@ -157,4 +168,3 @@ export async function getSessionLoginForFilter( const user = session?.user as { login?: string } | undefined return user?.login || null } - diff --git a/tests/audit-call-sites.spec.ts b/tests/audit-call-sites.spec.ts new file mode 100644 index 00000000..14441ea9 --- /dev/null +++ b/tests/audit-call-sites.spec.ts @@ -0,0 +1,135 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.hoisted(() => { + (globalThis as any).useRuntimeConfig = () => (globalThis as any).__test_config || {}; + (globalThis as any).getUserSession = async () => (globalThis as any).__test_session || null; + (globalThis as any).defineEventHandler = (h: any) => h; + (globalThis as any).createError = ({ statusCode, statusMessage }: { statusCode: number; statusMessage: string }) => { + const err: any = new Error(statusMessage); + err.statusCode = statusCode; + return err; + }; + (globalThis as any).getQuery = () => (globalThis as any).__test_query || {}; + (globalThis as any).$fetch = vi.fn(); +}); + +const { mockEmitAuditEvent } = vi.hoisted(() => ({ + mockEmitAuditEvent: vi.fn(), +})); + +vi.mock('../server/utils/audit', () => ({ + emitAuditEvent: mockEmitAuditEvent, +})); + +vi.mock('#app/nuxt', () => ({ + useRuntimeConfig: () => (globalThis as any).__test_config || {}, + defineNuxtPlugin: (h: any) => h, +})); + +vi.mock('../server/services/github-copilot-usage-api', async () => ({ + fetchLatestUserReport: vi.fn(async () => ({ + user_totals: [], + day_totals: [], + report_start_day: '2026-06-01', + report_end_day: '2026-06-28', + })), + fetchRawUserDayRecords: vi.fn(async () => []), + aggregateUserDayRecords: vi.fn(() => []), +})); + +vi.mock('../server/storage/user-day-metrics-storage', () => ({ + getUserDayMetricsByDateRange: vi.fn(async () => []), +})); +vi.mock('../server/storage/db', () => ({ + isDbConfigured: () => false, +})); + +import { isUserAuthorized } from '../server/utils/authorization'; +import { requireUsageAdmin } from '../server/utils/usage-admin'; +import myUsageHandler from '../server/api/my-usage.get'; + +function setConfig(config: Record) { + (globalThis as any).__test_config = config; +} + +function setSession(session: unknown) { + (globalThis as any).__test_session = session; +} + +function setQuery(query: Record) { + (globalThis as any).__test_query = query; +} + +function makeEvent() { + return { context: { headers: new Headers({ authorization: 'token test' }) } } as any; +} + +beforeEach(() => { + vi.clearAllMocks(); + setConfig({ + auditLogEnabled: 'true', + authorizedUsers: 'alice', + authorizedEmailDomains: '', + usageAdmins: 'admin', + public: { requireAuth: true, githubOrg: 'octodemo' }, + }); + setSession(null); + setQuery({}); +}); + +describe('audit call sites', () => { + it('emits auth.login.denied for OAuth allowlist denials', () => { + mockEmitAuditEvent.mockResolvedValue(undefined); + const event = makeEvent(); + expect(isUserAuthorized(event, { login: 'bob', email: 'bob@example.com' })).toBe(false); + expect(mockEmitAuditEvent).toHaveBeenCalledWith( + 'auth.login.denied', + expect.objectContaining({ + action: 'login', + outcome: 'deny', + target: 'bob', + }), + event + ); + }); + + it('emits authz.admin.denied and authz.admin.granted from requireUsageAdmin', async () => { + mockEmitAuditEvent.mockResolvedValue(undefined); + setSession({ user: { login: 'bob' } }); + await expect(requireUsageAdmin(makeEvent())).rejects.toMatchObject({ statusCode: 403 }); + expect(mockEmitAuditEvent).toHaveBeenCalledWith( + 'authz.admin.denied', + expect.objectContaining({ action: 'usage-admin', outcome: 'deny' }), + expect.anything() + ); + + mockEmitAuditEvent.mockClear(); + setSession({ user: { login: 'admin' } }); + await expect(requireUsageAdmin(makeEvent())).resolves.toBeUndefined(); + expect(mockEmitAuditEvent).toHaveBeenCalledWith( + 'authz.admin.granted', + expect.objectContaining({ action: 'usage-admin', outcome: 'allow' }), + expect.anything() + ); + }); + + it('emits user_data.viewed for admin drill-down data access', async () => { + mockEmitAuditEvent.mockResolvedValue(undefined); + setSession({ user: { login: 'admin' } }); + setQuery({ scope: 'organization', githubOrg: 'octodemo', login: 'bob' }); + + const result = await myUsageHandler(makeEvent()); + expect(result.viewingAsAdmin).toBe(true); + expect(mockEmitAuditEvent).toHaveBeenCalledWith( + 'user_data.viewed', + expect.objectContaining({ + action: 'view', + outcome: 'allow', + target: 'bob', + }), + expect.anything() + ); + }); +}); diff --git a/tests/audit.spec.ts b/tests/audit.spec.ts new file mode 100644 index 00000000..63fe079e --- /dev/null +++ b/tests/audit.spec.ts @@ -0,0 +1,117 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.hoisted(() => { + (globalThis as any).useRuntimeConfig = () => (globalThis as any).__test_config || {}; + (globalThis as any).getUserSession = async () => (globalThis as any).__test_session || null; + (globalThis as any).getRequestHeader = (event: any, name: string) => { + const headers = event?._headers ?? {}; + return headers[name.toLowerCase()]; + }; +}); + +vi.mock('#app/nuxt', () => ({ + useRuntimeConfig: () => (globalThis as any).__test_config || {}, + defineNuxtPlugin: (h: any) => h, +})); + +import { emitAuditEvent } from '../server/utils/audit'; + +function setConfig(config: Record) { + (globalThis as any).__test_config = config; +} + +function setSession(session: unknown) { + (globalThis as any).__test_session = session; +} + +function makeEvent(headers: Record = {}) { + return { + _headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])), + node: { req: { socket: { remoteAddress: '10.0.0.1' } } }, + context: {}, + } as any; +} + +describe('emitAuditEvent', () => { + beforeEach(() => { + vi.clearAllMocks(); + setConfig({ auditLogEnabled: 'false' }); + setSession(null); + }); + + it('no-ops when audit logging is disabled', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await emitAuditEvent('auth.login.success', { + action: 'login', + outcome: 'allow', + }, makeEvent()); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('emits single-line JSON with expected fields when enabled', async () => { + setConfig({ auditLogEnabled: 'true' }); + setSession({ user: { login: 'alice', email: 'alice@example.com' } }); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await emitAuditEvent('billing.viewed', { + action: 'view', + outcome: 'allow', + target: 'octodemo', + detail: { scope: 'organization', count: 3 }, + }, makeEvent({ + 'x-forwarded-for': '203.0.113.10, 10.0.0.2', + 'user-agent': 'vitest', + 'x-request-id': 'req-123', + })); + + expect(spy).toHaveBeenCalledOnce(); + const parsed = JSON.parse(spy.mock.calls[0]![0] as string); + expect(parsed).toMatchObject({ + audit: true, + event: 'billing.viewed', + action: 'view', + outcome: 'allow', + actor: 'alice', + sourceIp: '203.0.113.10', + userAgent: 'vitest', + requestId: 'req-123', + target: 'octodemo', + detail: { scope: 'organization', count: 3 }, + }); + expect(new Date(parsed.timestamp).toISOString()).toBe(parsed.timestamp); + spy.mockRestore(); + }); + + it('redacts sensitive detail fields', async () => { + setConfig({ auditLogEnabled: 'true' }); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await emitAuditEvent('admin.sync.triggered', { + action: 'sync', + outcome: 'allow', + detail: { + token: 'ghp_secret', + clientSecret: 'secret', + password: 'pw', + cookieValue: 'cookie', + safe: 'kept', + }, + }, makeEvent({ authorization: 'token test' })); + + const parsed = JSON.parse(spy.mock.calls[0]![0] as string); + expect(parsed.actor).toBe('token-mode'); + expect(parsed.detail).toEqual({ + token: '[redacted]', + clientSecret: '[redacted]', + password: '[redacted]', + cookieValue: '[redacted]', + safe: 'kept', + }); + spy.mockRestore(); + }); +});