diff --git a/supabase/functions/_backend/private/sso/check-domain.ts b/supabase/functions/_backend/private/sso/check-domain.ts index 36aa8eb207..ae0dceca70 100644 --- a/supabase/functions/_backend/private/sso/check-domain.ts +++ b/supabase/functions/_backend/private/sso/check-domain.ts @@ -7,6 +7,7 @@ import { cloudlog } from '../../utils/logging.ts' import { getClientIP } from '../../utils/rate_limit.ts' import { emptySupabase } from '../../utils/supabase.ts' import { version } from '../../utils/version.ts' +import { getSsoLogMetadata } from './logging.ts' // Rate limiting: 10 requests per minute per IP const RATE_LIMIT_WINDOW_SECONDS = 60 @@ -33,7 +34,7 @@ async function checkDomainRateLimit(c: Context): Promise { await cacheHelper.putJson(cacheKey, { count, resetAt } satisfies RateLimitCounter, Math.max(1, Math.ceil((resetAt - now) / 1000))) if (count > RATE_LIMIT_MAX_REQUESTS) { - cloudlog({ requestId: c.get('requestId'), message: 'check-domain rate limited', ip, count }) + cloudlog({ requestId: c.get('requestId'), message: 'check-domain rate limited', data: getSsoLogMetadata({ ip, count }) }) return true } @@ -85,9 +86,11 @@ app.post('/', async (c) => { cloudlog({ requestId, context: 'check_domain - query error', - domain, - enforcementError: enforcementError?.message, - legacyError: legacyError?.message, + data: { + ...getSsoLogMetadata({ domain }), + enforcement: getSsoLogMetadata({ error: enforcementError }), + legacy: getSsoLogMetadata({ error: legacyError }), + }, }) return quickError(500, 'query_error', 'Failed to check domain') } @@ -96,17 +99,19 @@ app.post('/', async (c) => { const legacyRow = Array.isArray(legacyData) ? legacyData[0] : legacyData if (!enforcementRow && !legacyRow) { - cloudlog({ requestId, context: 'check_domain - no SSO provider found', domain }) + cloudlog({ requestId, context: 'check_domain - no SSO provider found', data: getSsoLogMetadata({ domain }) }) return c.json({ has_sso: false }) } cloudlog({ requestId, context: 'check_domain - SSO provider found', - domain, - enforce_sso: enforcementRow?.enforce_sso, - provider_id: legacyRow?.provider_id, - org_id: enforcementRow?.org_id ?? legacyRow?.org_id, + data: getSsoLogMetadata({ + domain, + enforceSso: enforcementRow?.enforce_sso, + providerId: legacyRow?.provider_id, + orgId: enforcementRow?.org_id ?? legacyRow?.org_id, + }), }) return c.json({ @@ -115,7 +120,7 @@ app.post('/', async (c) => { }) } catch (err) { - cloudlog({ requestId, context: 'check_domain - unexpected error', error: String(err), domain }) + cloudlog({ requestId, context: 'check_domain - unexpected error', data: getSsoLogMetadata({ domain, error: err }) }) return quickError(500, 'internal_error', 'Internal server error') } }) diff --git a/supabase/functions/_backend/private/sso/check-enforcement.ts b/supabase/functions/_backend/private/sso/check-enforcement.ts index 4283881cfc..48279635ee 100644 --- a/supabase/functions/_backend/private/sso/check-enforcement.ts +++ b/supabase/functions/_backend/private/sso/check-enforcement.ts @@ -2,6 +2,7 @@ import { createHono, getClaimsFromJWT, middlewareAuth, parseBody, quickError, us import { cloudlog } from '../../utils/logging.ts' import { supabaseWithAuth } from '../../utils/supabase.ts' import { version } from '../../utils/version.ts' +import { getSsoLogMetadata } from './logging.ts' export const app = createHono('', version) @@ -26,7 +27,7 @@ app.post('/', middlewareAuth, async (c) => { const email = claims?.email if (!email) { - cloudlog({ requestId, context: 'check_enforcement - no email in JWT claims', userId }) + cloudlog({ requestId, context: 'check_enforcement - no email in JWT claims', data: getSsoLogMetadata({ userId }) }) return quickError(400, 'no_email', 'Email not found in authentication token') } @@ -42,7 +43,7 @@ app.post('/', middlewareAuth, async (c) => { // SSO authentication is always allowed if (isSsoAuth) { - cloudlog({ requestId, context: 'check_enforcement - SSO auth always allowed', email, provider, providers }) + cloudlog({ requestId, context: 'check_enforcement - SSO auth always allowed', data: getSsoLogMetadata({ email, provider, providers }) }) return c.json({ allowed: true }) } @@ -56,12 +57,12 @@ app.post('/', middlewareAuth, async (c) => { try { const { data: providerData, error: providerError } = await (supabase.rpc as any)('check_domain_sso', { p_domain: domain }) if (providerError) { - cloudlog({ requestId, context: 'check_enforcement - provider query error', error: providerError.message, domain }) + cloudlog({ requestId, context: 'check_enforcement - provider query error', data: getSsoLogMetadata({ domain, error: providerError }) }) return quickError(500, 'query_error', 'Failed to check SSO enforcement') } if (!providerData || (Array.isArray(providerData) && providerData.length === 0)) { - cloudlog({ requestId, context: 'check_enforcement - no SSO provider found', domain }) + cloudlog({ requestId, context: 'check_enforcement - no SSO provider found', data: getSsoLogMetadata({ domain }) }) return c.json({ allowed: true }) } @@ -70,12 +71,12 @@ app.post('/', middlewareAuth, async (c) => { }) if (enforcementError) { - cloudlog({ requestId, context: 'check_enforcement - enforcement query error', error: enforcementError.message, domain }) + cloudlog({ requestId, context: 'check_enforcement - enforcement query error', data: getSsoLogMetadata({ domain, error: enforcementError }) }) return quickError(500, 'query_error', 'Failed to check SSO enforcement') } if (!enforcementData || (Array.isArray(enforcementData) && enforcementData.length === 0)) { - cloudlog({ requestId, context: 'check_enforcement - no SSO enforcement data found', domain }) + cloudlog({ requestId, context: 'check_enforcement - no SSO enforcement data found', data: getSsoLogMetadata({ domain }) }) return c.json({ allowed: true }) } @@ -84,7 +85,7 @@ app.post('/', middlewareAuth, async (c) => { // If enforcement is not enabled, allow password auth if (!enforcementRow.enforce_sso) { - cloudlog({ requestId, context: 'check_enforcement - SSO not enforced', domain }) + cloudlog({ requestId, context: 'check_enforcement - SSO not enforced', data: getSsoLogMetadata({ domain, enforceSso: false }) }) return c.json({ allowed: true }) } @@ -97,7 +98,7 @@ app.post('/', middlewareAuth, async (c) => { if (roleError && roleError.code !== 'PGRST116') { // PGRST116 = no rows found (user not in org), which is expected - cloudlog({ requestId, context: 'check_enforcement - role query error', error: roleError.message, orgId, userId }) + cloudlog({ requestId, context: 'check_enforcement - role query error', data: getSsoLogMetadata({ orgId, userId, error: roleError }) }) return quickError(500, 'query_error', 'Failed to check user role') } @@ -105,16 +106,16 @@ app.post('/', middlewareAuth, async (c) => { const isSuperAdmin = roleData?.user_right === 'super_admin' if (isSuperAdmin) { - cloudlog({ requestId, context: 'check_enforcement - super admin bypass', email, orgId }) + cloudlog({ requestId, context: 'check_enforcement - super admin bypass', data: getSsoLogMetadata({ email, orgId }) }) return c.json({ allowed: true }) } // SSO is enforced and user is not super admin - cloudlog({ requestId, context: 'check_enforcement - SSO enforced, password blocked', email, orgId }) + cloudlog({ requestId, context: 'check_enforcement - SSO enforced, password blocked', data: getSsoLogMetadata({ email, orgId, enforceSso: true }) }) return c.json({ allowed: false, reason: 'sso_enforced' }) } catch (err) { - cloudlog({ requestId, context: 'check_enforcement - unexpected error', error: String(err), email }) + cloudlog({ requestId, context: 'check_enforcement - unexpected error', data: getSsoLogMetadata({ email, error: err }) }) return quickError(500, 'internal_error', 'Internal server error') } }) diff --git a/supabase/functions/_backend/private/sso/logging.ts b/supabase/functions/_backend/private/sso/logging.ts new file mode 100644 index 0000000000..7e5ad40101 --- /dev/null +++ b/supabase/functions/_backend/private/sso/logging.ts @@ -0,0 +1,83 @@ +interface SsoLogMetadataInput { + authenticatedProviders?: unknown + count?: unknown + domain?: unknown + email?: unknown + enforceSso?: unknown + error?: unknown + externalProviderId?: unknown + ip?: unknown + orgId?: unknown + provider?: unknown + providerId?: unknown + providers?: unknown + status?: unknown + userId?: unknown +} + +const SAFE_LOG_TOKEN_PATTERN = /^[\w:-]{1,64}$/ + +function hasStringValue(value: unknown) { + return typeof value === 'string' && value.length > 0 +} + +function getSafeLogToken(value: unknown) { + return typeof value === 'string' && SAFE_LOG_TOKEN_PATTERN.test(value) + ? value + : undefined +} + +function asProviderList(value: unknown) { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +} + +function isSsoProvider(value: unknown) { + return typeof value === 'string' && (value === 'sso' || value.startsWith('sso:')) +} + +function getProviderType(value: unknown) { + if (isSsoProvider(value)) + return 'sso' + + return getSafeLogToken(value) ?? (hasStringValue(value) ? 'other' : undefined) +} + +function getErrorLogMetadata(error: unknown) { + if (!error) + return { hasError: false } + + if (typeof error === 'object') { + const issue = error as { code?: unknown, name?: unknown } + return { + hasError: true, + errorCode: getSafeLogToken(issue.code), + errorName: getSafeLogToken(issue.name), + } + } + + return { hasError: true } +} + +export function getSsoLogMetadata(input: SsoLogMetadataInput = {}) { + const providers = asProviderList(input.providers) + const authenticatedProviders = asProviderList(input.authenticatedProviders) + + return { + hasEmail: hasStringValue(input.email), + hasDomain: hasStringValue(input.domain), + hasUserId: hasStringValue(input.userId), + hasOrgId: hasStringValue(input.orgId), + hasProviderId: hasStringValue(input.providerId), + hasExternalProviderId: hasStringValue(input.externalProviderId), + hasIp: hasStringValue(input.ip), + providerType: getProviderType(input.provider), + providerCount: providers.length, + ssoProviderCount: providers.filter(isSsoProvider).length, + authenticatedProviderCount: authenticatedProviders.length, + authenticatedSsoProviderCount: authenticatedProviders.filter(isSsoProvider).length, + requestCount: typeof input.count === 'number' ? input.count : undefined, + enforceSso: typeof input.enforceSso === 'boolean' ? input.enforceSso : undefined, + status: getSafeLogToken(input.status), + ...getErrorLogMetadata(input.error), + } +} diff --git a/supabase/functions/_backend/private/sso/sp-metadata.ts b/supabase/functions/_backend/private/sso/sp-metadata.ts index 45993a968f..790aa34d1d 100644 --- a/supabase/functions/_backend/private/sso/sp-metadata.ts +++ b/supabase/functions/_backend/private/sso/sp-metadata.ts @@ -60,7 +60,7 @@ app.get('/', (c: Context) => { const auth = c.get('auth') const requestId = c.get('requestId') if (!auth) { - cloudlog({ requestId, message: 'Unauthorized request to sp-metadata — no auth context', auth }) + cloudlog({ requestId, message: 'Unauthorized request to sp-metadata - no auth context' }) return quickError(401, 'not_authorized', 'Not authorized') } diff --git a/tests/sso-log-redaction.unit.test.ts b/tests/sso-log-redaction.unit.test.ts new file mode 100644 index 0000000000..4cd35ac9b6 --- /dev/null +++ b/tests/sso-log-redaction.unit.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { getSsoLogMetadata } from '../supabase/functions/_backend/private/sso/logging.ts' + +describe('SSO log redaction', () => { + it.concurrent('summarizes SSO identifiers without logging raw values', () => { + const summary = getSsoLogMetadata({ + authenticatedProviders: ['sso:provider-secret', 'email'], + count: 12, + domain: 'sensitive-company.example', + email: 'owner@sensitive-company.example', + enforceSso: true, + error: { + code: 'PGRST123', + message: 'owner@sensitive-company.example failed', + name: 'PostgrestError', + }, + externalProviderId: 'provider-secret', + ip: '203.0.113.10', + orgId: '046a36ac-e03c-4590-9257-bd6c9dba9ee8', + provider: 'sso:provider-secret', + providerId: 'provider-secret', + providers: ['email', 'sso:provider-secret'], + status: 'active', + userId: '86a84313-9b9f-46d0-9cbb-09d67f18c8f6', + }) + + expect(summary).toEqual({ + authenticatedProviderCount: 2, + authenticatedSsoProviderCount: 1, + enforceSso: true, + errorCode: 'PGRST123', + errorName: 'PostgrestError', + hasDomain: true, + hasEmail: true, + hasError: true, + hasExternalProviderId: true, + hasIp: true, + hasOrgId: true, + hasProviderId: true, + hasUserId: true, + providerCount: 2, + providerType: 'sso', + requestCount: 12, + ssoProviderCount: 1, + status: 'active', + }) + + const serialized = JSON.stringify(summary) + expect(serialized).not.toContain('owner@sensitive-company.example') + expect(serialized).not.toContain('sensitive-company.example') + expect(serialized).not.toContain('provider-secret') + expect(serialized).not.toContain('203.0.113.10') + expect(serialized).not.toContain('046a36ac-e03c-4590-9257-bd6c9dba9ee8') + expect(serialized).not.toContain('86a84313-9b9f-46d0-9cbb-09d67f18c8f6') + expect(serialized).not.toContain('failed') + }) + + it.concurrent('handles empty SSO log metadata without throwing', () => { + expect(getSsoLogMetadata()).toMatchObject({ + authenticatedProviderCount: 0, + authenticatedSsoProviderCount: 0, + hasDomain: false, + hasEmail: false, + hasError: false, + hasIp: false, + hasOrgId: false, + hasProviderId: false, + hasUserId: false, + providerCount: 0, + ssoProviderCount: 0, + }) + }) +})