diff --git a/README.md b/README.md index 08120545543e0..0b5a5e6f4556c 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ You can also [self-host](https://supabase.com/docs/guides/hosting/overview) and - [Storage](https://github.com/supabase/storage-api) a RESTful API for managing files in S3, with Postgres handling permissions. - [pg_graphql](http://github.com/supabase/pg_graphql/) a PostgreSQL extension that exposes a GraphQL API. - [postgres-meta](https://github.com/supabase/postgres-meta) is a RESTful API for managing your Postgres, allowing you to fetch tables, add roles, and run queries, etc. -- [Kong](https://github.com/Kong/kong) is a cloud-native API gateway. +- [Envoy](https://github.com/envoyproxy/envoy) is a cloud-native, high-performance edge and service proxy. #### Client libraries diff --git a/apps/docs/content/guides/auth/architecture.mdx b/apps/docs/content/guides/auth/architecture.mdx index b4c0a53092b64..3665526c40c12 100644 --- a/apps/docs/content/guides/auth/architecture.mdx +++ b/apps/docs/content/guides/auth/architecture.mdx @@ -6,12 +6,12 @@ subtitle: 'The architecture behind Supabase Auth.' There are four major layers to Supabase Auth: 1. [Client layer.](#client-layer) This can be one of the Supabase client SDKs, or manually made HTTP requests using the HTTP client of your choice. -1. Kong API gateway. This is shared between all Supabase products. +1. Envoy API gateway. This is shared between all Supabase products. 1. [Auth service](#auth-service) (formerly known as GoTrue). 1. [Postgres database.](#postgres) This is shared between all Supabase products. Diagram showing the architecture of Supabase. The Kong API gateway sits in front of 7 services: GoTrue, PostgREST, Realtime, Storage, pg_meta, Functions, and pg_graphql. All the services talk to a single Postgres instance. - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -171,4 +171,23 @@ + +ENVOY +envoyproxy.io +POSTGRESQL +postgresql.org +GoTrue +PostgREST +Realtime +Storage +pg-meta +Functions +pg_graphql +/auth +/rest +/realtime +/storage +/pg +/functions +/graphql diff --git a/apps/docs/public/img/supabase-architecture.svg b/apps/docs/public/img/supabase-architecture.svg index 2b0ad6325a99a..6b4dfd3dc8f15 100644 --- a/apps/docs/public/img/supabase-architecture.svg +++ b/apps/docs/public/img/supabase-architecture.svg @@ -15,69 +15,69 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -171,4 +171,23 @@ + +ENVOY +envoyproxy.io +POSTGRESQL +postgresql.org +GoTrue +PostgREST +Realtime +Storage +pg-meta +Functions +pg_graphql +/auth +/rest +/realtime +/storage +/pg +/functions +/graphql diff --git a/apps/studio/lib/session-replay.test.ts b/apps/studio/lib/session-replay.test.ts new file mode 100644 index 0000000000000..eac2fa5864619 --- /dev/null +++ b/apps/studio/lib/session-replay.test.ts @@ -0,0 +1,162 @@ +import { buildSessionRecordingConfig, type CapturedNetworkRequest } from 'common' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { maskReplayNetworkRequest, maskReplayText, SESSION_REPLAY_CONFIG } from './session-replay' + +const elementWith = (attributes: Record) => { + const element = document.createElement('span') + Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value)) + return element +} + +const networkRequest = (name: string): CapturedNetworkRequest => ({ + name, + entryType: 'resource', + startTime: 0, + duration: 0, +}) + +describe('maskReplayText', () => { + it('masks text by default', () => { + expect(maskReplayText('postgresql://postgres:hunter2@db.abc.supabase.co:5432')).toBe( + '*'.repeat('postgresql://postgres:hunter2@db.abc.supabase.co:5432'.length) + ) + }) + + it('masks text when no element is given', () => { + expect(maskReplayText('secret', undefined)).toBe('******') + }) + + it('masks based on trimmed length so whitespace is not leaked', () => { + expect(maskReplayText(' abc ')).toBe('***') + }) + + it('captures text opted in with data-ph-capture', () => { + const element = elementWith({ 'data-ph-capture': 'true' }) + expect(maskReplayText('Table editor', element)).toBe('Table editor') + }) + + it('masks text when data-ph-capture is not exactly "true"', () => { + expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'false' }))).toBe('******') + expect(maskReplayText('secret', elementWith({ 'data-ph-capture': '' }))).toBe('******') + expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'TRUE' }))).toBe('******') + }) + + it('masks text on elements carrying unrelated data attributes', () => { + expect(maskReplayText('secret', elementWith({ 'data-capture': 'true' }))).toBe('******') + }) +}) + +describe('maskReplayNetworkRequest', () => { + it('strips query strings', () => { + expect( + maskReplayNetworkRequest(networkRequest('https://api.supabase.com/v1/x?token=abc')).name + ).toBe('https://api.supabase.com/v1/x') + }) + + it('strips fragments, which carry GoTrue access tokens on auth callbacks', () => { + expect( + maskReplayNetworkRequest(networkRequest('https://supabase.com/dashboard#access_token=abc')) + .name + ).toBe('https://supabase.com/dashboard') + }) + + it('strips from the first separator when both are present', () => { + expect(maskReplayNetworkRequest(networkRequest('https://x.com/a?b=1#c=2')).name).toBe( + 'https://x.com/a' + ) + expect(maskReplayNetworkRequest(networkRequest('https://x.com/a#c=2?b=1')).name).toBe( + 'https://x.com/a' + ) + }) + + it('leaves URLs without a query string or fragment alone', () => { + expect(maskReplayNetworkRequest(networkRequest('https://x.com/project/abc/editor')).name).toBe( + 'https://x.com/project/abc/editor' + ) + }) + + it('returns the request rather than dropping it, so timings are still captured', () => { + const request = networkRequest('https://x.com/a?b=1') + expect(maskReplayNetworkRequest(request)).toBe(request) + }) +}) + +describe('SESSION_REPLAY_CONFIG', () => { + it('masks all text and inputs', () => { + expect(SESSION_REPLAY_CONFIG.maskTextSelector).toBe('*') + expect(SESSION_REPLAY_CONFIG.maskAllInputs).toBe(true) + expect(SESSION_REPLAY_CONFIG.maskTextFn).toBe(maskReplayText) + }) + + it('never records request or response payloads', () => { + expect(SESSION_REPLAY_CONFIG.recordHeaders).toBe(false) + expect(SESSION_REPLAY_CONFIG.recordBody).toBe(false) + }) + + it('never records canvas, which text masking cannot reach', () => { + expect(SESSION_REPLAY_CONFIG.captureCanvas).toEqual({ recordCanvas: false }) + }) + + it('strips sensitive URL parts via maskReplayNetworkRequest', () => { + expect(SESSION_REPLAY_CONFIG.maskCapturedNetworkRequestFn).toBe(maskReplayNetworkRequest) + }) +}) + +describe('buildSessionRecordingConfig', () => { + it('disables recording when given no policy', () => { + const config = buildSessionRecordingConfig() + + expect(config.disable_session_recording).toBe(true) + expect(config).not.toHaveProperty('session_recording') + }) + + it('disables recording when the policy is undefined', () => { + const config = buildSessionRecordingConfig(undefined) + + expect(config.disable_session_recording).toBe(true) + expect(config).not.toHaveProperty('session_recording') + }) + + it('enables recording and forwards the policy when given one', () => { + const config = buildSessionRecordingConfig(SESSION_REPLAY_CONFIG) + + expect(config.disable_session_recording).toBe(false) + expect(config.session_recording).toBe(SESSION_REPLAY_CONFIG) + }) + + it.each([undefined, SESSION_REPLAY_CONFIG])( + 'never records console logs, which masking cannot reach (%#)', + (sessionReplay) => { + expect(buildSessionRecordingConfig(sessionReplay).enable_recording_console_log).toBe(false) + } + ) +}) + +describe('IS_SESSION_REPLAY_ENABLED', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('is true only for the exact string "true"', async () => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', 'true') + const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay') + expect(IS_SESSION_REPLAY_ENABLED).toBe(true) + }) + + it.each(['false', '', 'TRUE', '1'])('is false for %o', async (value) => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', value) + const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay') + expect(IS_SESSION_REPLAY_ENABLED).toBe(false) + }) + + it('is false when unset', async () => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', undefined) + const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay') + expect(IS_SESSION_REPLAY_ENABLED).toBe(false) + }) +}) diff --git a/apps/studio/lib/session-replay.ts b/apps/studio/lib/session-replay.ts new file mode 100644 index 0000000000000..db7283d1dee75 --- /dev/null +++ b/apps/studio/lib/session-replay.ts @@ -0,0 +1,50 @@ +import type { CapturedNetworkRequest, SessionRecordingOptions } from 'common' + +/** + * Enables session replay in Studio. Recording also requires "Record user + * sessions" in PostHog, which www and docs share. + */ +export const IS_SESSION_REPLAY_ENABLED = process.env.NEXT_PUBLIC_POSTHOG_SESSION_REPLAY === 'true' + +/** + * Setting `data-ph-capture="true"` on an element opts its text in to session + * recording. All text is opted out by default. + */ +const CAPTURE_DATASET_KEY = 'phCapture' + +/** + * Returns asterisks for all text except text inside elements marked + * `data-ph-capture="true"`. + */ +export function maskReplayText(text: string, element?: HTMLElement): string { + if (element?.dataset[CAPTURE_DATASET_KEY] === 'true') return text + return '*'.repeat(text.trim().length) +} + +/** + * Strips query strings and fragments from recorded URLs, which posthog-js applies + * to page URLs as well as network requests. Auth callbacks carry tokens in the + * fragment. + */ +export function maskReplayNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest { + if (request.name) { + const separatorIndex = request.name.search(/[?#]/) + if (separatorIndex !== -1) { + request.name = request.name.slice(0, separatorIndex) + } + } + return request +} + +export const SESSION_REPLAY_CONFIG: SessionRecordingOptions = { + // Match posthog-js defaults, but set here so the PostHog UI can't relax them. + maskAllInputs: true, + maskTextSelector: '*', + maskTextFn: maskReplayText, + // Keeps network capture to URL, status and timing. Overrides the PostHog UI. + recordHeaders: false, + recordBody: false, + // Canvas is captured as images, which text masking can't reach. + captureCanvas: { recordCanvas: false }, + maskCapturedNetworkRequestFn: maskReplayNetworkRequest, +} diff --git a/apps/studio/lib/telemetry.tsx b/apps/studio/lib/telemetry.tsx index cdf1a5b1c0011..cd7f53762a1f0 100644 --- a/apps/studio/lib/telemetry.tsx +++ b/apps/studio/lib/telemetry.tsx @@ -6,6 +6,7 @@ import { useConsentToast } from 'ui-patterns/consent' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { API_URL, IS_PLATFORM } from '@/lib/constants' +import { IS_SESSION_REPLAY_ENABLED, SESSION_REPLAY_CONFIG } from '@/lib/session-replay' export function Telemetry() { // Although this is "technically" breaking the rules of hooks @@ -70,6 +71,7 @@ export function Telemetry() { hasAcceptedConsent={hasAcceptedConsent} enabled={IS_PLATFORM} organizationSlug={organization?.slug} + sessionReplay={IS_SESSION_REPLAY_ENABLED ? SESSION_REPLAY_CONFIG : undefined} /> ) } diff --git a/packages/common/posthog-client.ts b/packages/common/posthog-client.ts index 9374535025c77..19dad1f57f79c 100644 --- a/packages/common/posthog-client.ts +++ b/packages/common/posthog-client.ts @@ -1,7 +1,13 @@ -import posthog, { PostHogConfig } from 'posthog-js' +import posthog, { + type CapturedNetworkRequest, + type PostHogConfig, + type SessionRecordingOptions, +} from 'posthog-js' import { safeSessionStorage } from './safe-storage' +export type { CapturedNetworkRequest, SessionRecordingOptions } + // Limit the max number of queued events // (e.g. if a user navigates around a lot before accepting consent) const MAX_PENDING_EVENTS = 20 @@ -23,6 +29,30 @@ interface PostHogClientConfig { uiHost?: string } +interface PostHogInitOptions { + hasConsent?: boolean + /** + * Masking policy for session replay. Omit to disable recording, which every app + * sharing this PostHog project does unless it passes a policy of its own. + */ + sessionReplay?: SessionRecordingOptions +} + +/** + * Enables session recording when given a masking config, and disables it when + * given nothing. + */ +export function buildSessionRecordingConfig( + sessionReplay?: SessionRecordingOptions +): Partial { + return { + disable_session_recording: !sessionReplay, + // Console output is not in the DOM, so text masking cannot reach it. + enable_recording_console_log: false, + ...(sessionReplay && { session_recording: sessionReplay }), + } +} + class PostHogClient { /** True after posthog.init() is called (prevents double-init) */ private initStarted = false @@ -50,7 +80,7 @@ class PostHogClient { } } - init(hasConsent: boolean = true) { + init({ hasConsent = true, sessionReplay }: PostHogInitOptions = {}) { if (this.initStarted || typeof window === 'undefined' || !hasConsent) return if (!this.config.apiKey) { @@ -64,6 +94,7 @@ class PostHogClient { autocapture: false, // We'll manually track events capture_pageview: false, // We'll manually track pageviews capture_pageleave: false, // We'll manually track page leaves + ...buildSessionRecordingConfig(sessionReplay), loaded: (posthog) => { // Apply pending properties that were set before PostHog // initialized due to poor connection or user not accepting diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index b92aae494cca1..4e8e13aa9f897 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -21,7 +21,13 @@ import { } from './first-referrer-cookie' import { ensurePlatformSuffix, isBrowser } from './helpers' import { useFirstTouchStore, useParams } from './hooks' -import { posthogClient, type ClientTelemetryEvent } from './posthog-client' +import { + buildSessionRecordingConfig, + posthogClient, + type CapturedNetworkRequest, + type ClientTelemetryEvent, + type SessionRecordingOptions, +} from './posthog-client' import { TelemetryEvent } from './telemetry-constants' import { clearFirstTouchData, @@ -30,7 +36,13 @@ import { } from './telemetry-first-touch-store' import { getSharedTelemetryData, getTelemetryCookieOptions } from './telemetry-utils' -export { posthogClient, type ClientTelemetryEvent } +export { + buildSessionRecordingConfig, + posthogClient, + type CapturedNetworkRequest, + type ClientTelemetryEvent, + type SessionRecordingOptions, +} export const TelemetryTagManager = () => { const { hasAccepted } = useConsentState() @@ -255,12 +267,15 @@ export const PageTelemetry = ({ enabled = true, organizationSlug, projectRef, + sessionReplay, }: { API_URL: string hasAcceptedConsent: boolean enabled?: boolean organizationSlug?: string projectRef?: string + /** Masking policy for session replay. Omit to disable recording. */ + sessionReplay?: SessionRecordingOptions }) => { const router = useRouter() @@ -315,9 +330,9 @@ export const PageTelemetry = ({ useEffect(() => { if (hasAcceptedConsent && IS_PLATFORM) { - posthogClient.init(true) + posthogClient.init({ sessionReplay }) } - }, [hasAcceptedConsent, IS_PLATFORM]) + }, [hasAcceptedConsent, IS_PLATFORM, sessionReplay]) // Waiting for router.isReady before sending to avoid dynamic route placeholders useEffect(() => {