diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 2008159ba..e7aefa073 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -1,3 +1,5 @@ +import { ApiKeyCollection } from '../api-key/api-key-collection.decorator' +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import _isEmpty from 'lodash/isEmpty' import _isArray from 'lodash/isArray' import _toNumber from 'lodash/toNumber' @@ -255,6 +257,7 @@ export class AnalyticsController { @ApiBearerAuth() @Get() @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', ['events:read']) async getData( @Query() data: GetDataDto, @CurrentUserId() uid: string, @@ -432,6 +435,7 @@ export class AnalyticsController { @Get('funnel') @Auth(true, true) + @ApiKeyAccess('funnels:read', 'query', 'pid') async getFunnel( @Query() data: GetFunnelsDto, @CurrentUserId() uid: string, @@ -569,6 +573,11 @@ export class AnalyticsController { @Get('funnel-sessions') @Auth(true, true) + @ApiKeyAccess('funnels:read', 'query', 'pid', [ + 'analytics:read', + 'events:read', + 'errors:read', + ]) async getFunnelSessions( @Query() data: GetFunnelSessionsDto, @CurrentUserId() uid: string, @@ -668,6 +677,7 @@ export class AnalyticsController { @Get('meta') @Auth(true, true) + @ApiKeyAccess('events:read', 'query', 'pid') async getCustomEventMetadata( @Query() data: GetCustomEventMetadata, @CurrentUserId() uid: string, @@ -690,6 +700,7 @@ export class AnalyticsController { @Get('property') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getPagePropertyMetadata( @Query() data: GetPagePropertyMetaDto, @CurrentUserId() uid: string, @@ -712,6 +723,7 @@ export class AnalyticsController { @Get('filters') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getFilters( @Query() data: GetFiltersDto, @CurrentUserId() uid: string, @@ -734,6 +746,7 @@ export class AnalyticsController { @Get('errors-filters') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid') async getErrorsFilters( @Query() data: GetFiltersDto, @CurrentUserId() uid: string, @@ -759,6 +772,7 @@ export class AnalyticsController { @Get('filters/versions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getVersionFilters( @Query() data: GetVersionFiltersDto, @CurrentUserId() uid: string, @@ -784,6 +798,7 @@ export class AnalyticsController { @Get('chart') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getChartData( @Query() data: GetDataDto, @CurrentUserId() uid: string, @@ -849,6 +864,7 @@ export class AnalyticsController { @Get('performance') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getPerfData( @Query() data: GetDataDto & { measure: PerfMeasure }, @CurrentUserId() uid: string, @@ -943,6 +959,7 @@ export class AnalyticsController { @Get('performance/chart') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getPerfChartData( @Query() data: GetDataDto & { measure: PerfMeasure }, @CurrentUserId() uid: string, @@ -1010,6 +1027,7 @@ export class AnalyticsController { @Get('captcha') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getCaptchaData( @Query() data: GetDataDto, @CurrentUserId() uid: string, @@ -1019,6 +1037,7 @@ export class AnalyticsController { @Get('journeys') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getJourneys( @Query() data: GetJourneysDto, @CurrentUserId() uid: string, @@ -1101,6 +1120,11 @@ export class AnalyticsController { @Get('journey-sessions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'analytics:read', + 'events:read', + 'errors:read', + ]) async getJourneySessions( @Query() data: GetJourneySessionsDto, @CurrentUserId() uid: string, @@ -1181,6 +1205,7 @@ export class AnalyticsController { @Get('keywords') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getKeywords( @Query() data: GetKeywordsDto, @CurrentUserId() uid: string, @@ -1216,6 +1241,7 @@ export class AnalyticsController { @Get('birdseye') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') // returns overall short statistics per project async getOverallStats( @Query() data: GetOverallStatsDto, @@ -1278,6 +1304,7 @@ export class AnalyticsController { @Get('performance/birdseye') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getPerformanceOverallStats( @Query() data: GetOverallStatsDto & { measure: PerfMeasure }, @CurrentUserId() uid: string, @@ -1353,6 +1380,7 @@ export class AnalyticsController { @Get('hb') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getHeartBeatStats( @Query() data: GetHeartbeatStatsDto, @CurrentUserId() uid: string, @@ -1409,6 +1437,7 @@ export class AnalyticsController { @Get('live-visitors') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getLiveVisitors( @Query() queryParams: LiveVisitorsDto, @CurrentUserId() uid: string, @@ -1470,6 +1499,7 @@ export class AnalyticsController { @Get('bot-stats') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getBotStats( @Query() data: GetBotStatsDto, @CurrentUserId() uid: string, @@ -1493,6 +1523,8 @@ export class AnalyticsController { } @Post('session-replay/start') + @ApiKeyCollection() + @ApiKeyAccess('replays:write', 'body', 'pid') @Public() async startSessionReplay( @Body() replayDTO: SessionReplayStartDto, @@ -1540,6 +1572,8 @@ export class AnalyticsController { } @Post('session-replay/chunk') + @ApiKeyCollection() + @ApiKeyAccess('replays:write', 'body', 'pid') @Public() async uploadSessionReplayChunk( @Body() replayDTO: SessionReplayChunkDto, @@ -1588,6 +1622,8 @@ export class AnalyticsController { } @Post('error') + @ApiKeyCollection() + @ApiKeyAccess('errors:write', 'body', 'pid') @Public() async logError(@Body() errorDTO: ErrorDto, @Headers() headers, @Ip() reqIP) { const { 'user-agent': userAgent, origin } = headers @@ -1708,6 +1744,7 @@ export class AnalyticsController { // Update error(s) status @Patch('error-status') @Auth(true, true) + @ApiKeyAccess('errors:write', 'body', 'pid') async patchStatus( @Body() statusDTO: PatchStatusDto, @CurrentUserId() uid: string, @@ -1730,6 +1767,8 @@ export class AnalyticsController { } @Post('custom') + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async logCustom( @Body() eventsDTO: EventsDto, @@ -1950,6 +1989,7 @@ export class AnalyticsController { @Post('hb') @Auth(true, true) + @ApiKeyAccess('events:write', 'body', 'pid') async heartbeat( @Body() logDTO: PageviewsDto, @Headers() headers, @@ -2004,6 +2044,8 @@ export class AnalyticsController { // Log pageview event @Post() + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async log(@Body() logDTO: PageviewsDto, @Headers() headers, @Ip() reqIP) { const { 'user-agent': userAgent, origin } = headers @@ -2301,6 +2343,12 @@ export class AnalyticsController { @Get('sessions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + 'replays:read', + 'revenue:read', + ]) async getSessions( @Query() data: GetSessionsDto, @CurrentUserId() uid: string, @@ -2395,6 +2443,7 @@ export class AnalyticsController { @Get('session-replays') @Auth(true, true) + @ApiKeyAccess('replays:read', 'query', 'pid') async getSessionReplays( @Query() data: GetSessionReplaysDto, @CurrentUserId() uid: string, @@ -2478,6 +2527,7 @@ export class AnalyticsController { @Get('errors') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid') async getErrors( @Query() data: GetErrorsDto, @CurrentUserId() uid: string, @@ -2566,6 +2616,7 @@ export class AnalyticsController { @Get('get-error') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid') async getError( @Query() data: GetErrorDto, @CurrentUserId() uid: string, @@ -2631,6 +2682,7 @@ export class AnalyticsController { @Get('error-overview') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid') async getErrorOverview( @Query() data: GetErrorOverviewDto, @CurrentUserId() uid: string, @@ -2728,6 +2780,7 @@ export class AnalyticsController { @Get('error-sessions') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid', ['analytics:read']) async getErrorSessions( @Query() data: GetErrorDto & { take?: number; skip?: number }, @CurrentUserId() uid: string, @@ -2809,6 +2862,12 @@ export class AnalyticsController { @Get('session') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + 'replays:read', + 'revenue:read', + ]) async getSession( @Query() data: GetSessionDto, @CurrentUserId() uid: string, @@ -2839,6 +2898,7 @@ export class AnalyticsController { @Get('session-replay') @Auth(true, true) + @ApiKeyAccess('replays:read', 'query', 'pid') async getSessionReplay( @Query() data: GetSessionReplayDto, @CurrentUserId() uid: string, @@ -2864,6 +2924,7 @@ export class AnalyticsController { @Delete('session-replay') @Auth(true, true) + @ApiKeyAccess('replays:write', 'query', 'pid') async deleteSessionReplay( @Query() data: GetSessionReplayDto, @CurrentUserId() uid: string, @@ -2929,6 +2990,7 @@ export class AnalyticsController { @Post('session-replay/export') @Auth(true, true) + @ApiKeyAccess('replays:read', 'body', 'pid') async startSessionReplayExport( @Body() data: SessionReplayExportStartDto, @CurrentUserId() uid: string | null, @@ -2959,6 +3021,7 @@ export class AnalyticsController { @Get('session-replay/export/:exportId') @Auth(true, true) + @ApiKeyAccess('replays:read', 'export', 'exportId') async getSessionReplayExport( @Param('exportId') exportId: string, @CurrentUserId() uid: string | null, @@ -2980,6 +3043,7 @@ export class AnalyticsController { @Get('session-replay/export/:exportId/download') @Auth(true, true) + @ApiKeyAccess('replays:read', 'export', 'exportId') async downloadSessionReplayExport( @Param('exportId') exportId: string, @CurrentUserId() uid: string | null, @@ -3002,6 +3066,10 @@ export class AnalyticsController { @Get('profiles') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + ]) async getProfiles( @Query() data: GetProfilesDto, @CurrentUserId() uid: string, @@ -3092,6 +3160,11 @@ export class AnalyticsController { @Get('profile') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + 'revenue:read', + ]) async getProfile( @Query() data: GetProfileDto, @CurrentUserId() uid: string, @@ -3129,6 +3202,10 @@ export class AnalyticsController { @Get('profile/sessions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + ]) async getProfileSessions( @Query() data: GetProfileSessionsDto, @CurrentUserId() uid: string, @@ -3219,6 +3296,7 @@ export class AnalyticsController { @Get('custom-events') @Auth(true, true) + @ApiKeyAccess('events:read', 'query', 'pid') async getCustomEvents( @Query() data: GetCustomEventsDto, @CurrentUserId() uid: string, @@ -3325,6 +3403,8 @@ export class AnalyticsController { * profile and shown on its dashboard page. */ @Post('identify') + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async identify( @Body() dto: IdentifyDto, @@ -3416,6 +3496,8 @@ export class AnalyticsController { // Revenue attribution endpoints @Post('profile-id') + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async getOrCreateProfileId( @Body() dto: GetProfileIdDto, @@ -3447,6 +3529,8 @@ export class AnalyticsController { } @Post('session-id') + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async getOrCreateSessionId( @Body() dto: GetSessionIdDto, diff --git a/backend/apps/cloud/src/analytics/v2/controllers/captcha-v2.controller.ts b/backend/apps/cloud/src/analytics/v2/controllers/captcha-v2.controller.ts index b2e0c313b..ecfd296b1 100644 --- a/backend/apps/cloud/src/analytics/v2/controllers/captcha-v2.controller.ts +++ b/backend/apps/cloud/src/analytics/v2/controllers/captcha-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -47,6 +48,7 @@ export class CaptchaV2Controller { @Get('summary') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Captcha summary', @@ -72,6 +74,7 @@ export class CaptchaV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Captcha timeseries', @@ -97,6 +100,7 @@ export class CaptchaV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Captcha breakdown', diff --git a/backend/apps/cloud/src/analytics/v2/controllers/errors-v2.controller.ts b/backend/apps/cloud/src/analytics/v2/controllers/errors-v2.controller.ts index 902f3bdd8..a2b606829 100644 --- a/backend/apps/cloud/src/analytics/v2/controllers/errors-v2.controller.ts +++ b/backend/apps/cloud/src/analytics/v2/controllers/errors-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -52,6 +53,7 @@ export class ErrorsV2Controller { @Get() @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Error groups', @@ -77,6 +79,7 @@ export class ErrorsV2Controller { @Get('overview') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Errors overview', @@ -102,6 +105,7 @@ export class ErrorsV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Errors timeseries', @@ -126,6 +130,7 @@ export class ErrorsV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Errors breakdown', @@ -154,6 +159,7 @@ export class ErrorsV2Controller { @Get(':eid') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @ApiOperation({ summary: 'Error details', description: @@ -181,6 +187,7 @@ export class ErrorsV2Controller { @Get(':eid/sessions') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid', ['analytics:read']) @ApiOperation({ summary: 'Error sessions', description: 'Paginated sessions affected by a specific error group.', diff --git a/backend/apps/cloud/src/analytics/v2/controllers/performance-v2.controller.ts b/backend/apps/cloud/src/analytics/v2/controllers/performance-v2.controller.ts index 664f8d91e..cf1a5bf7b 100644 --- a/backend/apps/cloud/src/analytics/v2/controllers/performance-v2.controller.ts +++ b/backend/apps/cloud/src/analytics/v2/controllers/performance-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -48,6 +49,7 @@ export class PerformanceV2Controller { @Get('summary') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Performance summary', @@ -73,6 +75,7 @@ export class PerformanceV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Performance timeseries', @@ -101,6 +104,7 @@ export class PerformanceV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Performance breakdown', diff --git a/backend/apps/cloud/src/analytics/v2/controllers/profiles-v2.controller.ts b/backend/apps/cloud/src/analytics/v2/controllers/profiles-v2.controller.ts index 7b4e29f96..c3f7aeb30 100644 --- a/backend/apps/cloud/src/analytics/v2/controllers/profiles-v2.controller.ts +++ b/backend/apps/cloud/src/analytics/v2/controllers/profiles-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -47,6 +48,10 @@ export class ProfilesV2Controller { @Get() @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + ]) @CacheableAnalytics() @ApiOperation({ summary: 'Profiles', @@ -72,6 +77,11 @@ export class ProfilesV2Controller { @Get(':profileId') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + 'revenue:read', + ]) @ApiOperation({ summary: 'Profile details', description: @@ -103,6 +113,10 @@ export class ProfilesV2Controller { @Get(':profileId/sessions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + ]) @ApiOperation({ summary: 'Profile sessions', description: 'Paginated sessions recorded for a specific profile.', diff --git a/backend/apps/cloud/src/analytics/v2/controllers/project-v2.controller.ts b/backend/apps/cloud/src/analytics/v2/controllers/project-v2.controller.ts index bb3a81ff2..75a8710b0 100644 --- a/backend/apps/cloud/src/analytics/v2/controllers/project-v2.controller.ts +++ b/backend/apps/cloud/src/analytics/v2/controllers/project-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -51,6 +52,7 @@ export class ProjectV2Controller { @Get('funnel') @Auth(true, true) + @ApiKeyAccess('funnels:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Funnel analysis', @@ -76,6 +78,11 @@ export class ProjectV2Controller { @Get('funnel/sessions') @Auth(true, true) + @ApiKeyAccess('funnels:read', 'params', 'pid', [ + 'analytics:read', + 'events:read', + 'errors:read', + ]) @CacheableAnalytics() @ApiOperation({ summary: 'Funnel sessions', @@ -104,6 +111,7 @@ export class ProjectV2Controller { @Get('live-visitors') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @ApiOperation({ summary: 'Live visitors', description: @@ -127,6 +135,7 @@ export class ProjectV2Controller { @Get('dimensions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @ApiOperation({ summary: 'Dimension & metric discovery', description: @@ -157,6 +166,7 @@ export class ProjectV2Controller { @Get('dimensions/:dimension/values') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @ApiOperation({ summary: 'Dimension values', description: diff --git a/backend/apps/cloud/src/analytics/v2/controllers/seo-v2.controller.ts b/backend/apps/cloud/src/analytics/v2/controllers/seo-v2.controller.ts index 85ee82f0c..423d4662c 100644 --- a/backend/apps/cloud/src/analytics/v2/controllers/seo-v2.controller.ts +++ b/backend/apps/cloud/src/analytics/v2/controllers/seo-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -50,6 +51,7 @@ export class SeoV2Controller { @Get('status') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Search Console connection status', @@ -74,6 +76,7 @@ export class SeoV2Controller { @Get('summary') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'SEO summary', @@ -99,6 +102,7 @@ export class SeoV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'SEO timeseries', @@ -124,6 +128,7 @@ export class SeoV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'SEO breakdown', @@ -152,6 +157,7 @@ export class SeoV2Controller { @Get('branded-traffic') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Branded vs non-branded traffic', @@ -177,6 +183,7 @@ export class SeoV2Controller { @Get('positions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Search position analytics', diff --git a/backend/apps/cloud/src/analytics/v2/controllers/sessions-v2.controller.ts b/backend/apps/cloud/src/analytics/v2/controllers/sessions-v2.controller.ts index 9870e3744..c7d9ac079 100644 --- a/backend/apps/cloud/src/analytics/v2/controllers/sessions-v2.controller.ts +++ b/backend/apps/cloud/src/analytics/v2/controllers/sessions-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -47,6 +48,12 @@ export class SessionsV2Controller { @Get() @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + 'replays:read', + 'revenue:read', + ]) @CacheableAnalytics() @ApiOperation({ summary: 'Sessions', @@ -72,6 +79,12 @@ export class SessionsV2Controller { @Get(':psid') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + 'replays:read', + 'revenue:read', + ]) @ApiOperation({ summary: 'Session details', description: diff --git a/backend/apps/cloud/src/analytics/v2/controllers/traffic-v2.controller.ts b/backend/apps/cloud/src/analytics/v2/controllers/traffic-v2.controller.ts index 60c5b40db..49ca3583c 100644 --- a/backend/apps/cloud/src/analytics/v2/controllers/traffic-v2.controller.ts +++ b/backend/apps/cloud/src/analytics/v2/controllers/traffic-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -54,6 +55,7 @@ export class TrafficV2Controller { @Get('summary') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Traffic summary', @@ -79,6 +81,7 @@ export class TrafficV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Traffic timeseries', @@ -104,6 +107,7 @@ export class TrafficV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Traffic breakdown', @@ -132,6 +136,7 @@ export class TrafficV2Controller { @Get('custom-events') @Auth(true, true) + @ApiKeyAccess('events:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Custom events', @@ -160,6 +165,7 @@ export class TrafficV2Controller { @Get('custom-metrics') @Auth(true, true) + @ApiKeyAccess('events:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Custom metrics', @@ -188,6 +194,7 @@ export class TrafficV2Controller { @Get('custom-events/timeseries') @Auth(true, true) + @ApiKeyAccess('events:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Custom events timeseries', @@ -216,6 +223,7 @@ export class TrafficV2Controller { @Get('custom-events/metadata') @Auth(true, true) + @ApiKeyAccess('events:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Custom event metadata', @@ -244,6 +252,7 @@ export class TrafficV2Controller { @Get('page-properties/metadata') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Page property values', @@ -271,6 +280,7 @@ export class TrafficV2Controller { @Get('page-properties') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Page properties', diff --git a/backend/apps/cloud/src/api-key/api-key-access.decorator.ts b/backend/apps/cloud/src/api-key/api-key-access.decorator.ts new file mode 100644 index 000000000..6b5216c1a --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key-access.decorator.ts @@ -0,0 +1,15 @@ +import { SetMetadata } from '@nestjs/common' +import { API_KEY_ACCESS, ApiKeyPolicy, ApiKeyScope } from './api-key.types' + +export const ApiKeyAccess = ( + scope: ApiKeyScope, + source: ApiKeyPolicy['source'], + field = 'pid', + requires: ApiKeyScope[] = [], +) => + SetMetadata(API_KEY_ACCESS, { + scope, + source, + field, + requires, + } satisfies ApiKeyPolicy) diff --git a/backend/apps/cloud/src/api-key/api-key-collection.decorator.ts b/backend/apps/cloud/src/api-key/api-key-collection.decorator.ts new file mode 100644 index 000000000..0e5d18332 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key-collection.decorator.ts @@ -0,0 +1,20 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UseGuards, +} from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' +import { ApiKeyGuard } from './api-key.guard' +import { ApiKeyRateLimitGuard } from '../auth/guards/api-key-rate-limit.guard' + +@Injectable() +class CollectionKeyGuard extends AuthGuard('api-key') implements CanActivate { + canActivate(context: ExecutionContext) { + if (!context.switchToHttp().getRequest().headers?.['x-api-key']) return true + return super.canActivate(context) + } +} + +export const ApiKeyCollection = () => + UseGuards(CollectionKeyGuard, ApiKeyGuard, ApiKeyRateLimitGuard) diff --git a/backend/apps/cloud/src/api-key/api-key-policy.spec.ts b/backend/apps/cloud/src/api-key/api-key-policy.spec.ts new file mode 100644 index 000000000..6168c5885 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key-policy.spec.ts @@ -0,0 +1,94 @@ +import fs from 'fs' +import path from 'path' +import ts from 'typescript' + +function files(directory: string): string[] { + return fs + .readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => + entry.isDirectory() + ? files(path.join(directory, entry.name)) + : entry.name.endsWith('.controller.ts') + ? [path.join(directory, entry.name)] + : [], + ) +} +const decorators = (node: ts.Node) => + ts.canHaveDecorators(node) + ? ts + .getDecorators(node) + ?.map((decorator) => decorator.expression) + .filter(ts.isCallExpression) || [] + : [] +const named = (calls: ts.CallExpression[], name: string) => + calls.find((call) => call.expression.getText() === name) + +describe.each(['cloud', 'community'])( + '%s API permission coverage', + (edition) => { + const root = path.resolve(__dirname, '../../..', edition, 'src') + it('has explicit supported policies for every API-enabled controller method', () => { + let count = 0 + for (const file of files(root)) { + const source = ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ) + for (const controller of source.statements.filter( + ts.isClassDeclaration, + )) { + const classPath = + named( + decorators(controller), + 'Controller', + )?.arguments[0]?.getText() || '' + for (const method of controller.members.filter( + ts.isMethodDeclaration, + )) { + const calls = decorators(method) + const auth = named(calls, 'Auth') + if ( + auth?.arguments[0]?.kind !== ts.SyntaxKind.TrueKeyword && + !named(calls, 'ApiKeyCollection') + ) + continue + count++ + const policy = named(calls, 'ApiKeyAccess') + if (!policy) + throw new Error( + `${file}:${method.name.getText()} has no API permission policy`, + ) + const [scope, location, field] = policy.arguments + .slice(0, 3) + .map((argument) => (argument as ts.StringLiteral).text) + expect(scope).toMatch( + /^(analytics|events|projects|funnels|annotations|errors|replays|goals|flags|revenue|organisations):(read|write)$/, + ) + if (location === 'params') { + const route = calls.find((call) => + ['Get', 'Post', 'Put', 'Patch', 'Delete'].includes( + call.expression.getText(), + ), + ) + const routePath = route?.arguments[0]?.getText() || '' + if (!`${classPath}/${routePath}`.includes(`:${field}`)) + throw new Error( + `${file}:${method.name.getText()} references a nonexistent path parameter ${field}`, + ) + } + if (['goal', 'flag', 'export'].includes(location)) { + const params = method.parameters + .flatMap((parameter) => decorators(parameter)) + .filter((call) => call.expression.getText() === 'Param') + .map((call) => (call.arguments[0] as ts.StringLiteral)?.text) + expect(params).toContain(field) + } + } + } + } + expect(count).toBeGreaterThan(90) + }) + }, +) diff --git a/backend/apps/cloud/src/api-key/api-key.controller.ts b/backend/apps/cloud/src/api-key/api-key.controller.ts new file mode 100644 index 000000000..1d60e4f01 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + Param, + Patch, + Post, + Query, + UsePipes, + ValidationPipe, +} from '@nestjs/common' +import { Auth } from '../auth/decorators' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { ApiKeyService } from './api-key.service' +import { SaveApiKeyDto } from './api-key.dto' +import { checkRateLimit } from '../common/utils' + +@Controller(['user/api-keys', 'v1/user/api-keys']) +@Auth() +@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) +export class ApiKeyController { + constructor(private readonly keys: ApiKeyService) {} + + @Get() + @Header('Cache-Control', 'no-store') + list( + @CurrentUserId() userId: string, + @Query('projectId') projectId?: string, + ) { + return this.keys.list(userId, projectId) + } + + @Post() + @Header('Cache-Control', 'no-store') + async create(@CurrentUserId() userId: string, @Body() dto: SaveApiKeyDto) { + await checkRateLimit(userId, 'create-api-key', 20, 3600) + return this.keys.create(userId, dto) + } + + @Patch(':id') + update( + @CurrentUserId() userId: string, + @Param('id') id: string, + @Body() dto: SaveApiKeyDto, + ) { + return this.keys.update(userId, id, dto) + } + + @Post(':id/reveal') + @Header('Cache-Control', 'no-store') + async reveal(@CurrentUserId() userId: string, @Param('id') id: string) { + await checkRateLimit(userId, 'reveal-api-key', 60, 3600) + return this.keys.reveal(userId, id) + } + + @Post(':id/rotate') + @Header('Cache-Control', 'no-store') + async rotate(@CurrentUserId() userId: string, @Param('id') id: string) { + await checkRateLimit(userId, 'rotate-api-key', 20, 3600) + return this.keys.rotate(userId, id) + } + + @Delete(':id') + remove(@CurrentUserId() userId: string, @Param('id') id: string) { + return this.keys.remove(userId, id) + } +} diff --git a/backend/apps/cloud/src/api-key/api-key.crypto.ts b/backend/apps/cloud/src/api-key/api-key.crypto.ts new file mode 100644 index 000000000..8dfab6e36 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.crypto.ts @@ -0,0 +1,36 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'crypto' +import { deriveKey } from '../common/utils' + +export const hashApiKey = (key: string) => + createHash('sha256').update(key).digest('hex') +export const generateApiKey = () => `swx_${randomBytes(32).toString('hex')}` +const encryptionKey = () => { + if (!process.env.SECRET_KEY_BASE) + throw new Error('SECRET_KEY_BASE is required') + return Buffer.from(deriveKey('api-key', 32), 'hex') +} +export const encryptApiKey = (key: string, userId: string, id: string) => { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv) + cipher.setAAD(Buffer.from(`${userId}:${id}`)) + const encrypted = Buffer.concat([cipher.update(key, 'utf8'), cipher.final()]) + return [iv, cipher.getAuthTag(), encrypted] + .map((value) => value.toString('base64')) + .join('.') +} +export const decryptApiKey = (value: string, userId: string, id: string) => { + const [iv, tag, encrypted] = value + .split('.') + .map((part) => Buffer.from(part, 'base64')) + const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), iv) + decipher.setAAD(Buffer.from(`${userId}:${id}`)) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( + 'utf8', + ) +} diff --git a/backend/apps/cloud/src/api-key/api-key.dto.ts b/backend/apps/cloud/src/api-key/api-key.dto.ts new file mode 100644 index 000000000..8f431c12a --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.dto.ts @@ -0,0 +1,38 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsBoolean, + IsIn, + IsString, + Length, + Matches, +} from 'class-validator' +import { API_KEY_SCOPES, ApiKeyScope } from './api-key.types' + +export class SaveApiKeyDto { + @IsString() + @Length(1, 80) + name: string + + @IsArray() + @ArrayMaxSize(30) + @ArrayUnique() + @IsIn( + Object.entries(API_KEY_SCOPES).flatMap(([scope, actions]) => + actions.map((action) => `${scope}:${action}`), + ), + { each: true }, + ) + scopes: ApiKeyScope[] + + @IsBoolean() + allProjects: boolean + + @IsArray() + @ArrayMaxSize(1000) + @ArrayUnique() + @IsString({ each: true }) + @Matches(/^(?!.*--)[a-zA-Z0-9-]{12}$/, { each: true }) + projectIds: string[] +} diff --git a/backend/apps/cloud/src/api-key/api-key.guard.ts b/backend/apps/cloud/src/api-key/api-key.guard.ts new file mode 100644 index 000000000..5e836b605 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.guard.ts @@ -0,0 +1,132 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ApiKeyService } from './api-key.service' +import { API_KEY_ACCESS, ApiKeyPolicy, ApiKeyPrincipal } from './api-key.types' + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly keys: ApiKeyService, + ) {} + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest() + const key: ApiKeyPrincipal | undefined = request.user?.apiKeyAccess + if (!key || key.unrestricted) return true + const policy = this.reflector.get( + API_KEY_ACCESS, + context.getHandler(), + ) + if ( + !policy || + [policy.scope, ...(policy.requires || [])].some( + (scope) => !key.scopes.includes(scope), + ) + ) { + throw new ForbiddenException( + `This API key requires: ${policy ? [policy.scope, ...(policy.requires || [])].join(', ') : 'an explicit endpoint permission'}`, + ) + } + const query = request.query || {} + if (query.type === 'errors' && !key.scopes.includes('errors:read')) + throw new ForbiddenException('This API key requires errors:read') + if (!key.scopes.includes('events:read') && request.method !== 'POST') { + let filters = query.filters + if (typeof filters === 'string') { + try { + filters = JSON.parse(filters) + } catch { + throw new ForbiddenException('Invalid filters') + } + } + const eventDimension = (value: unknown) => + typeof value === 'string' && + (value === 'ev' || + value.startsWith('ev:') || + value === 'event' || + value === 'event_metadata') + if ( + eventDimension(query.type) || + eventDimension(request.params?.dimension) || + (Array.isArray(filters) && + filters.some( + (filter) => + eventDimension(filter?.column) || + eventDimension(filter?.dimension), + )) + ) { + throw new ForbiddenException('This query requires events:read') + } + } + if ( + policy.source === 'create' && + request.body?.organisationId && + !key.scopes.includes('organisations:write') + ) + throw new ForbiddenException( + 'Creating an organisation project requires organisations:write', + ) + if (policy.source === 'account' || policy.source === 'create') { + if (!key.allProjects) + throw new ForbiddenException( + 'This operation requires access to all projects', + ) + return true + } + const owned = await this.keys.store.ownedProjects(request.user.id) + const allowed = owned + .filter( + (project) => key.allProjects || key.projectIds.includes(project.id), + ) + .map((project) => project.id) + if (policy.source === 'list') { + request.apiKeyProjectIds = allowed + return true + } + let ids: unknown[] + if (['goal', 'flag', 'export'].includes(policy.source)) { + ids = [ + await this.keys.store.resourceProject( + policy.source, + request.params[policy.field], + ), + ] + } else { + const input = request[policy.source] || {} + const value = input[policy.field] + ids = value === undefined ? [] : [value] + if ( + policy.source === 'query' && + policy.field === 'pid' && + input.pids !== undefined + ) { + let pids = input.pids + if (typeof pids === 'string') { + try { + pids = JSON.parse(pids) + } catch { + throw new ForbiddenException('Invalid project IDs') + } + } + if (!Array.isArray(pids)) + throw new ForbiddenException('Invalid project IDs') + ids.push(...pids) + } + } + if ( + !ids.length || + ids.some((id) => typeof id !== 'string' || !allowed.includes(id)) + ) { + throw new ForbiddenException( + 'This API key does not have access to this project', + ) + } + return true + } +} diff --git a/backend/apps/cloud/src/api-key/api-key.module.ts b/backend/apps/cloud/src/api-key/api-key.module.ts new file mode 100644 index 000000000..b0b3f0ad2 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.module.ts @@ -0,0 +1,15 @@ +import { Global, Module, forwardRef } from '@nestjs/common' +import { UserModule } from '../user/user.module' +import { ApiKeyService } from './api-key.service' +import { ApiKeyStore } from './api-key.store' +import { ApiKeyController } from './api-key.controller' +import { ApiKeyGuard } from './api-key.guard' + +@Global() +@Module({ + imports: [forwardRef(() => UserModule)], + providers: [ApiKeyService, ApiKeyStore, ApiKeyGuard], + controllers: [ApiKeyController], + exports: [ApiKeyService, ApiKeyGuard], +}) +export class ApiKeyModule {} diff --git a/backend/apps/cloud/src/api-key/api-key.service.ts b/backend/apps/cloud/src/api-key/api-key.service.ts new file mode 100644 index 000000000..c04aeceed --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.service.ts @@ -0,0 +1,267 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common' +import { randomUUID } from 'crypto' +import { ApiKeyStore } from './api-key.store' +import { ApiKeyRecord, API_KEY_SCOPES } from './api-key.types' +import { SaveApiKeyDto } from './api-key.dto' +import { + decryptApiKey, + encryptApiKey, + generateApiKey, + hashApiKey, +} from './api-key.crypto' + +@Injectable() +export class ApiKeyService { + constructor(readonly store: ApiKeyStore) {} + + async authenticate(secret: string) { + if (typeof secret !== 'string' || secret.length > 100) + throw new UnauthorizedException() + const record = await this.store.findByHash(hashApiKey(secret)) + const user = record + ? await this.store.userById(record.userId) + : await this.store.legacyUser(secret) + if (!user) throw new UnauthorizedException() + return { + ...user, + apiKey: undefined, + password: undefined, + twoFactorAuthenticationSecret: undefined, + twoFactorRecoveryCode: undefined, + apiKeyAccess: record + ? { + id: record.id, + scopes: record.scopes, + projectIds: record.projectIds, + allProjects: record.allProjects, + unrestricted: record.unrestricted, + } + : { + id: 'legacy', + scopes: [], + projectIds: [], + allProjects: true, + unrestricted: true, + }, + } + } + + async list(userId: string, projectId?: string) { + const projects = await this.store.ownedProjects(userId) + if (projectId && !projects.some((project) => project.id === projectId)) + throw new ForbiddenException() + if (projectId) { + const users = await this.store.projectUsers(projectId) + const keys = ( + await Promise.all( + users.map(async (owner) => { + const records = await this.store.list(owner.id) + if (owner.apiKey) + records.unshift(this.legacyRecord(owner.id, owner.apiKey)) + return records + .filter( + (key) => + key.unrestricted || + (owner.id === userId && + (key.allProjects || key.projectIds.includes(projectId))), + ) + .map((key) => ({ + ...this.summary(key), + id: `${owner.id}:${key.id}`, + owner: owner.email, + projectReadOnly: owner.id !== userId && !owner.canWrite, + })) + }), + ) + ).flat() + return { + keys, + projects: projects.filter((project) => project.id === projectId), + scopes: API_KEY_SCOPES, + } + } + const records = await this.store.list(userId) + const user = await this.store.userById(userId) + if (user?.apiKey) records.unshift(this.legacyRecord(userId, user.apiKey)) + return { + keys: records + .filter( + (key) => + !projectId || key.allProjects || key.projectIds.includes(projectId), + ) + .map((key) => this.summary(key)), + projects, + scopes: API_KEY_SCOPES, + } + } + + private legacyRecord(userId: string, secret: string): ApiKeyRecord { + return { + id: 'legacy', + userId, + name: 'API key', + keyHash: '', + encryptedKey: '', + keyPreview: `••••${secret.slice(-4)}`, + scopes: [], + projectIds: [], + allProjects: true, + unrestricted: true, + created: null, + rotated: null, + } + } + + private summary(key: ApiKeyRecord) { + const { + keyHash: _keyHash, + encryptedKey: _encryptedKey, + userId: _userId, + ...summary + } = key + return summary + } + + async get(userId: string, id: string) { + if (id === 'legacy') { + const user = await this.store.userById(userId) + if (user?.apiKey) return this.legacyRecord(userId, user.apiKey) + } else { + const key = await this.store.get(userId, id) + if (key) return key + } + throw new NotFoundException('API key not found') + } + + async reveal(userId: string, id: string) { + const key = await this.get(userId, id) + if (id === 'legacy') + return { secret: (await this.store.userById(userId)).apiKey } + try { + return { secret: decryptApiKey(key.encryptedKey, userId, id) } + } catch { + throw new ConflictException( + 'Unable to decrypt this API key. Please rotate the key.', + ) + } + } + + private async validate(userId: string, dto: SaveApiKeyDto) { + if (!dto.name.trim()) + throw new BadRequestException('Enter a name for this key') + if (!dto.scopes.length) + throw new BadRequestException('Select at least one permission') + if (!dto.allProjects && !dto.projectIds.length) + throw new BadRequestException('Select at least one project') + if ( + !dto.allProjects && + dto.scopes.some((scope) => scope.startsWith('organisations:')) + ) + throw new BadRequestException( + 'Organisation permissions require all projects', + ) + dto.scopes = [ + ...new Set( + dto.scopes.flatMap((scope) => + scope.endsWith(':write') + ? [scope, scope.replace(':write', ':read') as typeof scope] + : [scope], + ), + ), + ] + const owned = await this.store.ownedProjects(userId) + if ( + dto.projectIds.some((id) => !owned.some((project) => project.id === id)) + ) + throw new ForbiddenException( + 'You can only grant access to projects you own', + ) + } + + private withSecret(record: ApiKeyRecord, secret: string): ApiKeyRecord { + return { + ...record, + keyHash: hashApiKey(secret), + encryptedKey: encryptApiKey(secret, record.userId, record.id), + keyPreview: `${secret.startsWith('swx_') ? 'swx_' : ''}••••${secret.slice(-4)}`, + } + } + + async create(userId: string, dto: SaveApiKeyDto) { + await this.validate(userId, dto) + if ((await this.store.list(userId)).length >= 50) + throw new BadRequestException('You can create up to 50 API keys') + const secret = generateApiKey() + const record = this.withSecret( + { + ...dto, + name: dto.name.trim(), + projectIds: dto.allProjects ? [] : dto.projectIds, + id: randomUUID(), + userId, + unrestricted: false, + created: new Date().toISOString(), + rotated: null, + keyHash: '', + encryptedKey: '', + keyPreview: '', + }, + secret, + ) + await this.store.insert(record) + return { key: this.summary(record), secret } + } + + async update(userId: string, id: string, dto: SaveApiKeyDto) { + await this.validate(userId, dto) + const old = await this.get(userId, id) + const record = { + ...old, + ...dto, + name: dto.name.trim(), + projectIds: dto.allProjects ? [] : dto.projectIds, + unrestricted: false, + } + if (id === 'legacy') { + const { secret } = await this.reveal(userId, id) + const migrated = this.withSecret({ ...record, id: randomUUID() }, secret) + await this.store.replaceLegacy(migrated, secret) + return this.summary(migrated) + } + await this.store.update(record, old.keyHash) + return this.summary(record) + } + + async rotate(userId: string, id: string) { + const old = await this.get(userId, id) + const secret = generateApiKey() + const record = this.withSecret( + { + ...old, + id: id === 'legacy' ? randomUUID() : id, + rotated: new Date().toISOString(), + }, + secret, + ) + if (id === 'legacy') { + const user = await this.store.userById(userId) + await this.store.replaceLegacy(record, user.apiKey) + } else { + await this.store.update(record, old.keyHash) + } + return { key: this.summary(record), secret } + } + + async remove(userId: string, id: string) { + await this.get(userId, id) + if (id === 'legacy') await this.store.clearLegacy(userId) + else await this.store.remove(userId, id) + } +} diff --git a/backend/apps/cloud/src/api-key/api-key.spec.ts b/backend/apps/cloud/src/api-key/api-key.spec.ts new file mode 100644 index 000000000..694ed5b30 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.spec.ts @@ -0,0 +1,492 @@ +import { AuthGuard } from '@nestjs/passport' +import { ApiKeyStrategy } from '../auth/strategies/api-key.strategy' +import 'reflect-metadata' +import { + ConflictException, + ForbiddenException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { validate } from 'class-validator' +import { plainToInstance } from 'class-transformer' +import { createDecipheriv, hkdfSync } from 'crypto' + +jest.mock('./api-key.store', () => ({ ApiKeyStore: class {} })) +jest.mock('../common/constants', () => ({})) +import { ApiKeyService } from './api-key.service' +import { ApiKeyGuard } from './api-key.guard' +import { ApiKeyRecord, ApiKeyPolicy } from './api-key.types' +import { SaveApiKeyDto } from './api-key.dto' +import { decryptApiKey, encryptApiKey, generateApiKey } from './api-key.crypto' + +const projectA = 'aaaaaaaaaaaa' +const projectB = 'bbbbbbbbbbbb' +const userId = 'owner' +const secretBase = process.env.SECRET_KEY_BASE +beforeAll(() => { + process.env.SECRET_KEY_BASE = 'test-secret-only-for-api-key-specs' +}) +afterAll(() => { + if (secretBase === undefined) delete process.env.SECRET_KEY_BASE + else process.env.SECRET_KEY_BASE = secretBase +}) + +function setup() { + const records = new Map() + const users = new Map([ + [ + userId, + { id: userId, email: 'owner@example.com', apiKey: 'old-uuid-key' }, + ], + ]) + const store = { + list: jest.fn(async (uid: string) => + [...records.values()].filter((key) => key.userId === uid), + ), + get: jest.fn(async (uid: string, id: string) => { + const key = records.get(id) + return key?.userId === uid ? key : undefined + }), + insert: jest.fn(async (key: ApiKeyRecord) => { + records.set(key.id, key) + }), + update: jest.fn(async (key: ApiKeyRecord) => { + records.set(key.id, key) + }), + remove: jest.fn(async (_uid: string, id: string) => { + records.delete(id) + }), + findByHash: jest.fn(async (hash: string) => + [...records.values()].find((key) => key.keyHash === hash), + ), + userById: jest.fn(async (id: string) => users.get(id)), + legacyUser: jest.fn(async (secret: string) => + [...users.values()].find((user) => user.apiKey === secret), + ), + clearLegacy: jest.fn(async (id: string) => { + users.get(id).apiKey = null + }), + replaceLegacy: jest.fn(async (key: ApiKeyRecord) => { + users.get(key.userId).apiKey = null + records.set(key.id, key) + }), + ownedProjects: jest.fn(async () => [ + { id: projectA, name: 'A' }, + { id: projectB, name: 'B' }, + ]), + resourceProject: jest.fn(async () => projectB), + projectUsers: jest.fn(async () => [...users.values()]), + } + const service = new ApiKeyService(store as any) + return { service, store, records, users } +} +const input = (): SaveApiKeyDto => ({ + name: 'Reporting', + scopes: ['analytics:read'], + allProjects: false, + projectIds: [projectA], +}) + +function context(policy: ApiKeyPolicy | undefined, key: any, values: any = {}) { + const request = { + user: key ? { id: userId, apiKeyAccess: key } : { id: userId }, + params: {}, + query: {}, + body: {}, + ...values, + } + const ctx = { + getHandler: () => null, + switchToHttp: () => ({ getRequest: () => request }), + } + const reflector = { get: () => policy } as unknown as Reflector + return { request, ctx, reflector } +} +const scoped = () => ({ + id: 'key', + scopes: ['analytics:read'], + projectIds: [projectA], + allProjects: false, + unrestricted: false, +}) + +describe('scoped API keys', () => { + it('generates prefixed independent 256-bit secrets and authenticated ciphertext', () => { + const secret = generateApiKey() + expect(secret).toMatch(/^swx_[a-f0-9]{64}$/) + expect(generateApiKey()).not.toBe(secret) + const cipher = encryptApiKey(secret, userId, 'id') + expect(cipher).not.toContain(secret) + expect(decryptApiKey(cipher, userId, 'id')).toBe(secret) + expect(() => decryptApiKey(cipher, 'other', 'id')).toThrow() + expect(() => decryptApiKey(cipher, userId, 'other')).toThrow() + + const derivedKey = Buffer.from( + hkdfSync('sha256', process.env.SECRET_KEY_BASE, '', 'api-key', 32), + ) + const [iv, tag, encrypted] = cipher + .split('.') + .map((part) => Buffer.from(part, 'base64')) + const decipher = createDecipheriv('aes-256-gcm', derivedKey, iv) + decipher.setAAD(Buffer.from(`${userId}:id`)) + decipher.setAuthTag(tag) + expect( + Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( + 'utf8', + ), + ).toBe(secret) + }) + + it('requires a base secret even in test environments', () => { + const cipher = encryptApiKey('secret', userId, 'id') + const base = process.env.SECRET_KEY_BASE + delete process.env.SECRET_KEY_BASE + try { + expect(() => encryptApiKey('secret', userId, 'id')).toThrow( + 'SECRET_KEY_BASE is required', + ) + expect(() => decryptApiKey(cipher, userId, 'id')).toThrow( + 'SECRET_KEY_BASE is required', + ) + } finally { + process.env.SECRET_KEY_BASE = base + } + }) + + it('lists metadata without secrets and reveals only to the owner', async () => { + const { service } = setup() + const result = await service.create(userId, input()) + expect(JSON.stringify(await service.list(userId))).not.toContain( + result.secret, + ) + expect(JSON.stringify(await service.list(userId))).not.toContain('keyHash') + expect(JSON.stringify(await service.list(userId))).not.toContain( + 'encryptedKey', + ) + expect(await service.reveal(userId, result.key.id)).toEqual({ + secret: result.secret, + }) + await expect(service.reveal('other', result.key.id)).rejects.toThrow() + await expect(service.rotate('other', result.key.id)).rejects.toThrow() + await expect(service.remove('other', result.key.id)).rejects.toThrow() + }) + + it('asks the owner to rotate a key when decryption fails', async () => { + const { service, records } = setup() + const { key } = await service.create(userId, input()) + records.get(key.id).encryptedKey = 'invalid-ciphertext' + + await expect(service.reveal(userId, key.id)).rejects.toThrow( + new ConflictException( + 'Unable to decrypt this API key. Please rotate the key.', + ), + ) + await expect(service.reveal('other', key.id)).rejects.toThrow( + NotFoundException, + ) + + const rotated = await service.rotate(userId, key.id) + await expect(service.reveal(userId, key.id)).resolves.toEqual({ + secret: rotated.secret, + }) + }) + + it('reveals legacy keys without an encryption secret', async () => { + const { service } = setup() + const base = process.env.SECRET_KEY_BASE + delete process.env.SECRET_KEY_BASE + try { + await expect(service.reveal(userId, 'legacy')).resolves.toEqual({ + secret: 'old-uuid-key', + }) + } finally { + process.env.SECRET_KEY_BASE = base + } + }) + + it('rotates and revokes immediately while preserving scope and creation date', async () => { + const { service, records } = setup() + const first = await service.create(userId, input()) + const second = await service.rotate(userId, first.key.id) + expect(second.key.created).toBe(first.key.created) + expect(second.key.scopes).toEqual(first.key.scopes) + expect(second.key.projectIds).toEqual(first.key.projectIds) + await expect(service.authenticate(first.secret)).rejects.toThrow( + UnauthorizedException, + ) + expect((await service.authenticate(second.secret)).apiKeyAccess.id).toBe( + first.key.id, + ) + await service.remove(userId, first.key.id) + expect(records.size).toBe(0) + await expect(service.authenticate(second.secret)).rejects.toThrow( + UnauthorizedException, + ) + }) + + it('preserves old UUID keys, including unrestricted rotation', async () => { + const { service } = setup() + expect( + (await service.authenticate('old-uuid-key')).apiKeyAccess.unrestricted, + ).toBe(true) + const rotated = await service.rotate(userId, 'legacy') + expect(rotated.key.unrestricted).toBe(true) + expect(rotated.key.allProjects).toBe(true) + await expect(service.authenticate('old-uuid-key')).rejects.toThrow() + expect( + (await service.authenticate(rotated.secret)).apiKeyAccess.unrestricted, + ).toBe(true) + }) + + it('can restrict an old key without requiring the caller to change its secret', async () => { + const { service } = setup() + await service.update(userId, 'legacy', input()) + const principal = (await service.authenticate('old-uuid-key')).apiKeyAccess + expect(principal.unrestricted).toBe(false) + expect(principal.projectIds).toEqual([projectA]) + expect(principal.scopes).toEqual(['analytics:read']) + }) + + it('rejects arbitrary projects and empty permission sets', async () => { + const { service } = setup() + await expect( + service.create(userId, { ...input(), projectIds: ['cccccccccccc'] }), + ).rejects.toThrow(ForbiddenException) + await expect( + service.create(userId, { ...input(), scopes: [] }), + ).rejects.toThrow() + await expect( + service.create(userId, { ...input(), projectIds: [] }), + ).rejects.toThrow() + }) + + it('grants read alongside write and rejects unsupported write scopes', async () => { + const { service } = setup() + const { key } = await service.create(userId, { + ...input(), + scopes: ['projects:write'], + }) + expect(key.scopes).toEqual( + expect.arrayContaining(['projects:read', 'projects:write']), + ) + expect( + await validate( + plainToInstance(SaveApiKeyDto, { + ...input(), + scopes: ['analytics:write'], + }), + ), + ).not.toHaveLength(0) + expect( + await validate( + plainToInstance(SaveApiKeyDto, { ...input(), allProjects: 'true' }), + ), + ).not.toHaveLength(0) + expect( + await validate( + plainToInstance(SaveApiKeyDto, { + ...input(), + projectIds: ['abc-def12345'], + }), + ), + ).toHaveLength(0) + }) + + it('shows inherited legacy access in project settings without exposing another owner secret', async () => { + const { service, users } = setup() + users.set('teammate', { + id: 'teammate', + email: 'team@example.com', + apiKey: 'another-secret', + }) + const list = await service.list(userId, projectA) + expect(list.keys).toHaveLength(2) + expect(JSON.stringify(list)).not.toContain('another-secret') + expect(list.keys[1]).toMatchObject({ + owner: 'team@example.com', + unrestricted: true, + }) + await expect(service.list(userId, 'cccccccccccc')).rejects.toThrow( + ForbiddenException, + ) + }) +}) + +describe('API key authorization', () => { + async function run( + policy: ApiKeyPolicy | undefined, + key = scoped(), + values = {}, + ) { + const { service, store } = setup() + const { ctx, reflector, request } = context(policy, key, values) + const result = await new ApiKeyGuard(reflector, service).canActivate( + ctx as any, + ) + return { result, request, store } + } + const read: ApiKeyPolicy = { + scope: 'analytics:read', + source: 'query', + field: 'pid', + } + + it('allows only explicitly scoped projects, including for public routes', async () => { + expect( + (await run(read, scoped(), { query: { pid: projectA } })).result, + ).toBe(true) + await expect( + run(read, scoped(), { query: { pid: projectB } }), + ).rejects.toThrow(ForbiddenException) + await expect(run(read, scoped(), { query: {} })).rejects.toThrow( + ForbiddenException, + ) + await expect( + run(read, scoped(), { query: { pid: [projectA] } }), + ).rejects.toThrow(ForbiddenException) + }) + it('denies unannotated endpoints and writes with a read-only key', async () => { + await expect(run(undefined)).rejects.toThrow(ForbiddenException) + await expect(run({ ...read, scope: 'projects:write' })).rejects.toThrow( + ForbiddenException, + ) + }) + it('validates every project in JSON and array batch queries', async () => { + expect( + ( + await run(read, scoped(), { + query: { pids: JSON.stringify([projectA]) }, + }) + ).result, + ).toBe(true) + await expect( + run(read, scoped(), { + query: { pids: JSON.stringify([projectA, projectB]) }, + }), + ).rejects.toThrow() + await expect( + run(read, scoped(), { query: { pids: [projectA, projectB] } }), + ).rejects.toThrow() + await expect( + run(read, scoped(), { query: { pids: '{}' } }), + ).rejects.toThrow() + }) + it('resolves resource IDs from storage and ignores spoofed project query parameters', async () => { + for (const source of ['goal', 'flag', 'export'] as const) { + await expect( + run({ ...read, source, field: 'id' }, scoped(), { + params: { id: 'resource-on-B' }, + query: { pid: projectA }, + }), + ).rejects.toThrow(ForbiddenException) + } + }) + it('limits project lists before the controller runs', async () => { + const { request } = await run({ ...read, source: 'list' }) + expect((request as any).apiKeyProjectIds).toEqual([projectA]) + }) + it('requires all-project access for account-wide operations', async () => { + await expect(run({ ...read, source: 'account' })).rejects.toThrow() + expect( + ( + await run( + { ...read, source: 'account' }, + { ...scoped(), allProjects: true }, + ) + ).result, + ).toBe(true) + }) + it('never grants new scoped keys access to a project the owner no longer owns', async () => { + const { service, store } = setup() + store.ownedProjects.mockResolvedValue([]) + const { reflector, ctx } = context( + read, + { ...scoped(), allProjects: true }, + { query: { pid: projectA } }, + ) + await expect( + new ApiKeyGuard(reflector, service).canActivate(ctx as any), + ).rejects.toThrow() + }) + it('does not apply API-key restrictions to sessions or existing unrestricted keys', async () => { + expect((await run(undefined, null)).result).toBe(true) + expect( + (await run(undefined, { ...scoped(), unrestricted: true })).result, + ).toBe(true) + }) + it('requires error permission for polymorphic dimension queries', async () => { + await expect( + run(read, scoped(), { query: { pid: projectA, type: 'errors' } }), + ).rejects.toThrow() + }) + it('requires every permission for combined responses', async () => { + const combined = { + ...read, + requires: ['events:read', 'errors:read'] as const, + } + await expect( + run(combined as any, scoped(), { query: { pid: projectA } }), + ).rejects.toThrow() + expect( + ( + await run( + combined as any, + { + ...scoped(), + scopes: ['analytics:read', 'events:read', 'errors:read'], + }, + { query: { pid: projectA } }, + ) + ).result, + ).toBe(true) + }) + it('cannot use custom event filters or discovery to bypass event permissions', async () => { + for (const query of [ + { pid: projectA, type: 'ev' }, + { + pid: projectA, + filters: JSON.stringify([{ column: 'ev', filter: 'signup' }]), + }, + { + pid: projectA, + filters: JSON.stringify([ + { dimension: 'event_metadata', key: 'email', value: 'test' }, + ]), + }, + ]) + await expect(run(read, scoped(), { query })).rejects.toThrow() + }) +}) + +describe('Passport API key integration', () => { + it('authenticates prefixed and existing secrets through the real strategy', async () => { + const { service } = setup() + new ApiKeyStrategy(service) + const { secret } = await service.create(userId, input()) + const Guard = AuthGuard('api-key') + for (const token of [secret, 'old-uuid-key']) { + const request: any = { headers: { 'x-api-key': token } } + const ctx: any = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => ({}), + }), + } + expect(await new Guard().canActivate(ctx)).toBe(true) + expect(request.user.id).toBe(userId) + expect(request.user.apiKeyAccess.unrestricted).toBe( + token === 'old-uuid-key', + ) + } + const ctx: any = { + switchToHttp: () => ({ + getRequest: () => ({ headers: { 'x-api-key': 'invalid' } }), + getResponse: () => ({}), + }), + } + await expect(new Guard().canActivate(ctx)).rejects.toThrow( + UnauthorizedException, + ) + }) +}) diff --git a/backend/apps/cloud/src/api-key/api-key.store.ts b/backend/apps/cloud/src/api-key/api-key.store.ts new file mode 100644 index 000000000..fa15c9558 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.store.ts @@ -0,0 +1,148 @@ +import { ConflictException, Injectable } from '@nestjs/common' +import { DataSource } from 'typeorm' +import { User } from '../user/entities/user.entity' +import { redis } from '../common/constants' +import { ApiKeyRecord } from './api-key.types' + +const decode = (row: any): ApiKeyRecord => + row && { + ...row, + scopes: + typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes, + projectIds: + typeof row.projectIds === 'string' + ? JSON.parse(row.projectIds) + : row.projectIds, + allProjects: Boolean(row.allProjects), + unrestricted: Boolean(row.unrestricted), + } +const values = (key: ApiKeyRecord) => [ + key.name, + key.keyHash, + key.encryptedKey, + key.keyPreview, + JSON.stringify(key.scopes), + JSON.stringify(key.projectIds), + key.allProjects, + key.unrestricted, + key.created, + key.rotated, +] + +@Injectable() +export class ApiKeyStore { + constructor(private readonly db: DataSource) {} + async list(userId: string) { + return ( + await this.db.query( + 'SELECT * FROM api_key WHERE userId = ? ORDER BY created DESC', + [userId], + ) + ).map(decode) + } + async get(userId: string, id: string) { + return decode( + ( + await this.db.query( + 'SELECT * FROM api_key WHERE userId = ? AND id = ?', + [userId, id], + ) + )[0], + ) + } + async findByHash(hash: string) { + return decode( + ( + await this.db.query('SELECT * FROM api_key WHERE keyHash = ?', [hash]) + )[0], + ) + } + async insert(key: ApiKeyRecord) { + await this.db.query( + 'INSERT INTO api_key (name, keyHash, encryptedKey, keyPreview, scopes, projectIds, allProjects, unrestricted, created, rotated, id, userId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [...values(key), key.id, key.userId], + ) + } + async update(key: ApiKeyRecord, expectedHash: string) { + const result = await this.db.query( + 'UPDATE api_key SET name = ?, keyHash = ?, encryptedKey = ?, keyPreview = ?, scopes = ?, projectIds = ?, allProjects = ?, unrestricted = ?, created = ?, rotated = ? WHERE id = ? AND userId = ? AND keyHash = ?', + [...values(key), key.id, key.userId, expectedHash], + ) + if (!result.affectedRows) + throw new ConflictException( + 'This key has changed. Refresh and try again.', + ) + } + async remove(userId: string, id: string) { + await this.db.query('DELETE FROM api_key WHERE userId = ? AND id = ?', [ + userId, + id, + ]) + } + userById(id: string) { + return this.db + .getRepository(User) + .createQueryBuilder('user') + .addSelect('user.apiKey') + .where('user.id = :id', { id }) + .getOne() + } + legacyUser(apiKey: string) { + return this.db.getRepository(User).findOne({ where: { apiKey } }) + } + async clearLegacy(userId: string) { + await this.db.query('UPDATE user SET apiKey = NULL WHERE id = ?', [userId]) + } + async replaceLegacy(key: ApiKeyRecord, previous: string) { + await this.db.transaction(async (manager) => { + const result = await manager.query( + 'UPDATE user SET apiKey = NULL WHERE id = ? AND apiKey = ?', + [key.userId, previous], + ) + if (!result.affectedRows) + throw new ConflictException( + 'This key has changed. Refresh and try again.', + ) + await manager.query( + 'INSERT INTO api_key (name, keyHash, encryptedKey, keyPreview, scopes, projectIds, allProjects, unrestricted, created, rotated, id, userId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [...values(key), key.id, key.userId], + ) + }) + } + async ownedProjects(userId: string): Promise<{ id: string; name: string }[]> { + return this.db.query( + 'SELECT id, name FROM project WHERE adminId = ? ORDER BY name', + [userId], + ) + } + async projectUsers( + projectId: string, + ): Promise< + { id: string; email: string; apiKey: string | null; canWrite?: number }[] + > { + return this.db.query( + `SELECT DISTINCT u.id, u.email, u.apiKey, + (u.id = p.adminId + OR EXISTS (SELECT 1 FROM project_share ps WHERE ps.projectId = p.id AND ps.userId = u.id AND ps.confirmed = true AND ps.role = 'admin') + OR EXISTS (SELECT 1 FROM organisation_member om WHERE om.organisationId = p.organisationId AND om.userId = u.id AND om.confirmed = true AND om.role IN ('owner', 'admin'))) AS canWrite + FROM user u + JOIN project p ON p.id = ? + WHERE u.id = p.adminId + OR EXISTS (SELECT 1 FROM project_share ps WHERE ps.projectId = p.id AND ps.userId = u.id AND ps.confirmed = true) + OR EXISTS (SELECT 1 FROM organisation_member om WHERE om.organisationId = p.organisationId AND om.userId = u.id AND om.confirmed = true)`, + [projectId], + ) + } + async resourceProject(kind: string, id: string): Promise { + if (kind === 'export') { + const raw = await redis.get(`session-replay:export:${id}`) + return raw ? JSON.parse(raw).pid : undefined + } + const table = + kind === 'goal' ? 'goal' : kind === 'flag' ? 'feature_flag' : null + if (!table) return undefined + return ( + await this.db.query(`SELECT projectId FROM ${table} WHERE id = ?`, [id]) + )[0]?.projectId + } +} diff --git a/backend/apps/cloud/src/api-key/api-key.types.ts b/backend/apps/cloud/src/api-key/api-key.types.ts new file mode 100644 index 000000000..1d86f67c4 --- /dev/null +++ b/backend/apps/cloud/src/api-key/api-key.types.ts @@ -0,0 +1,49 @@ +export const API_KEY_SCOPES = { + analytics: ['read'], + events: ['read', 'write'], + projects: ['read', 'write'], + funnels: ['read', 'write'], + annotations: ['read', 'write'], + errors: ['read', 'write'], + replays: ['read', 'write'], + goals: ['read'], + flags: ['read'], + revenue: ['read', 'write'], + organisations: ['read', 'write'], +} as const + +export type ApiKeyScope = `${keyof typeof API_KEY_SCOPES}:${'read' | 'write'}` +export interface ApiKeyRecord { + id: string + userId: string + name: string + keyHash: string + encryptedKey: string + keyPreview: string + scopes: ApiKeyScope[] + projectIds: string[] + allProjects: boolean + unrestricted: boolean + created: string | null + rotated: string | null +} +export type ApiKeyPrincipal = Pick< + ApiKeyRecord, + 'id' | 'scopes' | 'projectIds' | 'allProjects' | 'unrestricted' +> +export const API_KEY_ACCESS = 'apiKeyAccess' +export interface ApiKeyPolicy { + scope: ApiKeyScope + requires?: ApiKeyScope[] + source: + | 'params' + | 'query' + | 'body' + | 'list' + | 'account' + | 'create' + | 'goal' + | 'flag' + | 'export' + field?: string +} diff --git a/backend/apps/cloud/src/app.module.ts b/backend/apps/cloud/src/app.module.ts index 23616e218..40bb22c96 100644 --- a/backend/apps/cloud/src/app.module.ts +++ b/backend/apps/cloud/src/app.module.ts @@ -1,3 +1,4 @@ +import { ApiKeyModule } from './api-key/api-key.module' import { Module } from '@nestjs/common' import { TypeOrmModule } from '@nestjs/typeorm' import { ConfigModule } from '@nestjs/config' @@ -96,6 +97,7 @@ const modules = [ FeatureFlagModule, AiModule, AuthModule, + ApiKeyModule, CaptchaModule, HealthModule, OrganisationModule, diff --git a/backend/apps/cloud/src/auth/decorators/auth.decorator.ts b/backend/apps/cloud/src/auth/decorators/auth.decorator.ts index 07d43fa8f..fecc17aa3 100644 --- a/backend/apps/cloud/src/auth/decorators/auth.decorator.ts +++ b/backend/apps/cloud/src/auth/decorators/auth.decorator.ts @@ -1,3 +1,4 @@ +import { ApiKeyGuard } from '../../api-key/api-key.guard' import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common' import { ApiKeyRateLimitGuard, @@ -11,7 +12,12 @@ export function Auth(allowApiKeyAuth = false, isOptionalAuth = false) { return applyDecorators( SetMetadata(IS_OPTIONAL_AUTH_KEY, isOptionalAuth), allowApiKeyAuth - ? UseGuards(MultiAuthGuard, ApiKeyRateLimitGuard, AuthenticationGuard) + ? UseGuards( + MultiAuthGuard, + ApiKeyGuard, + ApiKeyRateLimitGuard, + AuthenticationGuard, + ) : UseGuards(JwtAccessTokenGuard, AuthenticationGuard), ) } diff --git a/backend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.ts b/backend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.ts index 6c93aedfb..261cb258d 100644 --- a/backend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.ts +++ b/backend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.ts @@ -11,7 +11,7 @@ export class ApiKeyRateLimitGuard implements CanActivate { const request = context.switchToHttp().getRequest() const { user } = request - if (request.headers['x-api-key']) { + if (user?.apiKeyAccess) { if (!user) return false const { apiRateLimitPerHour } = getEffectiveAccountLimits(user) @@ -21,7 +21,7 @@ export class ApiKeyRateLimitGuard implements CanActivate { user.dashboardBlockReason !== null ? 0 : apiRateLimitPerHour - return checkRateLimitForApiKey(user.apiKey, reqAmount) + return checkRateLimitForApiKey(`account:${user.id}`, reqAmount) } return true diff --git a/backend/apps/cloud/src/auth/guards/authentication.guard.ts b/backend/apps/cloud/src/auth/guards/authentication.guard.ts index 98678c22b..161c0b23e 100644 --- a/backend/apps/cloud/src/auth/guards/authentication.guard.ts +++ b/backend/apps/cloud/src/auth/guards/authentication.guard.ts @@ -21,7 +21,7 @@ export class AuthenticationGuard implements CanActivate { // If user is missing here, upstream guards (e.g., JwtAccessTokenGuard) already rejected the request, // or this route allows API key auth via MultiAuthGuard. When using API key, skip 2FA enforcement. - if (!user) return true + if (!user || user.apiKeyAccess) return true if ( user?.isTwoFactorAuthenticationEnabled && diff --git a/backend/apps/cloud/src/auth/guards/multi-auth.guard.spec.ts b/backend/apps/cloud/src/auth/guards/multi-auth.guard.spec.ts index 0e2bac760..35c4376b9 100644 --- a/backend/apps/cloud/src/auth/guards/multi-auth.guard.spec.ts +++ b/backend/apps/cloud/src/auth/guards/multi-auth.guard.spec.ts @@ -1,3 +1,4 @@ +import passport from 'passport' import { ExecutionContext, UnauthorizedException } from '@nestjs/common' import { Reflector } from '@nestjs/core' @@ -64,3 +65,32 @@ describe('MultiAuthGuard', () => { ).toBe(user) }) }) + +describe('API key precedence', () => { + it('rejects an invalid key even when the session strategy would succeed', async () => { + passport.use('api-key', { + authenticate() { + this.fail() + }, + } as any) + passport.use('jwt-access-token', { + authenticate() { + this.success({ id: 'session-user' }) + }, + } as any) + const request = { + headers: { 'x-api-key': 'invalid' }, + cookies: { token: 'valid-session' }, + } + const context = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => ({}), + }), + } as ExecutionContext + const guard = new MultiAuthGuard({ getAllAndOverride: () => false } as any) + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ) + }) +}) diff --git a/backend/apps/cloud/src/auth/guards/multi-auth.guard.ts b/backend/apps/cloud/src/auth/guards/multi-auth.guard.ts index 70de2cf8f..9cebe78cb 100644 --- a/backend/apps/cloud/src/auth/guards/multi-auth.guard.ts +++ b/backend/apps/cloud/src/auth/guards/multi-auth.guard.ts @@ -9,6 +9,14 @@ export class MultiAuthGuard extends AuthGuard(['jwt-access-token', 'api-key']) { super() } + canActivate(context: ExecutionContext) { + if (context.switchToHttp().getRequest().headers?.['x-api-key']) { + const KeyGuard = AuthGuard('api-key') + return new KeyGuard().canActivate(context) + } + return super.canActivate(context) + } + handleRequest( err: any, user: any, diff --git a/backend/apps/cloud/src/auth/strategies/api-key.strategy.ts b/backend/apps/cloud/src/auth/strategies/api-key.strategy.ts index 166a6df41..d78e34f56 100644 --- a/backend/apps/cloud/src/auth/strategies/api-key.strategy.ts +++ b/backend/apps/cloud/src/auth/strategies/api-key.strategy.ts @@ -1,30 +1,18 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common' +import { Injectable } from '@nestjs/common' import { PassportStrategy } from '@nestjs/passport' import { HeaderAPIKeyStrategy } from 'passport-headerapikey' -import { AuthService } from '../auth.service' +import { ApiKeyService } from '../../api-key/api-key.service' @Injectable() export class ApiKeyStrategy extends PassportStrategy( HeaderAPIKeyStrategy, 'api-key', ) { - constructor(private readonly authService: AuthService) { - super( - { header: 'X-Api-Key', prefix: '' }, - true, - // TODO: What the fuck is this? As of Nest V 11 - this code should not work. - // Test is later on. https://github.com/nestjs/passport/pull/1439 - // @ts-expect-error - async (apiKey: string, done: (err: any, user: any) => void) => { - return this.validate(apiKey, done) - }, - ) + constructor(private readonly keys: ApiKeyService) { + super({ header: 'X-Api-Key', prefix: '' }, false) } - async validate(apiKey: string, done: (err: any, user: any) => void) { - const user = await this.authService.findUserByApiKey(apiKey) - if (!user) return done(new UnauthorizedException(), false) - - return done(null, user) + validate(apiKey: string) { + return this.keys.authenticate(apiKey) } } diff --git a/backend/apps/cloud/src/common/utils.ts b/backend/apps/cloud/src/common/utils.ts index fd199ab84..aab20d3ba 100644 --- a/backend/apps/cloud/src/common/utils.ts +++ b/backend/apps/cloud/src/common/utils.ts @@ -37,13 +37,14 @@ export const deriveKey = ( | 'access-token' | 'gsc-token' | 'ga4-token' - | 'revenue', + | 'revenue' + | 'api-key', length = 32, ) => { const baseStr = process.env.SECRET_KEY_BASE - // Security footgun: if SECRET_KEY_BASE is missing, token secrets become - // predictable across deployments. Fail closed outside dev/test. + // If SECRET_KEY_BASE is missing, token secrets become predictable across deployments. + // Fail closed outside dev/test. if (!baseStr) { const env = process.env.NODE_ENV if (env === 'development' || env === 'test') { diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index c08fb0972..2890e653a 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import * as fs from 'fs' import * as os from 'os' import * as path from 'path' @@ -290,6 +291,7 @@ export class DataImportController { @Get(':projectId/has-imported-data') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'projectId') @ApiBearerAuth() async hasImportedData( @Param('projectId') projectId: string, diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts index e59875ba6..e5bcd3bd5 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import { Controller, Get, @@ -261,6 +262,7 @@ export class FeatureFlagController { @ApiBearerAuth() @Get('/project/:projectId') @Auth(true, true) + @ApiKeyAccess('flags:read', 'params', 'projectId') @ApiResponse({ status: 200, type: [FeatureFlagDto] }) @ApiOperation({ summary: 'Get all feature flags for a project' }) async getProjectFeatureFlags( @@ -313,6 +315,7 @@ export class FeatureFlagController { @ApiBearerAuth() @Get('/:flagId') @Auth(true, true) + @ApiKeyAccess('flags:read', 'flag', 'flagId') @ApiResponse({ status: 200, type: FeatureFlagDto }) @ApiOperation({ summary: 'Get a feature flag by ID' }) async getFeatureFlag( @@ -939,6 +942,7 @@ export class FeatureFlagController { @ApiBearerAuth() @Get('/:id/stats') @Auth(true, true) + @ApiKeyAccess('flags:read', 'flag', 'id') @ApiResponse({ status: 200, type: FeatureFlagStatsDto }) @ApiOperation({ summary: 'Get statistics for a feature flag' }) async getFeatureFlagStats( @@ -1045,6 +1049,7 @@ export class FeatureFlagController { @ApiBearerAuth() @Get('/:id/profiles') @Auth(true, true) + @ApiKeyAccess('flags:read', 'flag', 'id') @ApiResponse({ status: 200, type: FeatureFlagProfilesResponseDto }) @ApiOperation({ summary: 'Get profiles who have evaluated a feature flag', diff --git a/backend/apps/cloud/src/goal/goal.controller.ts b/backend/apps/cloud/src/goal/goal.controller.ts index b6683d209..3db0818e6 100644 --- a/backend/apps/cloud/src/goal/goal.controller.ts +++ b/backend/apps/cloud/src/goal/goal.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import { Controller, Get, @@ -141,6 +142,7 @@ export class GoalController { @ApiBearerAuth() @Get('/:goalId') @Auth(true, true) + @ApiKeyAccess('goals:read', 'goal', 'goalId') @ApiResponse({ status: 200, type: Goal }) async getGoal( @CurrentUserId() userId: string, @@ -171,6 +173,7 @@ export class GoalController { @ApiBearerAuth() @Get('/project/:projectId') @Auth(true, true) + @ApiKeyAccess('goals:read', 'params', 'projectId') @ApiResponse({ status: 200, type: [GoalDto] }) async getProjectGoals( @CurrentUserId() userId: string, @@ -849,6 +852,7 @@ export class GoalController { @ApiBearerAuth() @Get('/:id/stats') @Auth(true, true) + @ApiKeyAccess('goals:read', 'goal', 'id') @ApiResponse({ status: 200, type: GoalStatsDto }) async getGoalStats( @CurrentUserId() userId: string, @@ -1003,6 +1007,13 @@ export class GoalController { @ApiBearerAuth() @Get('/:id/sessions') @Auth(true, true) + @ApiKeyAccess('goals:read', 'goal', 'id', [ + 'analytics:read', + 'events:read', + 'errors:read', + 'replays:read', + 'revenue:read', + ]) async getGoalSessions( @CurrentUserId() userId: string, @Param('id') id: string, @@ -1145,6 +1156,7 @@ export class GoalController { @ApiBearerAuth() @Get('/:id/chart') @Auth(true, true) + @ApiKeyAccess('goals:read', 'goal', 'id') async getGoalChart( @CurrentUserId() userId: string, @Param('id') id: string, diff --git a/backend/apps/cloud/src/organisation/organisation.controller.ts b/backend/apps/cloud/src/organisation/organisation.controller.ts index 253ea07a2..b21ed65af 100644 --- a/backend/apps/cloud/src/organisation/organisation.controller.ts +++ b/backend/apps/cloud/src/organisation/organisation.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import { Controller, Post, @@ -65,6 +66,7 @@ export class OrganisationController { @ApiQuery({ name: 'search', required: false, type: String }) @ApiResponse({ status: 200, type: [Organisation] }) @Auth(true) + @ApiKeyAccess('organisations:read', 'account', 'pid') async get( @CurrentUserId() userId: string, @Query('take', new ParseIntPipe({ optional: true })) take?: number, @@ -80,6 +82,7 @@ export class OrganisationController { @Get('/:orgId') @ApiResponse({ status: 200, type: Organisation }) @Auth(true) + @ApiKeyAccess('organisations:read', 'account', 'pid') async getOne( @Param('orgId') orgId: string, @CurrentUserId() userId: string, @@ -125,9 +128,9 @@ export class OrganisationController { @ApiBearerAuth() @Post('/') - @Auth() @ApiResponse({ status: 200, type: Organisation }) @Auth(true) + @ApiKeyAccess('organisations:write', 'account', 'pid') async create( @Body() createOrgDTO: CreateOrganisationDTO, @CurrentUserId() uid: string, @@ -161,8 +164,8 @@ export class OrganisationController { @ApiBearerAuth() @Post('/:orgId/invite') @HttpCode(200) - @Auth() @Auth(true) + @ApiKeyAccess('organisations:write', 'account', 'pid') async inviteMember( @Param('orgId') orgId: string, @Body() inviteDTO: InviteMemberDTO, @@ -241,10 +244,7 @@ export class OrganisationController { }, ) - return await this.organisationService.findOne({ - where: { id: orgId }, - relations: ['members', 'members.user'], - }) + return this.getOne(orgId, userId) } catch (reason) { this.logger.error( { orgId: organisation?.id, email: inviteDTO.email, reason }, @@ -299,10 +299,7 @@ export class OrganisationController { }, ) - return await this.organisationService.findOne({ - where: { id: orgId }, - relations: ['members', 'members.user'], - }) + return this.getOne(orgId, userId) } catch (reason) { this.logger.error( { orgId: organisation?.id, inviteeId: invitee?.id, reason }, @@ -315,8 +312,8 @@ export class OrganisationController { @ApiBearerAuth() @Patch('/member/:memberId') @HttpCode(200) - @Auth() @Auth(true) + @ApiKeyAccess('organisations:write', 'account', 'pid') async updateMemberRole( @Param('memberId') memberId: string, @Body() updateDTO: UpdateMemberRoleDTO, @@ -364,8 +361,8 @@ export class OrganisationController { @ApiBearerAuth() @Delete('/member/:memberId') @HttpCode(204) - @Auth() @Auth(true) + @ApiKeyAccess('organisations:write', 'account', 'pid') async removeMember( @Param('memberId') memberId: string, @CurrentUserId() uid: string, @@ -401,6 +398,7 @@ export class OrganisationController { @Delete('/:orgId') @ApiResponse({ status: 200, type: Organisation }) @Auth(true) + @ApiKeyAccess('organisations:write', 'account', 'pid') async delete( @Param('orgId') orgId: string, @CurrentUserId() userId: string, @@ -444,6 +442,7 @@ export class OrganisationController { @Patch('/:orgId') @ApiResponse({ status: 200, type: Organisation }) @Auth(true) + @ApiKeyAccess('organisations:write', 'account', 'pid') async update( @Param('orgId') orgId: string, @Body() updateOrgDTO: UpdateOrganisationDTO, diff --git a/backend/apps/cloud/src/project/project.controller.ts b/backend/apps/cloud/src/project/project.controller.ts index cdf6ae2b1..acba46b46 100644 --- a/backend/apps/cloud/src/project/project.controller.ts +++ b/backend/apps/cloud/src/project/project.controller.ts @@ -1,3 +1,5 @@ +import { Req } from '@nestjs/common' +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import { Controller, Body, @@ -162,7 +164,9 @@ export class ProjectController { }) @ApiResponse({ status: 200, type: [Project] }) @Auth(true) + @ApiKeyAccess('projects:read', 'list', 'pid', ['funnels:read']) async get( + @Req() request: { apiKeyProjectIds?: string[] }, @CurrentUserId() userId: string, @Query('take', new ParseIntPipe({ optional: true })) take?: number, @Query('skip', new ParseIntPipe({ optional: true })) skip?: number, @@ -199,6 +203,7 @@ export class ProjectController { userId, search, sort, + request.apiKeyProjectIds, ) return this.projectService.processDefaultResults(paginated, userId) @@ -211,7 +216,9 @@ export class ProjectController { @ApiQuery({ name: 'search', required: false, type: String }) @ApiResponse({ status: 200, type: [Project] }) @Auth(true) + @ApiKeyAccess('projects:read', 'list', 'pid') async getAvailableProjectsForOrganization( + @Req() request: { apiKeyProjectIds?: string[] }, @CurrentUserId() userId: string, @Query('take', new ParseIntPipe({ optional: true })) take?: number, @Query('skip', new ParseIntPipe({ optional: true })) skip?: number, @@ -226,6 +233,7 @@ export class ProjectController { { take, skip }, userId, search, + request.apiKeyProjectIds, ) return paginated @@ -236,6 +244,7 @@ export class ProjectController { @ApiOperation({ summary: 'Pin a project to the top of the dashboard' }) @ApiResponse({ status: 200, description: 'Project pinned successfully' }) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') async pinProject( @Param('id') projectId: string, @CurrentUserId() userId: string, @@ -270,6 +279,7 @@ export class ProjectController { @ApiOperation({ summary: 'Unpin a project from the top of the dashboard' }) @ApiResponse({ status: 200, description: 'Project unpinned successfully' }) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') async unpinProject( @Param('id') projectId: string, @CurrentUserId() userId: string, @@ -303,6 +313,7 @@ export class ProjectController { @Post('/') @ApiResponse({ status: 201, type: Project }) @Auth(true) + @ApiKeyAccess('projects:write', 'create', 'pid') async create( @Body() projectDTO: CreateProjectDTO, @Headers() headers: Record, @@ -415,6 +426,7 @@ export class ProjectController { @Post('/funnel') @ApiResponse({ status: 201 }) @Auth(true) + @ApiKeyAccess('funnels:write', 'body', 'pid') async createFunnel( @Body() funnelDTO: FunnelCreateDTO, @CurrentUserId() userId: string, @@ -473,6 +485,7 @@ export class ProjectController { @Patch('/funnel') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('funnels:write', 'body', 'pid') async updateFunnel( @Body() funnelDTO: FunnelUpdateDTO, @CurrentUserId() userId: string, @@ -525,6 +538,7 @@ export class ProjectController { @Delete('/funnel/:id/:pid') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('funnels:write', 'params', 'pid') async deleteFunnel( @Param('id') id: string, @Param('pid') pid: string, @@ -567,6 +581,7 @@ export class ProjectController { @Get('/funnels/:pid') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('funnels:read', 'params', 'pid') async getFunnels( @Param('pid') pid: string, @CurrentUserId() userId: string, @@ -592,6 +607,7 @@ export class ProjectController { @Post('/annotation') @ApiResponse({ status: 201 }) @Auth(true) + @ApiKeyAccess('annotations:write', 'body', 'pid') async createAnnotation( @Body() annotationDTO: AnnotationCreateDTO, @CurrentUserId() userId: string, @@ -635,6 +651,7 @@ export class ProjectController { @Patch('/annotation') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('annotations:write', 'body', 'pid') async updateAnnotation( @Body() annotationDTO: AnnotationUpdateDTO, @CurrentUserId() userId: string, @@ -676,6 +693,7 @@ export class ProjectController { @Delete('/annotation/:id/:pid') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('annotations:write', 'params', 'pid') async deleteAnnotation( @Param('id') id: string, @Param('pid') pid: string, @@ -710,6 +728,7 @@ export class ProjectController { @Get('/annotations/:pid') @ApiResponse({ status: 200 }) @Auth(true, true) + @ApiKeyAccess('annotations:read', 'params', 'pid') async getAnnotations( @Param('pid') pid: string, @CurrentUserId() userId: string, @@ -899,8 +918,8 @@ export class ProjectController { @ApiBearerAuth() @Post('organisation/:orgId') @HttpCode(200) - @Auth() @Auth(true) + @ApiKeyAccess('projects:write', 'body', 'projectId') async addProject( @Param('orgId') orgId: string, @Body() addProjectDTO: ProjectIdDto, @@ -945,8 +964,8 @@ export class ProjectController { @ApiBearerAuth() @Delete('organisation/:orgId/:projectId') @HttpCode(204) - @Auth() @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'projectId') async removeProject( @Param('orgId') orgId: string, @Param('projectId') projectId: string, @@ -1482,6 +1501,7 @@ export class ProjectController { @Get('password/:projectId') @Auth(true, true) + @ApiKeyAccess('projects:read', 'params', 'projectId') @ApiResponse({ status: 200, type: Project }) async checkPassword( @Param('projectId') projectId: string, @@ -1615,6 +1635,7 @@ export class ProjectController { @Delete('/:id') @HttpCode(204) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') @ApiResponse({ status: 204, description: 'Empty body' }) async delete( @Param('id') id: string, @@ -1712,6 +1733,7 @@ export class ProjectController { @Patch('/:id/organisation') @HttpCode(204) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') async updateOrganisation( @Param('id') id: string, @Body() body: ProjectOrganisationDto, @@ -1753,6 +1775,7 @@ export class ProjectController { @Put('/:id') @HttpCode(200) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') @ApiResponse({ status: 200, type: Project }) async update( @Param('id') id: string, @@ -1940,6 +1963,7 @@ export class ProjectController { @ApiBearerAuth() @Get('/:id') @Auth(true, true) + @ApiKeyAccess('projects:read', 'params', 'id', ['funnels:read']) @ApiResponse({ status: 200, type: Project }) async getOne( @Param('id') id: string, @@ -2054,6 +2078,7 @@ export class ProjectController { @ApiBearerAuth() @Get(':projectId/views/:viewId') @Auth(true, true) + @ApiKeyAccess('projects:read', 'params', 'projectId') async getProjectView( @Param() params: ProjectViewIdsDto, @CurrentUserId() userId: string, @@ -2078,6 +2103,7 @@ export class ProjectController { @ApiBearerAuth() @Post(':projectId/views') @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'projectId') async createProjectView( @Param() params: ProjectIdDto, @Body() body: CreateProjectViewDto, @@ -2130,6 +2156,7 @@ export class ProjectController { @ApiBearerAuth() @Get(':projectId/views') @Auth(true, true) + @ApiKeyAccess('projects:read', 'params', 'projectId') async getProjectViews( @Param() params: ProjectIdDto, @CurrentUserId() userId: string, @@ -2151,6 +2178,7 @@ export class ProjectController { @ApiBearerAuth() @Patch(':projectId/views/:viewId') @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'projectId') async updateProjectView( @Param() params: ProjectViewIdsDto, @Body() body: UpdateProjectViewDto, @@ -2198,6 +2226,7 @@ export class ProjectController { @HttpCode(HttpStatus.NO_CONTENT) @Delete(':projectId/views/:viewId') @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'projectId') async deleteProjectView( @Param() params: ProjectViewIdsDto, @CurrentUserId() userId: string, diff --git a/backend/apps/cloud/src/project/project.service.ts b/backend/apps/cloud/src/project/project.service.ts index 78f8b03e8..ca13ca930 100644 --- a/backend/apps/cloud/src/project/project.service.ts +++ b/backend/apps/cloud/src/project/project.service.ts @@ -308,6 +308,7 @@ export class ProjectService { userId: string, search?: string, sort?: string, + allowedProjectIds?: string[], ): Promise> { const queryBuilder = this.projectsRepository .createQueryBuilder('project') @@ -358,6 +359,15 @@ export class ProjectService { { userId }, ) + if (allowedProjectIds) { + queryBuilder.andWhere( + allowedProjectIds.length + ? 'project.id IN (:...allowedProjectIds)' + : '1 = 0', + { allowedProjectIds }, + ) + } + if (search?.trim()) { queryBuilder .andWhere('project.name LIKE :search') @@ -403,6 +413,7 @@ export class ProjectService { options: PaginationOptionsInterface, userId: string, search?: string, + allowedProjectIds?: string[], ): Promise> { const queryBuilder = this.projectsRepository .createQueryBuilder('project') @@ -419,6 +430,15 @@ export class ProjectService { }), ) + if (allowedProjectIds) { + queryBuilder.andWhere( + allowedProjectIds.length + ? 'project.id IN (:...allowedProjectIds)' + : '1 = 0', + { allowedProjectIds }, + ) + } + if (search?.trim()) { queryBuilder.andWhere('LOWER(project.name) LIKE LOWER(:search)', { search: `%${search.trim()}%`, diff --git a/backend/apps/cloud/src/revenue/revenue.controller.ts b/backend/apps/cloud/src/revenue/revenue.controller.ts index fb1951007..215e3b0dd 100644 --- a/backend/apps/cloud/src/revenue/revenue.controller.ts +++ b/backend/apps/cloud/src/revenue/revenue.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import { Controller, Get, @@ -62,6 +63,7 @@ export class RevenueController { @ApiBearerAuth() @Get('/:pid/revenue/status') @Auth(true, true) + @ApiKeyAccess('revenue:read', 'params', 'pid') @ApiResponse({ status: 200, type: RevenueStatusDto }) async getRevenueStatus( @CurrentUserId() userId: string, @@ -299,6 +301,7 @@ export class RevenueAnalyticsController { @ApiBearerAuth() @Get('/') @Auth(true, true) + @ApiKeyAccess('revenue:read', 'query', 'pid') @ApiResponse({ status: 200 }) async getRevenueStats( @CurrentUserId() userId: string, @@ -422,6 +425,7 @@ export class RevenueAnalyticsController { @ApiBearerAuth() @Post() @Auth(true, false) + @ApiKeyAccess('revenue:write', 'body', 'pid') @HttpCode(201) @ApiResponse({ status: 201 }) async logRevenue( diff --git a/backend/apps/cloud/src/user/entities/user.entity.ts b/backend/apps/cloud/src/user/entities/user.entity.ts index fc0c310a9..54ae844d4 100644 --- a/backend/apps/cloud/src/user/entities/user.entity.ts +++ b/backend/apps/cloud/src/user/entities/user.entity.ts @@ -770,6 +770,7 @@ export class User { unique: true, nullable: true, default: null, + select: false, }) apiKey: string | null diff --git a/backend/apps/cloud/src/user/user.controller.ts b/backend/apps/cloud/src/user/user.controller.ts index a9bb16b99..e17531e68 100644 --- a/backend/apps/cloud/src/user/user.controller.ts +++ b/backend/apps/cloud/src/user/user.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyService } from '../api-key/api-key.service' import { Controller, Req, @@ -12,7 +13,6 @@ import { FileTypeValidator, UseGuards, UseInterceptors, - ConflictException, Headers, Ip, NotFoundException, @@ -32,13 +32,11 @@ import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import _map from 'lodash/map' import _join from 'lodash/join' -import _isNull from 'lodash/isNull' import _isEmpty from 'lodash/isEmpty' import _includes from 'lodash/includes' import _isString from 'lodash/isString' import _pick from 'lodash/pick' import _round from 'lodash/round' -import { randomUUID } from 'crypto' import { Markup } from 'telegraf' import { Public, CurrentUserId, Auth } from '../auth/decorators' @@ -119,6 +117,7 @@ const formatUsageInfo = ( @Auth() export class UserController { constructor( + private readonly apiKeys: ApiKeyService, private readonly userService: UserService, private readonly authService: AuthService, private readonly projectService: ProjectService, @@ -196,34 +195,10 @@ export class UserController { @ApiBearerAuth() @Post('/api-key') - async generateApiKey( - @CurrentUserId() userId: string, - @Headers() headers, - @Ip() reqIP, - ): Promise<{ - apiKey: string - }> { - this.logger.log({ userId }, 'POST /user/api-key') - - const ip = getIPFromHeaders(headers) || reqIP || '' - - await checkRateLimit(ip, 'generate-api-key', 5, 3600) - - const user = await this.userService.findOne({ where: { id: userId } }) - - if (!_isNull(user.apiKey)) { - throw new ConflictException('You already have an API key') - } - - const apiKey: string = randomUUID() - - await this.userService.update(userId, { apiKey }) - - await trackCustom(ip, headers['user-agent'], { - ev: 'API_KEY_GENERATED', - }) - - return { apiKey } + async generateApiKey() { + throw new BadRequestException( + 'Create scoped API keys through /user/api-keys', + ) } @ApiBearerAuth() @@ -237,17 +212,11 @@ export class UserController { const ip = getIPFromHeaders(headers) || requestIp || '' - const user = await this.userService.findOne({ where: { id: userId } }) - - if (_isNull(user.apiKey)) { - throw new ConflictException("You don't have an API key") - } - await trackCustom(ip, headers['user-agent'], { ev: 'API_KEY_DELETED', }) - await this.userService.update(userId, { apiKey: null }) + await this.apiKeys.remove(userId, 'legacy') } @ApiBearerAuth() diff --git a/backend/apps/cloud/src/user/user.service.ts b/backend/apps/cloud/src/user/user.service.ts index 781c4a9df..095139fb8 100644 --- a/backend/apps/cloud/src/user/user.service.ts +++ b/backend/apps/cloud/src/user/user.service.ts @@ -2425,6 +2425,8 @@ export class UserService { return _omit(enhancedUser, [ 'password', + 'apiKey', + 'apiKeyAccess', 'twoFactorRecoveryCode', 'twoFactorAuthenticationSecret', ]) diff --git a/backend/apps/community/src/analytics/analytics.controller.ts b/backend/apps/community/src/analytics/analytics.controller.ts index 4d3d63dd2..00145a730 100644 --- a/backend/apps/community/src/analytics/analytics.controller.ts +++ b/backend/apps/community/src/analytics/analytics.controller.ts @@ -1,3 +1,5 @@ +import { ApiKeyCollection } from '../api-key/api-key-collection.decorator' +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import type { Response as ExpressResponse } from 'express' import { SessionReplayExportService } from './session-replay-export.service' import { @@ -230,6 +232,7 @@ export class AnalyticsController { @Get() @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', ['events:read']) async getData( @Query() data: GetDataDto, @CurrentUserId() uid: string, @@ -402,6 +405,7 @@ export class AnalyticsController { @Get('captcha') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getCaptchaData( @Query() data: GetDataDto, @CurrentUserId() uid: string, @@ -411,6 +415,7 @@ export class AnalyticsController { @Get('funnel') @Auth(true, true) + @ApiKeyAccess('funnels:read', 'query', 'pid') async getFunnel( @Query() data: GetFunnelsDto, @CurrentUserId() uid: string, @@ -546,6 +551,11 @@ export class AnalyticsController { @Get('funnel-sessions') @Auth(true, true) + @ApiKeyAccess('funnels:read', 'query', 'pid', [ + 'analytics:read', + 'events:read', + 'errors:read', + ]) async getFunnelSessions( @Query() data: GetFunnelSessionsDto, @CurrentUserId() uid: string, @@ -643,6 +653,7 @@ export class AnalyticsController { @Get('meta') @Auth(true, true) + @ApiKeyAccess('events:read', 'query', 'pid') async getCustomEventMetadata( @Query() data: GetCustomEventMetadata, @CurrentUserId() uid: string, @@ -663,6 +674,7 @@ export class AnalyticsController { @Get('property') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getPagePropertyMetadata( @Query() data: GetPagePropertyMetaDto, @CurrentUserId() uid: string, @@ -683,6 +695,7 @@ export class AnalyticsController { @Get('filters') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getFilters( @Query() data: GetFiltersDto, @CurrentUserId() uid: string, @@ -703,6 +716,7 @@ export class AnalyticsController { @Get('chart') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getChartData( @Query() data: GetDataDto, @CurrentUserId() uid: string, @@ -758,6 +772,7 @@ export class AnalyticsController { @Get('performance') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getPerfData( @Query() data: GetDataDto & { measure: PerfMeasure }, @CurrentUserId() uid: string, @@ -841,6 +856,7 @@ export class AnalyticsController { @Get('performance/chart') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getPerfChartData( @Query() data: GetDataDto & { measure: PerfMeasure }, @CurrentUserId() uid: string, @@ -898,6 +914,7 @@ export class AnalyticsController { @Get('journeys') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getJourneys( @Query() data: GetJourneysDto, @CurrentUserId() uid: string, @@ -978,6 +995,11 @@ export class AnalyticsController { @Get('journey-sessions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'analytics:read', + 'events:read', + 'errors:read', + ]) async getJourneySessions( @Query() data: GetJourneySessionsDto, @CurrentUserId() uid: string, @@ -1056,6 +1078,7 @@ export class AnalyticsController { @Get('birdseye') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') // returns overall short statistics per project async getOverallStats( @Query() data: GetOverallStatsDto, @@ -1101,6 +1124,7 @@ export class AnalyticsController { @Get('performance/birdseye') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getPerformanceOverallStats( @Query() data: GetOverallStatsDto & { measure: PerfMeasure }, @CurrentUserId() uid: string, @@ -1190,6 +1214,7 @@ export class AnalyticsController { @Get('live-visitors') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getLiveVisitors( @Query() queryParams: LiveVisitorsDto, @CurrentUserId() uid: string, @@ -1291,6 +1316,8 @@ export class AnalyticsController { } @Post('custom') + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async logCustom( @Body() eventsDTO: EventsDto, @@ -1507,6 +1534,7 @@ export class AnalyticsController { @Post('hb') @Auth(true, true) + @ApiKeyAccess('events:write', 'body', 'pid') async heartbeat( @Body() logDTO: PageviewsDto, @Headers() headers, @@ -1560,6 +1588,8 @@ export class AnalyticsController { } @Post() + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async log(@Body() logDTO: PageviewsDto, @Headers() headers, @Ip() reqIP) { const { 'user-agent': userAgent, origin } = headers @@ -1859,6 +1889,11 @@ export class AnalyticsController { @Get('sessions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + 'replays:read', + ]) async getSessions( @Query() data: GetSessionsDto, @CurrentUserId() uid: string, @@ -1951,6 +1986,11 @@ export class AnalyticsController { @Get('session') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + 'replays:read', + ]) async getSession( @Query() data: GetSessionDto, @CurrentUserId() uid: string, @@ -1979,6 +2019,7 @@ export class AnalyticsController { @Get('custom-events') @Auth(true, true) + @ApiKeyAccess('events:read', 'query', 'pid') async getCustomEvents( @Query() data: GetCustomEventsDto, @CurrentUserId() uid: string, @@ -2074,6 +2115,7 @@ export class AnalyticsController { @Get('errors-filters') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid') async getErrorsFilters( @Query() data: GetFiltersDto, @CurrentUserId() uid: string, @@ -2097,6 +2139,7 @@ export class AnalyticsController { @Get('filters/versions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getVersionFilters( @Query() data: GetVersionFiltersDto, @CurrentUserId() uid: string, @@ -2120,6 +2163,7 @@ export class AnalyticsController { @Get('bot-stats') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getBotStats( @Query() data: GetBotStatsDto, @CurrentUserId() uid: string, @@ -2142,6 +2186,8 @@ export class AnalyticsController { } @Post('error') + @ApiKeyCollection() + @ApiKeyAccess('errors:write', 'body', 'pid') @Public() async logError(@Body() errorDTO: ErrorDto, @Headers() headers, @Ip() reqIP) { const { 'user-agent': userAgent, origin } = headers @@ -2262,6 +2308,7 @@ export class AnalyticsController { // Update error(s) status @Patch('error-status') @Auth(true, true) + @ApiKeyAccess('errors:write', 'body', 'pid') async patchStatus( @Body() statusDTO: PatchStatusDto, @CurrentUserId() uid: string, @@ -2288,6 +2335,7 @@ export class AnalyticsController { @Get('errors') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid') async getErrors( @Query() data: GetErrorsDto, @CurrentUserId() uid: string, @@ -2374,6 +2422,7 @@ export class AnalyticsController { @Get('get-error') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid') async getError( @Query() data: GetErrorDto, @CurrentUserId() uid: string, @@ -2437,6 +2486,7 @@ export class AnalyticsController { @Get('error-overview') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid') async getErrorOverview( @Query() data: GetErrorOverviewDto, @CurrentUserId() uid: string, @@ -2532,6 +2582,7 @@ export class AnalyticsController { @Get('error-sessions') @Auth(true, true) + @ApiKeyAccess('errors:read', 'query', 'pid', ['analytics:read']) async getErrorSessions( @Query() data: GetErrorDto & { take?: number; skip?: number }, @CurrentUserId() uid: string, @@ -2611,6 +2662,10 @@ export class AnalyticsController { @Get('profiles') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + ]) async getProfiles( @Query() data: GetProfilesDto, @CurrentUserId() uid: string, @@ -2699,6 +2754,10 @@ export class AnalyticsController { @Get('profile') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + ]) async getProfile( @Query() data: GetProfileDto, @CurrentUserId() uid: string, @@ -2734,6 +2793,10 @@ export class AnalyticsController { @Get('profile/sessions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid', [ + 'events:read', + 'errors:read', + ]) async getProfileSessions( @Query() data: GetProfileSessionsDto, @CurrentUserId() uid: string, @@ -2831,6 +2894,8 @@ export class AnalyticsController { * profile and shown on its dashboard page. */ @Post('identify') + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async identify( @Body() dto: IdentifyDto, @@ -2920,6 +2985,8 @@ export class AnalyticsController { } @Post('profile-id') + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async getOrCreateProfileId( @Body() dto: GetProfileIdDto, @@ -2951,6 +3018,8 @@ export class AnalyticsController { } @Post('session-id') + @ApiKeyCollection() + @ApiKeyAccess('events:write', 'body', 'pid') @Public() async getOrCreateSessionId( @Body() dto: GetSessionIdDto, @@ -2983,6 +3052,7 @@ export class AnalyticsController { @Get('keywords') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'query', 'pid') async getKeywords( @Query() data: GetKeywordsDto, @CurrentUserId() uid: string, @@ -3015,6 +3085,8 @@ export class AnalyticsController { } @Post('session-replay/start') + @ApiKeyCollection() + @ApiKeyAccess('replays:write', 'body', 'pid') @Public() async startSessionReplay( @Body() replayDTO: SessionReplayStartDto, @@ -3062,6 +3134,8 @@ export class AnalyticsController { } @Post('session-replay/chunk') + @ApiKeyCollection() + @ApiKeyAccess('replays:write', 'body', 'pid') @Public() async uploadSessionReplayChunk( @Body() replayDTO: SessionReplayChunkDto, @@ -3111,6 +3185,7 @@ export class AnalyticsController { @Get('session-replays') @Auth(true, true) + @ApiKeyAccess('replays:read', 'query', 'pid') async getSessionReplays( @Query() data: GetSessionReplaysDto, @CurrentUserId() uid: string, @@ -3192,6 +3267,7 @@ export class AnalyticsController { @Get('session-replay') @Auth(true, true) + @ApiKeyAccess('replays:read', 'query', 'pid') async getSessionReplay( @Query() data: GetSessionReplayDto, @CurrentUserId() uid: string, @@ -3215,6 +3291,7 @@ export class AnalyticsController { @Delete('session-replay') @Auth(true, true) + @ApiKeyAccess('replays:write', 'query', 'pid') async deleteSessionReplay( @Query() data: GetSessionReplayDto, @CurrentUserId() uid: string, @@ -3233,6 +3310,7 @@ export class AnalyticsController { @Post('session-replay/export') @Auth(true, true) + @ApiKeyAccess('replays:read', 'body', 'pid') async startSessionReplayExport( @Body() data: SessionReplayExportStartDto, @CurrentUserId() uid: string | null, @@ -3261,6 +3339,7 @@ export class AnalyticsController { @Get('session-replay/export/:exportId') @Auth(true, true) + @ApiKeyAccess('replays:read', 'export', 'exportId') async getSessionReplayExport( @Param('exportId') exportId: string, @CurrentUserId() uid: string | null, @@ -3280,6 +3359,7 @@ export class AnalyticsController { @Get('session-replay/export/:exportId/download') @Auth(true, true) + @ApiKeyAccess('replays:read', 'export', 'exportId') async downloadSessionReplayExport( @Param('exportId') exportId: string, @CurrentUserId() uid: string | null, diff --git a/backend/apps/community/src/analytics/v2/controllers/captcha-v2.controller.ts b/backend/apps/community/src/analytics/v2/controllers/captcha-v2.controller.ts index b2e0c313b..ecfd296b1 100644 --- a/backend/apps/community/src/analytics/v2/controllers/captcha-v2.controller.ts +++ b/backend/apps/community/src/analytics/v2/controllers/captcha-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -47,6 +48,7 @@ export class CaptchaV2Controller { @Get('summary') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Captcha summary', @@ -72,6 +74,7 @@ export class CaptchaV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Captcha timeseries', @@ -97,6 +100,7 @@ export class CaptchaV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Captcha breakdown', diff --git a/backend/apps/community/src/analytics/v2/controllers/errors-v2.controller.ts b/backend/apps/community/src/analytics/v2/controllers/errors-v2.controller.ts index 902f3bdd8..a2b606829 100644 --- a/backend/apps/community/src/analytics/v2/controllers/errors-v2.controller.ts +++ b/backend/apps/community/src/analytics/v2/controllers/errors-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -52,6 +53,7 @@ export class ErrorsV2Controller { @Get() @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Error groups', @@ -77,6 +79,7 @@ export class ErrorsV2Controller { @Get('overview') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Errors overview', @@ -102,6 +105,7 @@ export class ErrorsV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Errors timeseries', @@ -126,6 +130,7 @@ export class ErrorsV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Errors breakdown', @@ -154,6 +159,7 @@ export class ErrorsV2Controller { @Get(':eid') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid') @ApiOperation({ summary: 'Error details', description: @@ -181,6 +187,7 @@ export class ErrorsV2Controller { @Get(':eid/sessions') @Auth(true, true) + @ApiKeyAccess('errors:read', 'params', 'pid', ['analytics:read']) @ApiOperation({ summary: 'Error sessions', description: 'Paginated sessions affected by a specific error group.', diff --git a/backend/apps/community/src/analytics/v2/controllers/performance-v2.controller.ts b/backend/apps/community/src/analytics/v2/controllers/performance-v2.controller.ts index 664f8d91e..cf1a5bf7b 100644 --- a/backend/apps/community/src/analytics/v2/controllers/performance-v2.controller.ts +++ b/backend/apps/community/src/analytics/v2/controllers/performance-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -48,6 +49,7 @@ export class PerformanceV2Controller { @Get('summary') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Performance summary', @@ -73,6 +75,7 @@ export class PerformanceV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Performance timeseries', @@ -101,6 +104,7 @@ export class PerformanceV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Performance breakdown', diff --git a/backend/apps/community/src/analytics/v2/controllers/profiles-v2.controller.ts b/backend/apps/community/src/analytics/v2/controllers/profiles-v2.controller.ts index 7b4e29f96..de7a5ea10 100644 --- a/backend/apps/community/src/analytics/v2/controllers/profiles-v2.controller.ts +++ b/backend/apps/community/src/analytics/v2/controllers/profiles-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -47,6 +48,10 @@ export class ProfilesV2Controller { @Get() @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + ]) @CacheableAnalytics() @ApiOperation({ summary: 'Profiles', @@ -72,6 +77,10 @@ export class ProfilesV2Controller { @Get(':profileId') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + ]) @ApiOperation({ summary: 'Profile details', description: @@ -103,6 +112,10 @@ export class ProfilesV2Controller { @Get(':profileId/sessions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + ]) @ApiOperation({ summary: 'Profile sessions', description: 'Paginated sessions recorded for a specific profile.', diff --git a/backend/apps/community/src/analytics/v2/controllers/project-v2.controller.ts b/backend/apps/community/src/analytics/v2/controllers/project-v2.controller.ts index bb3a81ff2..75a8710b0 100644 --- a/backend/apps/community/src/analytics/v2/controllers/project-v2.controller.ts +++ b/backend/apps/community/src/analytics/v2/controllers/project-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -51,6 +52,7 @@ export class ProjectV2Controller { @Get('funnel') @Auth(true, true) + @ApiKeyAccess('funnels:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Funnel analysis', @@ -76,6 +78,11 @@ export class ProjectV2Controller { @Get('funnel/sessions') @Auth(true, true) + @ApiKeyAccess('funnels:read', 'params', 'pid', [ + 'analytics:read', + 'events:read', + 'errors:read', + ]) @CacheableAnalytics() @ApiOperation({ summary: 'Funnel sessions', @@ -104,6 +111,7 @@ export class ProjectV2Controller { @Get('live-visitors') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @ApiOperation({ summary: 'Live visitors', description: @@ -127,6 +135,7 @@ export class ProjectV2Controller { @Get('dimensions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @ApiOperation({ summary: 'Dimension & metric discovery', description: @@ -157,6 +166,7 @@ export class ProjectV2Controller { @Get('dimensions/:dimension/values') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @ApiOperation({ summary: 'Dimension values', description: diff --git a/backend/apps/community/src/analytics/v2/controllers/seo-v2.controller.ts b/backend/apps/community/src/analytics/v2/controllers/seo-v2.controller.ts index 85ee82f0c..423d4662c 100644 --- a/backend/apps/community/src/analytics/v2/controllers/seo-v2.controller.ts +++ b/backend/apps/community/src/analytics/v2/controllers/seo-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -50,6 +51,7 @@ export class SeoV2Controller { @Get('status') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Search Console connection status', @@ -74,6 +76,7 @@ export class SeoV2Controller { @Get('summary') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'SEO summary', @@ -99,6 +102,7 @@ export class SeoV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'SEO timeseries', @@ -124,6 +128,7 @@ export class SeoV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'SEO breakdown', @@ -152,6 +157,7 @@ export class SeoV2Controller { @Get('branded-traffic') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Branded vs non-branded traffic', @@ -177,6 +183,7 @@ export class SeoV2Controller { @Get('positions') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Search position analytics', diff --git a/backend/apps/community/src/analytics/v2/controllers/sessions-v2.controller.ts b/backend/apps/community/src/analytics/v2/controllers/sessions-v2.controller.ts index 9870e3744..417ddd883 100644 --- a/backend/apps/community/src/analytics/v2/controllers/sessions-v2.controller.ts +++ b/backend/apps/community/src/analytics/v2/controllers/sessions-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -47,6 +48,11 @@ export class SessionsV2Controller { @Get() @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + 'replays:read', + ]) @CacheableAnalytics() @ApiOperation({ summary: 'Sessions', @@ -72,6 +78,11 @@ export class SessionsV2Controller { @Get(':psid') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid', [ + 'events:read', + 'errors:read', + 'replays:read', + ]) @ApiOperation({ summary: 'Session details', description: diff --git a/backend/apps/community/src/analytics/v2/controllers/traffic-v2.controller.ts b/backend/apps/community/src/analytics/v2/controllers/traffic-v2.controller.ts index 60c5b40db..49ca3583c 100644 --- a/backend/apps/community/src/analytics/v2/controllers/traffic-v2.controller.ts +++ b/backend/apps/community/src/analytics/v2/controllers/traffic-v2.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../../../api-key/api-key-access.decorator' import { Controller, Get, @@ -54,6 +55,7 @@ export class TrafficV2Controller { @Get('summary') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Traffic summary', @@ -79,6 +81,7 @@ export class TrafficV2Controller { @Get('timeseries') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Traffic timeseries', @@ -104,6 +107,7 @@ export class TrafficV2Controller { @Get('breakdown') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Traffic breakdown', @@ -132,6 +136,7 @@ export class TrafficV2Controller { @Get('custom-events') @Auth(true, true) + @ApiKeyAccess('events:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Custom events', @@ -160,6 +165,7 @@ export class TrafficV2Controller { @Get('custom-metrics') @Auth(true, true) + @ApiKeyAccess('events:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Custom metrics', @@ -188,6 +194,7 @@ export class TrafficV2Controller { @Get('custom-events/timeseries') @Auth(true, true) + @ApiKeyAccess('events:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Custom events timeseries', @@ -216,6 +223,7 @@ export class TrafficV2Controller { @Get('custom-events/metadata') @Auth(true, true) + @ApiKeyAccess('events:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Custom event metadata', @@ -244,6 +252,7 @@ export class TrafficV2Controller { @Get('page-properties/metadata') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Page property values', @@ -271,6 +280,7 @@ export class TrafficV2Controller { @Get('page-properties') @Auth(true, true) + @ApiKeyAccess('analytics:read', 'params', 'pid') @CacheableAnalytics() @ApiOperation({ summary: 'Page properties', diff --git a/backend/apps/community/src/api-key/api-key-access.decorator.ts b/backend/apps/community/src/api-key/api-key-access.decorator.ts new file mode 100644 index 000000000..6b5216c1a --- /dev/null +++ b/backend/apps/community/src/api-key/api-key-access.decorator.ts @@ -0,0 +1,15 @@ +import { SetMetadata } from '@nestjs/common' +import { API_KEY_ACCESS, ApiKeyPolicy, ApiKeyScope } from './api-key.types' + +export const ApiKeyAccess = ( + scope: ApiKeyScope, + source: ApiKeyPolicy['source'], + field = 'pid', + requires: ApiKeyScope[] = [], +) => + SetMetadata(API_KEY_ACCESS, { + scope, + source, + field, + requires, + } satisfies ApiKeyPolicy) diff --git a/backend/apps/community/src/api-key/api-key-collection.decorator.ts b/backend/apps/community/src/api-key/api-key-collection.decorator.ts new file mode 100644 index 000000000..c4b69fe1b --- /dev/null +++ b/backend/apps/community/src/api-key/api-key-collection.decorator.ts @@ -0,0 +1,18 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UseGuards, +} from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' +import { ApiKeyGuard } from './api-key.guard' + +@Injectable() +class CollectionKeyGuard extends AuthGuard('api-key') implements CanActivate { + canActivate(context: ExecutionContext) { + if (!context.switchToHttp().getRequest().headers?.['x-api-key']) return true + return super.canActivate(context) + } +} + +export const ApiKeyCollection = () => UseGuards(CollectionKeyGuard, ApiKeyGuard) diff --git a/backend/apps/community/src/api-key/api-key.controller.ts b/backend/apps/community/src/api-key/api-key.controller.ts new file mode 100644 index 000000000..1d60e4f01 --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + Param, + Patch, + Post, + Query, + UsePipes, + ValidationPipe, +} from '@nestjs/common' +import { Auth } from '../auth/decorators' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { ApiKeyService } from './api-key.service' +import { SaveApiKeyDto } from './api-key.dto' +import { checkRateLimit } from '../common/utils' + +@Controller(['user/api-keys', 'v1/user/api-keys']) +@Auth() +@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) +export class ApiKeyController { + constructor(private readonly keys: ApiKeyService) {} + + @Get() + @Header('Cache-Control', 'no-store') + list( + @CurrentUserId() userId: string, + @Query('projectId') projectId?: string, + ) { + return this.keys.list(userId, projectId) + } + + @Post() + @Header('Cache-Control', 'no-store') + async create(@CurrentUserId() userId: string, @Body() dto: SaveApiKeyDto) { + await checkRateLimit(userId, 'create-api-key', 20, 3600) + return this.keys.create(userId, dto) + } + + @Patch(':id') + update( + @CurrentUserId() userId: string, + @Param('id') id: string, + @Body() dto: SaveApiKeyDto, + ) { + return this.keys.update(userId, id, dto) + } + + @Post(':id/reveal') + @Header('Cache-Control', 'no-store') + async reveal(@CurrentUserId() userId: string, @Param('id') id: string) { + await checkRateLimit(userId, 'reveal-api-key', 60, 3600) + return this.keys.reveal(userId, id) + } + + @Post(':id/rotate') + @Header('Cache-Control', 'no-store') + async rotate(@CurrentUserId() userId: string, @Param('id') id: string) { + await checkRateLimit(userId, 'rotate-api-key', 20, 3600) + return this.keys.rotate(userId, id) + } + + @Delete(':id') + remove(@CurrentUserId() userId: string, @Param('id') id: string) { + return this.keys.remove(userId, id) + } +} diff --git a/backend/apps/community/src/api-key/api-key.crypto.ts b/backend/apps/community/src/api-key/api-key.crypto.ts new file mode 100644 index 000000000..8dfab6e36 --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.crypto.ts @@ -0,0 +1,36 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'crypto' +import { deriveKey } from '../common/utils' + +export const hashApiKey = (key: string) => + createHash('sha256').update(key).digest('hex') +export const generateApiKey = () => `swx_${randomBytes(32).toString('hex')}` +const encryptionKey = () => { + if (!process.env.SECRET_KEY_BASE) + throw new Error('SECRET_KEY_BASE is required') + return Buffer.from(deriveKey('api-key', 32), 'hex') +} +export const encryptApiKey = (key: string, userId: string, id: string) => { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv) + cipher.setAAD(Buffer.from(`${userId}:${id}`)) + const encrypted = Buffer.concat([cipher.update(key, 'utf8'), cipher.final()]) + return [iv, cipher.getAuthTag(), encrypted] + .map((value) => value.toString('base64')) + .join('.') +} +export const decryptApiKey = (value: string, userId: string, id: string) => { + const [iv, tag, encrypted] = value + .split('.') + .map((part) => Buffer.from(part, 'base64')) + const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), iv) + decipher.setAAD(Buffer.from(`${userId}:${id}`)) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( + 'utf8', + ) +} diff --git a/backend/apps/community/src/api-key/api-key.dto.ts b/backend/apps/community/src/api-key/api-key.dto.ts new file mode 100644 index 000000000..8f431c12a --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.dto.ts @@ -0,0 +1,38 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsBoolean, + IsIn, + IsString, + Length, + Matches, +} from 'class-validator' +import { API_KEY_SCOPES, ApiKeyScope } from './api-key.types' + +export class SaveApiKeyDto { + @IsString() + @Length(1, 80) + name: string + + @IsArray() + @ArrayMaxSize(30) + @ArrayUnique() + @IsIn( + Object.entries(API_KEY_SCOPES).flatMap(([scope, actions]) => + actions.map((action) => `${scope}:${action}`), + ), + { each: true }, + ) + scopes: ApiKeyScope[] + + @IsBoolean() + allProjects: boolean + + @IsArray() + @ArrayMaxSize(1000) + @ArrayUnique() + @IsString({ each: true }) + @Matches(/^(?!.*--)[a-zA-Z0-9-]{12}$/, { each: true }) + projectIds: string[] +} diff --git a/backend/apps/community/src/api-key/api-key.guard.ts b/backend/apps/community/src/api-key/api-key.guard.ts new file mode 100644 index 000000000..b35c2cf73 --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.guard.ts @@ -0,0 +1,124 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ApiKeyService } from './api-key.service' +import { API_KEY_ACCESS, ApiKeyPolicy, ApiKeyPrincipal } from './api-key.types' + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly keys: ApiKeyService, + ) {} + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest() + const key: ApiKeyPrincipal | undefined = request.user?.apiKeyAccess + if (!key || key.unrestricted) return true + const policy = this.reflector.get( + API_KEY_ACCESS, + context.getHandler(), + ) + if ( + !policy || + [policy.scope, ...(policy.requires || [])].some( + (scope) => !key.scopes.includes(scope), + ) + ) { + throw new ForbiddenException( + `This API key requires: ${policy ? [policy.scope, ...(policy.requires || [])].join(', ') : 'an explicit endpoint permission'}`, + ) + } + const query = request.query || {} + if (query.type === 'errors' && !key.scopes.includes('errors:read')) + throw new ForbiddenException('This API key requires errors:read') + if (!key.scopes.includes('events:read') && request.method !== 'POST') { + let filters = query.filters + if (typeof filters === 'string') { + try { + filters = JSON.parse(filters) + } catch { + throw new ForbiddenException('Invalid filters') + } + } + const eventDimension = (value: unknown) => + typeof value === 'string' && + (value === 'ev' || + value.startsWith('ev:') || + value === 'event' || + value === 'event_metadata') + if ( + eventDimension(query.type) || + eventDimension(request.params?.dimension) || + (Array.isArray(filters) && + filters.some( + (filter) => + eventDimension(filter?.column) || + eventDimension(filter?.dimension), + )) + ) { + throw new ForbiddenException('This query requires events:read') + } + } + if (policy.source === 'account' || policy.source === 'create') { + if (!key.allProjects) + throw new ForbiddenException( + 'This operation requires access to all projects', + ) + return true + } + const owned = await this.keys.store.ownedProjects(request.user.id) + const allowed = owned + .filter( + (project) => key.allProjects || key.projectIds.includes(project.id), + ) + .map((project) => project.id) + if (policy.source === 'list') { + request.apiKeyProjectIds = allowed + return true + } + let ids: unknown[] + if (['goal', 'flag', 'export'].includes(policy.source)) { + ids = [ + await this.keys.store.resourceProject( + policy.source, + request.params[policy.field], + ), + ] + } else { + const input = request[policy.source] || {} + const value = input[policy.field] + ids = value === undefined ? [] : [value] + if ( + policy.source === 'query' && + policy.field === 'pid' && + input.pids !== undefined + ) { + let pids = input.pids + if (typeof pids === 'string') { + try { + pids = JSON.parse(pids) + } catch { + throw new ForbiddenException('Invalid project IDs') + } + } + if (!Array.isArray(pids)) + throw new ForbiddenException('Invalid project IDs') + ids.push(...pids) + } + } + if ( + !ids.length || + ids.some((id) => typeof id !== 'string' || !allowed.includes(id)) + ) { + throw new ForbiddenException( + 'This API key does not have access to this project', + ) + } + return true + } +} diff --git a/backend/apps/community/src/api-key/api-key.module.ts b/backend/apps/community/src/api-key/api-key.module.ts new file mode 100644 index 000000000..b0b3f0ad2 --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.module.ts @@ -0,0 +1,15 @@ +import { Global, Module, forwardRef } from '@nestjs/common' +import { UserModule } from '../user/user.module' +import { ApiKeyService } from './api-key.service' +import { ApiKeyStore } from './api-key.store' +import { ApiKeyController } from './api-key.controller' +import { ApiKeyGuard } from './api-key.guard' + +@Global() +@Module({ + imports: [forwardRef(() => UserModule)], + providers: [ApiKeyService, ApiKeyStore, ApiKeyGuard], + controllers: [ApiKeyController], + exports: [ApiKeyService, ApiKeyGuard], +}) +export class ApiKeyModule {} diff --git a/backend/apps/community/src/api-key/api-key.service.ts b/backend/apps/community/src/api-key/api-key.service.ts new file mode 100644 index 000000000..c04aeceed --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.service.ts @@ -0,0 +1,267 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common' +import { randomUUID } from 'crypto' +import { ApiKeyStore } from './api-key.store' +import { ApiKeyRecord, API_KEY_SCOPES } from './api-key.types' +import { SaveApiKeyDto } from './api-key.dto' +import { + decryptApiKey, + encryptApiKey, + generateApiKey, + hashApiKey, +} from './api-key.crypto' + +@Injectable() +export class ApiKeyService { + constructor(readonly store: ApiKeyStore) {} + + async authenticate(secret: string) { + if (typeof secret !== 'string' || secret.length > 100) + throw new UnauthorizedException() + const record = await this.store.findByHash(hashApiKey(secret)) + const user = record + ? await this.store.userById(record.userId) + : await this.store.legacyUser(secret) + if (!user) throw new UnauthorizedException() + return { + ...user, + apiKey: undefined, + password: undefined, + twoFactorAuthenticationSecret: undefined, + twoFactorRecoveryCode: undefined, + apiKeyAccess: record + ? { + id: record.id, + scopes: record.scopes, + projectIds: record.projectIds, + allProjects: record.allProjects, + unrestricted: record.unrestricted, + } + : { + id: 'legacy', + scopes: [], + projectIds: [], + allProjects: true, + unrestricted: true, + }, + } + } + + async list(userId: string, projectId?: string) { + const projects = await this.store.ownedProjects(userId) + if (projectId && !projects.some((project) => project.id === projectId)) + throw new ForbiddenException() + if (projectId) { + const users = await this.store.projectUsers(projectId) + const keys = ( + await Promise.all( + users.map(async (owner) => { + const records = await this.store.list(owner.id) + if (owner.apiKey) + records.unshift(this.legacyRecord(owner.id, owner.apiKey)) + return records + .filter( + (key) => + key.unrestricted || + (owner.id === userId && + (key.allProjects || key.projectIds.includes(projectId))), + ) + .map((key) => ({ + ...this.summary(key), + id: `${owner.id}:${key.id}`, + owner: owner.email, + projectReadOnly: owner.id !== userId && !owner.canWrite, + })) + }), + ) + ).flat() + return { + keys, + projects: projects.filter((project) => project.id === projectId), + scopes: API_KEY_SCOPES, + } + } + const records = await this.store.list(userId) + const user = await this.store.userById(userId) + if (user?.apiKey) records.unshift(this.legacyRecord(userId, user.apiKey)) + return { + keys: records + .filter( + (key) => + !projectId || key.allProjects || key.projectIds.includes(projectId), + ) + .map((key) => this.summary(key)), + projects, + scopes: API_KEY_SCOPES, + } + } + + private legacyRecord(userId: string, secret: string): ApiKeyRecord { + return { + id: 'legacy', + userId, + name: 'API key', + keyHash: '', + encryptedKey: '', + keyPreview: `••••${secret.slice(-4)}`, + scopes: [], + projectIds: [], + allProjects: true, + unrestricted: true, + created: null, + rotated: null, + } + } + + private summary(key: ApiKeyRecord) { + const { + keyHash: _keyHash, + encryptedKey: _encryptedKey, + userId: _userId, + ...summary + } = key + return summary + } + + async get(userId: string, id: string) { + if (id === 'legacy') { + const user = await this.store.userById(userId) + if (user?.apiKey) return this.legacyRecord(userId, user.apiKey) + } else { + const key = await this.store.get(userId, id) + if (key) return key + } + throw new NotFoundException('API key not found') + } + + async reveal(userId: string, id: string) { + const key = await this.get(userId, id) + if (id === 'legacy') + return { secret: (await this.store.userById(userId)).apiKey } + try { + return { secret: decryptApiKey(key.encryptedKey, userId, id) } + } catch { + throw new ConflictException( + 'Unable to decrypt this API key. Please rotate the key.', + ) + } + } + + private async validate(userId: string, dto: SaveApiKeyDto) { + if (!dto.name.trim()) + throw new BadRequestException('Enter a name for this key') + if (!dto.scopes.length) + throw new BadRequestException('Select at least one permission') + if (!dto.allProjects && !dto.projectIds.length) + throw new BadRequestException('Select at least one project') + if ( + !dto.allProjects && + dto.scopes.some((scope) => scope.startsWith('organisations:')) + ) + throw new BadRequestException( + 'Organisation permissions require all projects', + ) + dto.scopes = [ + ...new Set( + dto.scopes.flatMap((scope) => + scope.endsWith(':write') + ? [scope, scope.replace(':write', ':read') as typeof scope] + : [scope], + ), + ), + ] + const owned = await this.store.ownedProjects(userId) + if ( + dto.projectIds.some((id) => !owned.some((project) => project.id === id)) + ) + throw new ForbiddenException( + 'You can only grant access to projects you own', + ) + } + + private withSecret(record: ApiKeyRecord, secret: string): ApiKeyRecord { + return { + ...record, + keyHash: hashApiKey(secret), + encryptedKey: encryptApiKey(secret, record.userId, record.id), + keyPreview: `${secret.startsWith('swx_') ? 'swx_' : ''}••••${secret.slice(-4)}`, + } + } + + async create(userId: string, dto: SaveApiKeyDto) { + await this.validate(userId, dto) + if ((await this.store.list(userId)).length >= 50) + throw new BadRequestException('You can create up to 50 API keys') + const secret = generateApiKey() + const record = this.withSecret( + { + ...dto, + name: dto.name.trim(), + projectIds: dto.allProjects ? [] : dto.projectIds, + id: randomUUID(), + userId, + unrestricted: false, + created: new Date().toISOString(), + rotated: null, + keyHash: '', + encryptedKey: '', + keyPreview: '', + }, + secret, + ) + await this.store.insert(record) + return { key: this.summary(record), secret } + } + + async update(userId: string, id: string, dto: SaveApiKeyDto) { + await this.validate(userId, dto) + const old = await this.get(userId, id) + const record = { + ...old, + ...dto, + name: dto.name.trim(), + projectIds: dto.allProjects ? [] : dto.projectIds, + unrestricted: false, + } + if (id === 'legacy') { + const { secret } = await this.reveal(userId, id) + const migrated = this.withSecret({ ...record, id: randomUUID() }, secret) + await this.store.replaceLegacy(migrated, secret) + return this.summary(migrated) + } + await this.store.update(record, old.keyHash) + return this.summary(record) + } + + async rotate(userId: string, id: string) { + const old = await this.get(userId, id) + const secret = generateApiKey() + const record = this.withSecret( + { + ...old, + id: id === 'legacy' ? randomUUID() : id, + rotated: new Date().toISOString(), + }, + secret, + ) + if (id === 'legacy') { + const user = await this.store.userById(userId) + await this.store.replaceLegacy(record, user.apiKey) + } else { + await this.store.update(record, old.keyHash) + } + return { key: this.summary(record), secret } + } + + async remove(userId: string, id: string) { + await this.get(userId, id) + if (id === 'legacy') await this.store.clearLegacy(userId) + else await this.store.remove(userId, id) + } +} diff --git a/backend/apps/community/src/api-key/api-key.spec.ts b/backend/apps/community/src/api-key/api-key.spec.ts new file mode 100644 index 000000000..8b38ec3f8 --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.spec.ts @@ -0,0 +1,493 @@ +import { AuthGuard } from '@nestjs/passport' +import { ApiKeyStrategy } from '../auth/strategies/api-key.strategy' +import 'reflect-metadata' +import { + ConflictException, + ForbiddenException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { validate } from 'class-validator' +import { plainToInstance } from 'class-transformer' +import { createDecipheriv, hkdfSync } from 'crypto' + +jest.mock('./api-key.store', () => ({ ApiKeyStore: class {} })) +jest.mock('../common/constants', () => ({})) +jest.mock('../common/integrations/clickhouse', () => ({})) +import { ApiKeyService } from './api-key.service' +import { ApiKeyGuard } from './api-key.guard' +import { ApiKeyRecord, ApiKeyPolicy } from './api-key.types' +import { SaveApiKeyDto } from './api-key.dto' +import { decryptApiKey, encryptApiKey, generateApiKey } from './api-key.crypto' + +const projectA = 'aaaaaaaaaaaa' +const projectB = 'bbbbbbbbbbbb' +const userId = 'owner' +const secretBase = process.env.SECRET_KEY_BASE +beforeAll(() => { + process.env.SECRET_KEY_BASE = 'test-secret-only-for-api-key-specs' +}) +afterAll(() => { + if (secretBase === undefined) delete process.env.SECRET_KEY_BASE + else process.env.SECRET_KEY_BASE = secretBase +}) + +function setup() { + const records = new Map() + const users = new Map([ + [ + userId, + { id: userId, email: 'owner@example.com', apiKey: 'old-uuid-key' }, + ], + ]) + const store = { + list: jest.fn(async (uid: string) => + [...records.values()].filter((key) => key.userId === uid), + ), + get: jest.fn(async (uid: string, id: string) => { + const key = records.get(id) + return key?.userId === uid ? key : undefined + }), + insert: jest.fn(async (key: ApiKeyRecord) => { + records.set(key.id, key) + }), + update: jest.fn(async (key: ApiKeyRecord) => { + records.set(key.id, key) + }), + remove: jest.fn(async (_uid: string, id: string) => { + records.delete(id) + }), + findByHash: jest.fn(async (hash: string) => + [...records.values()].find((key) => key.keyHash === hash), + ), + userById: jest.fn(async (id: string) => users.get(id)), + legacyUser: jest.fn(async (secret: string) => + [...users.values()].find((user) => user.apiKey === secret), + ), + clearLegacy: jest.fn(async (id: string) => { + users.get(id).apiKey = null + }), + replaceLegacy: jest.fn(async (key: ApiKeyRecord) => { + users.get(key.userId).apiKey = null + records.set(key.id, key) + }), + ownedProjects: jest.fn(async () => [ + { id: projectA, name: 'A' }, + { id: projectB, name: 'B' }, + ]), + resourceProject: jest.fn(async () => projectB), + projectUsers: jest.fn(async () => [...users.values()]), + } + const service = new ApiKeyService(store as any) + return { service, store, records, users } +} +const input = (): SaveApiKeyDto => ({ + name: 'Reporting', + scopes: ['analytics:read'], + allProjects: false, + projectIds: [projectA], +}) + +function context(policy: ApiKeyPolicy | undefined, key: any, values: any = {}) { + const request = { + user: key ? { id: userId, apiKeyAccess: key } : { id: userId }, + params: {}, + query: {}, + body: {}, + ...values, + } + const ctx = { + getHandler: () => null, + switchToHttp: () => ({ getRequest: () => request }), + } + const reflector = { get: () => policy } as unknown as Reflector + return { request, ctx, reflector } +} +const scoped = () => ({ + id: 'key', + scopes: ['analytics:read'], + projectIds: [projectA], + allProjects: false, + unrestricted: false, +}) + +describe('scoped API keys', () => { + it('generates prefixed independent 256-bit secrets and authenticated ciphertext', () => { + const secret = generateApiKey() + expect(secret).toMatch(/^swx_[a-f0-9]{64}$/) + expect(generateApiKey()).not.toBe(secret) + const cipher = encryptApiKey(secret, userId, 'id') + expect(cipher).not.toContain(secret) + expect(decryptApiKey(cipher, userId, 'id')).toBe(secret) + expect(() => decryptApiKey(cipher, 'other', 'id')).toThrow() + expect(() => decryptApiKey(cipher, userId, 'other')).toThrow() + + const derivedKey = Buffer.from( + hkdfSync('sha256', process.env.SECRET_KEY_BASE, '', 'api-key', 32), + ) + const [iv, tag, encrypted] = cipher + .split('.') + .map((part) => Buffer.from(part, 'base64')) + const decipher = createDecipheriv('aes-256-gcm', derivedKey, iv) + decipher.setAAD(Buffer.from(`${userId}:id`)) + decipher.setAuthTag(tag) + expect( + Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( + 'utf8', + ), + ).toBe(secret) + }) + + it('requires a base secret even in test environments', () => { + const cipher = encryptApiKey('secret', userId, 'id') + const base = process.env.SECRET_KEY_BASE + delete process.env.SECRET_KEY_BASE + try { + expect(() => encryptApiKey('secret', userId, 'id')).toThrow( + 'SECRET_KEY_BASE is required', + ) + expect(() => decryptApiKey(cipher, userId, 'id')).toThrow( + 'SECRET_KEY_BASE is required', + ) + } finally { + process.env.SECRET_KEY_BASE = base + } + }) + + it('lists metadata without secrets and reveals only to the owner', async () => { + const { service } = setup() + const result = await service.create(userId, input()) + expect(JSON.stringify(await service.list(userId))).not.toContain( + result.secret, + ) + expect(JSON.stringify(await service.list(userId))).not.toContain('keyHash') + expect(JSON.stringify(await service.list(userId))).not.toContain( + 'encryptedKey', + ) + expect(await service.reveal(userId, result.key.id)).toEqual({ + secret: result.secret, + }) + await expect(service.reveal('other', result.key.id)).rejects.toThrow() + await expect(service.rotate('other', result.key.id)).rejects.toThrow() + await expect(service.remove('other', result.key.id)).rejects.toThrow() + }) + + it('asks the owner to rotate a key when decryption fails', async () => { + const { service, records } = setup() + const { key } = await service.create(userId, input()) + records.get(key.id).encryptedKey = 'invalid-ciphertext' + + await expect(service.reveal(userId, key.id)).rejects.toThrow( + new ConflictException( + 'Unable to decrypt this API key. Please rotate the key.', + ), + ) + await expect(service.reveal('other', key.id)).rejects.toThrow( + NotFoundException, + ) + + const rotated = await service.rotate(userId, key.id) + await expect(service.reveal(userId, key.id)).resolves.toEqual({ + secret: rotated.secret, + }) + }) + + it('reveals legacy keys without an encryption secret', async () => { + const { service } = setup() + const base = process.env.SECRET_KEY_BASE + delete process.env.SECRET_KEY_BASE + try { + await expect(service.reveal(userId, 'legacy')).resolves.toEqual({ + secret: 'old-uuid-key', + }) + } finally { + process.env.SECRET_KEY_BASE = base + } + }) + + it('rotates and revokes immediately while preserving scope and creation date', async () => { + const { service, records } = setup() + const first = await service.create(userId, input()) + const second = await service.rotate(userId, first.key.id) + expect(second.key.created).toBe(first.key.created) + expect(second.key.scopes).toEqual(first.key.scopes) + expect(second.key.projectIds).toEqual(first.key.projectIds) + await expect(service.authenticate(first.secret)).rejects.toThrow( + UnauthorizedException, + ) + expect((await service.authenticate(second.secret)).apiKeyAccess.id).toBe( + first.key.id, + ) + await service.remove(userId, first.key.id) + expect(records.size).toBe(0) + await expect(service.authenticate(second.secret)).rejects.toThrow( + UnauthorizedException, + ) + }) + + it('preserves old UUID keys, including unrestricted rotation', async () => { + const { service } = setup() + expect( + (await service.authenticate('old-uuid-key')).apiKeyAccess.unrestricted, + ).toBe(true) + const rotated = await service.rotate(userId, 'legacy') + expect(rotated.key.unrestricted).toBe(true) + expect(rotated.key.allProjects).toBe(true) + await expect(service.authenticate('old-uuid-key')).rejects.toThrow() + expect( + (await service.authenticate(rotated.secret)).apiKeyAccess.unrestricted, + ).toBe(true) + }) + + it('can restrict an old key without requiring the caller to change its secret', async () => { + const { service } = setup() + await service.update(userId, 'legacy', input()) + const principal = (await service.authenticate('old-uuid-key')).apiKeyAccess + expect(principal.unrestricted).toBe(false) + expect(principal.projectIds).toEqual([projectA]) + expect(principal.scopes).toEqual(['analytics:read']) + }) + + it('rejects arbitrary projects and empty permission sets', async () => { + const { service } = setup() + await expect( + service.create(userId, { ...input(), projectIds: ['cccccccccccc'] }), + ).rejects.toThrow(ForbiddenException) + await expect( + service.create(userId, { ...input(), scopes: [] }), + ).rejects.toThrow() + await expect( + service.create(userId, { ...input(), projectIds: [] }), + ).rejects.toThrow() + }) + + it('grants read alongside write and rejects unsupported write scopes', async () => { + const { service } = setup() + const { key } = await service.create(userId, { + ...input(), + scopes: ['projects:write'], + }) + expect(key.scopes).toEqual( + expect.arrayContaining(['projects:read', 'projects:write']), + ) + expect( + await validate( + plainToInstance(SaveApiKeyDto, { + ...input(), + scopes: ['analytics:write'], + }), + ), + ).not.toHaveLength(0) + expect( + await validate( + plainToInstance(SaveApiKeyDto, { ...input(), allProjects: 'true' }), + ), + ).not.toHaveLength(0) + expect( + await validate( + plainToInstance(SaveApiKeyDto, { + ...input(), + projectIds: ['abc-def12345'], + }), + ), + ).toHaveLength(0) + }) + + it('shows inherited legacy access in project settings without exposing another owner secret', async () => { + const { service, users } = setup() + users.set('teammate', { + id: 'teammate', + email: 'team@example.com', + apiKey: 'another-secret', + }) + const list = await service.list(userId, projectA) + expect(list.keys).toHaveLength(2) + expect(JSON.stringify(list)).not.toContain('another-secret') + expect(list.keys[1]).toMatchObject({ + owner: 'team@example.com', + unrestricted: true, + }) + await expect(service.list(userId, 'cccccccccccc')).rejects.toThrow( + ForbiddenException, + ) + }) +}) + +describe('API key authorization', () => { + async function run( + policy: ApiKeyPolicy | undefined, + key = scoped(), + values = {}, + ) { + const { service, store } = setup() + const { ctx, reflector, request } = context(policy, key, values) + const result = await new ApiKeyGuard(reflector, service).canActivate( + ctx as any, + ) + return { result, request, store } + } + const read: ApiKeyPolicy = { + scope: 'analytics:read', + source: 'query', + field: 'pid', + } + + it('allows only explicitly scoped projects, including for public routes', async () => { + expect( + (await run(read, scoped(), { query: { pid: projectA } })).result, + ).toBe(true) + await expect( + run(read, scoped(), { query: { pid: projectB } }), + ).rejects.toThrow(ForbiddenException) + await expect(run(read, scoped(), { query: {} })).rejects.toThrow( + ForbiddenException, + ) + await expect( + run(read, scoped(), { query: { pid: [projectA] } }), + ).rejects.toThrow(ForbiddenException) + }) + it('denies unannotated endpoints and writes with a read-only key', async () => { + await expect(run(undefined)).rejects.toThrow(ForbiddenException) + await expect(run({ ...read, scope: 'projects:write' })).rejects.toThrow( + ForbiddenException, + ) + }) + it('validates every project in JSON and array batch queries', async () => { + expect( + ( + await run(read, scoped(), { + query: { pids: JSON.stringify([projectA]) }, + }) + ).result, + ).toBe(true) + await expect( + run(read, scoped(), { + query: { pids: JSON.stringify([projectA, projectB]) }, + }), + ).rejects.toThrow() + await expect( + run(read, scoped(), { query: { pids: [projectA, projectB] } }), + ).rejects.toThrow() + await expect( + run(read, scoped(), { query: { pids: '{}' } }), + ).rejects.toThrow() + }) + it('resolves resource IDs from storage and ignores spoofed project query parameters', async () => { + for (const source of ['goal', 'flag', 'export'] as const) { + await expect( + run({ ...read, source, field: 'id' }, scoped(), { + params: { id: 'resource-on-B' }, + query: { pid: projectA }, + }), + ).rejects.toThrow(ForbiddenException) + } + }) + it('limits project lists before the controller runs', async () => { + const { request } = await run({ ...read, source: 'list' }) + expect((request as any).apiKeyProjectIds).toEqual([projectA]) + }) + it('requires all-project access for account-wide operations', async () => { + await expect(run({ ...read, source: 'account' })).rejects.toThrow() + expect( + ( + await run( + { ...read, source: 'account' }, + { ...scoped(), allProjects: true }, + ) + ).result, + ).toBe(true) + }) + it('never grants new scoped keys access to a project the owner no longer owns', async () => { + const { service, store } = setup() + store.ownedProjects.mockResolvedValue([]) + const { reflector, ctx } = context( + read, + { ...scoped(), allProjects: true }, + { query: { pid: projectA } }, + ) + await expect( + new ApiKeyGuard(reflector, service).canActivate(ctx as any), + ).rejects.toThrow() + }) + it('does not apply API-key restrictions to sessions or existing unrestricted keys', async () => { + expect((await run(undefined, null)).result).toBe(true) + expect( + (await run(undefined, { ...scoped(), unrestricted: true })).result, + ).toBe(true) + }) + it('requires error permission for polymorphic dimension queries', async () => { + await expect( + run(read, scoped(), { query: { pid: projectA, type: 'errors' } }), + ).rejects.toThrow() + }) + it('requires every permission for combined responses', async () => { + const combined = { + ...read, + requires: ['events:read', 'errors:read'] as const, + } + await expect( + run(combined as any, scoped(), { query: { pid: projectA } }), + ).rejects.toThrow() + expect( + ( + await run( + combined as any, + { + ...scoped(), + scopes: ['analytics:read', 'events:read', 'errors:read'], + }, + { query: { pid: projectA } }, + ) + ).result, + ).toBe(true) + }) + it('cannot use custom event filters or discovery to bypass event permissions', async () => { + for (const query of [ + { pid: projectA, type: 'ev' }, + { + pid: projectA, + filters: JSON.stringify([{ column: 'ev', filter: 'signup' }]), + }, + { + pid: projectA, + filters: JSON.stringify([ + { dimension: 'event_metadata', key: 'email', value: 'test' }, + ]), + }, + ]) + await expect(run(read, scoped(), { query })).rejects.toThrow() + }) +}) + +describe('Passport API key integration', () => { + it('authenticates prefixed and existing secrets through the real strategy', async () => { + const { service } = setup() + new ApiKeyStrategy(service) + const { secret } = await service.create(userId, input()) + const Guard = AuthGuard('api-key') + for (const token of [secret, 'old-uuid-key']) { + const request: any = { headers: { 'x-api-key': token } } + const ctx: any = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => ({}), + }), + } + expect(await new Guard().canActivate(ctx)).toBe(true) + expect(request.user.id).toBe(userId) + expect(request.user.apiKeyAccess.unrestricted).toBe( + token === 'old-uuid-key', + ) + } + const ctx: any = { + switchToHttp: () => ({ + getRequest: () => ({ headers: { 'x-api-key': 'invalid' } }), + getResponse: () => ({}), + }), + } + await expect(new Guard().canActivate(ctx)).rejects.toThrow( + UnauthorizedException, + ) + }) +}) diff --git a/backend/apps/community/src/api-key/api-key.store.ts b/backend/apps/community/src/api-key/api-key.store.ts new file mode 100644 index 000000000..5a991ce00 --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.store.ts @@ -0,0 +1,161 @@ +import { ConflictException, Injectable } from '@nestjs/common' +import { clickhouse } from '../common/integrations/clickhouse' +import { redis } from '../common/constants' +import { UserService } from '../user/user.service' +import { ApiKeyRecord } from './api-key.types' + +const decode = (row: any): ApiKeyRecord => + row && { + ...row, + scopes: JSON.parse(row.scopes), + projectIds: JSON.parse(row.projectIds), + allProjects: Boolean(row.allProjects), + unrestricted: Boolean(row.unrestricted), + } +const encode = (key: ApiKeyRecord) => ({ + ...key, + scopes: JSON.stringify(key.scopes), + projectIds: JSON.stringify(key.projectIds), + allProjects: Number(key.allProjects), + unrestricted: Number(key.unrestricted), +}) + +@Injectable() +export class ApiKeyStore { + constructor(private readonly users: UserService) {} + private async query(query: string, query_params: Record) { + return (await (await clickhouse.query({ query, query_params })).json()) + .data + } + async list(userId: string): Promise { + return ( + await this.query( + 'SELECT * FROM api_key WHERE userId = {userId:String} ORDER BY created DESC', + { userId }, + ) + ).map(decode) + } + async get(userId: string, id: string) { + return decode( + ( + await this.query( + 'SELECT * FROM api_key WHERE userId = {userId:String} AND id = {id:String}', + { userId, id }, + ) + )[0], + ) + } + async findByHash(keyHash: string) { + return decode( + ( + await this.query( + 'SELECT * FROM api_key WHERE keyHash = {keyHash:String}', + { keyHash }, + ) + )[0], + ) + } + async insert(key: ApiKeyRecord) { + await clickhouse.insert({ + table: 'api_key', + format: 'JSONEachRow', + values: [encode(key)], + clickhouse_settings: { async_insert: 0, wait_for_async_insert: 1 }, + }) + } + async update(key: ApiKeyRecord, expectedHash: string) { + await clickhouse.command({ + query: + 'ALTER TABLE api_key UPDATE name = {name:String}, keyHash = {keyHash:String}, encryptedKey = {encryptedKey:String}, keyPreview = {keyPreview:String}, scopes = {scopes:String}, projectIds = {projectIds:String}, allProjects = {allProjects:UInt8}, unrestricted = {unrestricted:UInt8}, rotated = {rotated:Nullable(String)} WHERE id = {id:String} AND userId = {userId:String} AND keyHash = {expectedHash:String}', + query_params: { ...encode(key), expectedHash }, + clickhouse_settings: { mutations_sync: '2' }, + }) + if ((await this.get(key.userId, key.id))?.keyHash !== key.keyHash) + throw new ConflictException( + 'This key has changed. Refresh and try again.', + ) + } + async remove(userId: string, id: string) { + await clickhouse.command({ + query: + 'ALTER TABLE api_key DELETE WHERE userId = {userId:String} AND id = {id:String}', + query_params: { userId, id }, + clickhouse_settings: { mutations_sync: '2' }, + }) + } + userById(id: string) { + return this.users.findOne({ id }) + } + legacyUser(apiKey: string) { + return this.users.findOne({ apiKey }) + } + async clearLegacy(userId: string) { + await clickhouse.command({ + query: 'ALTER TABLE user UPDATE apiKey = NULL WHERE id = {userId:String}', + query_params: { userId }, + clickhouse_settings: { mutations_sync: '2' }, + }) + } + async replaceLegacy(key: ApiKeyRecord, previous: string) { + const lock = `api-key:legacy:${key.userId}` + const acquired = await redis.set(lock, key.id, 'EX', 300, 'NX') + if (!acquired) + throw new ConflictException('This key is being changed. Try again.') + try { + if ((await this.userById(key.userId))?.apiKey !== previous) + throw new ConflictException( + 'This key has changed. Refresh and try again.', + ) + await this.insert(key) + try { + await this.clearLegacy(key.userId) + } catch (error) { + await this.remove(key.userId, key.id) + throw error + } + } finally { + await redis.eval( + 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end', + 1, + lock, + key.id, + ) + } + } + async ownedProjects(userId: string): Promise<{ id: string; name: string }[]> { + return this.query( + 'SELECT id, name FROM project WHERE adminId = {userId:String} ORDER BY name', + { userId }, + ) + } + async projectUsers( + projectId: string, + ): Promise< + { id: string; email: string; apiKey: string | null; canWrite?: number }[] + > { + return this.query( + `SELECT id, email, apiKey, + (id IN (SELECT adminId FROM project WHERE id = {projectId:String}) OR id IN (SELECT userId FROM project_share WHERE projectId = {projectId:String} AND confirmed = 1 AND role = 'admin')) AS canWrite + FROM user WHERE id IN ( + SELECT adminId FROM project WHERE id = {projectId:String} + UNION DISTINCT SELECT userId FROM project_share WHERE projectId = {projectId:String} AND confirmed = 1 + )`, + { projectId }, + ) + } + async resourceProject(kind: string, id: string): Promise { + if (kind === 'export') { + const raw = await redis.get(`session-replay:export:${id}`) + return raw ? JSON.parse(raw).pid : undefined + } + const table = + kind === 'goal' ? 'goal' : kind === 'flag' ? 'feature_flag' : null + if (!table) return undefined + return ( + await this.query( + `SELECT projectId FROM ${table} WHERE id = {id:String}`, + { id }, + ) + )[0]?.projectId + } +} diff --git a/backend/apps/community/src/api-key/api-key.types.ts b/backend/apps/community/src/api-key/api-key.types.ts new file mode 100644 index 000000000..a03f200b3 --- /dev/null +++ b/backend/apps/community/src/api-key/api-key.types.ts @@ -0,0 +1,47 @@ +export const API_KEY_SCOPES = { + analytics: ['read'], + events: ['read', 'write'], + projects: ['read', 'write'], + funnels: ['read', 'write'], + annotations: ['read', 'write'], + errors: ['read', 'write'], + replays: ['read', 'write'], + goals: ['read'], + flags: ['read'], +} as const + +export type ApiKeyScope = `${keyof typeof API_KEY_SCOPES}:${'read' | 'write'}` +export interface ApiKeyRecord { + id: string + userId: string + name: string + keyHash: string + encryptedKey: string + keyPreview: string + scopes: ApiKeyScope[] + projectIds: string[] + allProjects: boolean + unrestricted: boolean + created: string | null + rotated: string | null +} +export type ApiKeyPrincipal = Pick< + ApiKeyRecord, + 'id' | 'scopes' | 'projectIds' | 'allProjects' | 'unrestricted' +> +export const API_KEY_ACCESS = 'apiKeyAccess' +export interface ApiKeyPolicy { + scope: ApiKeyScope + requires?: ApiKeyScope[] + source: + | 'params' + | 'query' + | 'body' + | 'list' + | 'account' + | 'create' + | 'goal' + | 'flag' + | 'export' + field?: string +} diff --git a/backend/apps/community/src/app.module.ts b/backend/apps/community/src/app.module.ts index ffb83473a..e63032f3d 100644 --- a/backend/apps/community/src/app.module.ts +++ b/backend/apps/community/src/app.module.ts @@ -1,3 +1,4 @@ +import { ApiKeyModule } from './api-key/api-key.module' import { Module } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' import { ScheduleModule } from '@nestjs/schedule' @@ -82,6 +83,7 @@ const modules = [ FeatureFlagModule, CaptchaModule, AuthModule, + ApiKeyModule, TwoFactorAuthModule, DataImportModule, ] diff --git a/backend/apps/community/src/auth/decorators/auth.decorator.ts b/backend/apps/community/src/auth/decorators/auth.decorator.ts index 62796598e..ffef91a4d 100644 --- a/backend/apps/community/src/auth/decorators/auth.decorator.ts +++ b/backend/apps/community/src/auth/decorators/auth.decorator.ts @@ -1,3 +1,4 @@ +import { ApiKeyGuard } from '../../api-key/api-key.guard' import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common' import { JwtAccessTokenGuard, @@ -10,7 +11,7 @@ export function Auth(isApiKeyAuth = false, isOptionalAuth = false) { return applyDecorators( SetMetadata(IS_OPTIONAL_AUTH_KEY, isOptionalAuth), isApiKeyAuth - ? UseGuards(MultiAuthGuard, AuthenticationGuard) + ? UseGuards(MultiAuthGuard, ApiKeyGuard, AuthenticationGuard) : UseGuards(JwtAccessTokenGuard, AuthenticationGuard), ) } diff --git a/backend/apps/community/src/auth/guards/authentication.guard.ts b/backend/apps/community/src/auth/guards/authentication.guard.ts index b4a71c7cd..dbeddee0f 100644 --- a/backend/apps/community/src/auth/guards/authentication.guard.ts +++ b/backend/apps/community/src/auth/guards/authentication.guard.ts @@ -19,7 +19,7 @@ export class AuthenticationGuard implements CanActivate { return true } - if (!user) return true + if (!user || user.apiKeyAccess) return true if ( user?.isTwoFactorAuthenticationEnabled && diff --git a/backend/apps/community/src/auth/guards/multi-auth.guard.spec.ts b/backend/apps/community/src/auth/guards/multi-auth.guard.spec.ts index 0e2bac760..35c4376b9 100644 --- a/backend/apps/community/src/auth/guards/multi-auth.guard.spec.ts +++ b/backend/apps/community/src/auth/guards/multi-auth.guard.spec.ts @@ -1,3 +1,4 @@ +import passport from 'passport' import { ExecutionContext, UnauthorizedException } from '@nestjs/common' import { Reflector } from '@nestjs/core' @@ -64,3 +65,32 @@ describe('MultiAuthGuard', () => { ).toBe(user) }) }) + +describe('API key precedence', () => { + it('rejects an invalid key even when the session strategy would succeed', async () => { + passport.use('api-key', { + authenticate() { + this.fail() + }, + } as any) + passport.use('jwt-access-token', { + authenticate() { + this.success({ id: 'session-user' }) + }, + } as any) + const request = { + headers: { 'x-api-key': 'invalid' }, + cookies: { token: 'valid-session' }, + } + const context = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => ({}), + }), + } as ExecutionContext + const guard = new MultiAuthGuard({ getAllAndOverride: () => false } as any) + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ) + }) +}) diff --git a/backend/apps/community/src/auth/guards/multi-auth.guard.ts b/backend/apps/community/src/auth/guards/multi-auth.guard.ts index 70de2cf8f..9cebe78cb 100644 --- a/backend/apps/community/src/auth/guards/multi-auth.guard.ts +++ b/backend/apps/community/src/auth/guards/multi-auth.guard.ts @@ -9,6 +9,14 @@ export class MultiAuthGuard extends AuthGuard(['jwt-access-token', 'api-key']) { super() } + canActivate(context: ExecutionContext) { + if (context.switchToHttp().getRequest().headers?.['x-api-key']) { + const KeyGuard = AuthGuard('api-key') + return new KeyGuard().canActivate(context) + } + return super.canActivate(context) + } + handleRequest( err: any, user: any, diff --git a/backend/apps/community/src/auth/strategies/api-key.strategy.ts b/backend/apps/community/src/auth/strategies/api-key.strategy.ts index 83dacf5e2..d78e34f56 100644 --- a/backend/apps/community/src/auth/strategies/api-key.strategy.ts +++ b/backend/apps/community/src/auth/strategies/api-key.strategy.ts @@ -1,30 +1,18 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common' +import { Injectable } from '@nestjs/common' import { PassportStrategy } from '@nestjs/passport' import { HeaderAPIKeyStrategy } from 'passport-headerapikey' -import { UserService } from '../../user/user.service' +import { ApiKeyService } from '../../api-key/api-key.service' @Injectable() export class ApiKeyStrategy extends PassportStrategy( HeaderAPIKeyStrategy, 'api-key', ) { - constructor(private readonly userService: UserService) { - super( - { header: 'X-Api-Key', prefix: '' }, - true, - // TODO: What the fuck is this? As of Nest V 11 - this code should not work. - // Test is later on. https://github.com/nestjs/passport/pull/1439 - // @ts-expect-error - async (apiKey: string, done: (err: any, user: any) => void) => { - return this.validate(apiKey, done) - }, - ) + constructor(private readonly keys: ApiKeyService) { + super({ header: 'X-Api-Key', prefix: '' }, false) } - async validate(apiKey: string, done: (err: any, user: any) => void) { - const user = await this.userService.findOne({ apiKey }) - if (!user) return done(new UnauthorizedException(), false) - - return done(null, user) + validate(apiKey: string) { + return this.keys.authenticate(apiKey) } } diff --git a/backend/apps/community/src/common/utils.ts b/backend/apps/community/src/common/utils.ts index a8aeaebb6..17e719020 100644 --- a/backend/apps/community/src/common/utils.ts +++ b/backend/apps/community/src/common/utils.ts @@ -85,7 +85,12 @@ export const hash = (content: string): string => { * Derives a key from the SECRET_KEY_BASE environment variable using the HKDF algorithm. */ export const deriveKey = ( - purpose: 'refresh-token' | 'access-token' | 'gsc-token' | 'ga4-token', + purpose: + | 'refresh-token' + | 'access-token' + | 'gsc-token' + | 'ga4-token' + | 'api-key', length = 32, ) => { const base = Buffer.from(process.env.SECRET_KEY_BASE || '', 'utf8') diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index 06a3f4373..2997e05cf 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import { Controller, Get, @@ -237,7 +238,8 @@ export class FeatureFlagController { @ApiBearerAuth() @Get('/project/:projectId') - @Auth(false, true) + @Auth(true, true) + @ApiKeyAccess('flags:read', 'params', 'projectId') @ApiResponse({ status: 200, type: [FeatureFlagDto] }) @ApiOperation({ summary: 'Get all feature flags for a project' }) async getProjectFeatureFlags( @@ -285,7 +287,8 @@ export class FeatureFlagController { @ApiBearerAuth() @Get('/:flagId') - @Auth(false, true) + @Auth(true, true) + @ApiKeyAccess('flags:read', 'flag', 'flagId') @ApiResponse({ status: 200, type: FeatureFlagDto }) @ApiOperation({ summary: 'Get a feature flag by ID' }) async getFeatureFlag( @@ -821,7 +824,8 @@ export class FeatureFlagController { @ApiBearerAuth() @Get('/:id/stats') - @Auth(false, true) + @Auth(true, true) + @ApiKeyAccess('flags:read', 'flag', 'id') @ApiResponse({ status: 200, type: FeatureFlagStatsDto }) @ApiOperation({ summary: 'Get statistics for a feature flag' }) async getFeatureFlagStats( @@ -923,7 +927,8 @@ export class FeatureFlagController { @ApiBearerAuth() @Get('/:id/profiles') - @Auth(false, true) + @Auth(true, true) + @ApiKeyAccess('flags:read', 'flag', 'id') @ApiResponse({ status: 200, type: FeatureFlagProfilesResponseDto }) @ApiOperation({ summary: 'Get profiles who have evaluated a feature flag', diff --git a/backend/apps/community/src/goal/goal.controller.ts b/backend/apps/community/src/goal/goal.controller.ts index 6bb526c67..6308e5b5a 100644 --- a/backend/apps/community/src/goal/goal.controller.ts +++ b/backend/apps/community/src/goal/goal.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import { Controller, Get, @@ -125,7 +126,8 @@ export class GoalController { ) {} @Get('/:goalId') - @Auth(false, true) + @Auth(true, true) + @ApiKeyAccess('goals:read', 'goal', 'goalId') async getGoal( @CurrentUserId() userId: string, @Param('goalId') goalId: string, @@ -150,7 +152,8 @@ export class GoalController { } @Get('/project/:projectId') - @Auth(false, true) + @Auth(true, true) + @ApiKeyAccess('goals:read', 'params', 'projectId') async getProjectGoals( @CurrentUserId() userId: string, @Param('projectId') projectId: string, @@ -773,7 +776,8 @@ export class GoalController { } @Get('/:id/stats') - @Auth() + @Auth(true) + @ApiKeyAccess('goals:read', 'goal', 'id') async getGoalStats( @CurrentUserId() userId: string, @Param('id') id: string, @@ -925,7 +929,13 @@ export class GoalController { } @Get('/:id/sessions') - @Auth() + @Auth(true) + @ApiKeyAccess('goals:read', 'goal', 'id', [ + 'analytics:read', + 'events:read', + 'errors:read', + 'replays:read', + ]) async getGoalSessions( @CurrentUserId() userId: string, @Param('id') id: string, @@ -1066,7 +1076,8 @@ export class GoalController { } @Get('/:id/chart') - @Auth() + @Auth(true) + @ApiKeyAccess('goals:read', 'goal', 'id') async getGoalChart( @CurrentUserId() userId: string, @Param('id') id: string, diff --git a/backend/apps/community/src/project/project.controller.ts b/backend/apps/community/src/project/project.controller.ts index e6bba1d12..f3cbbee05 100644 --- a/backend/apps/community/src/project/project.controller.ts +++ b/backend/apps/community/src/project/project.controller.ts @@ -1,3 +1,5 @@ +import { Req } from '@nestjs/common' +import { ApiKeyAccess } from '../api-key/api-key-access.decorator' import { Controller, Body, @@ -134,7 +136,9 @@ export class ProjectController { }) @ApiResponse({ status: 200, type: [Project] }) @Auth(true) + @ApiKeyAccess('projects:read', 'list', 'pid', ['funnels:read']) async get( + @Req() request: { apiKeyProjectIds?: string[] }, @CurrentUserId() userId: string, @Query('take', new ParseIntPipe({ optional: true })) take?: number, @Query('skip', new ParseIntPipe({ optional: true })) skip?: number, @@ -199,6 +203,10 @@ export class ProjectController { }) let combinedProjects = _map(combinedProjectsMap, (p) => p) + if (request.apiKeyProjectIds) + combinedProjects = combinedProjects.filter((project) => + request.apiKeyProjectIds.includes(project.id), + ) // Get pinned project IDs to mark shared projects as pinned too const pinnedProjectIds = await getPinnedProjectsClickhouse(userId) @@ -327,6 +335,7 @@ export class ProjectController { @ApiOperation({ summary: 'Pin a project to the top of the dashboard' }) @ApiResponse({ status: 200, description: 'Project pinned successfully' }) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') async pinProject( @Param('id') projectId: string, @CurrentUserId() userId: string, @@ -353,6 +362,7 @@ export class ProjectController { @ApiOperation({ summary: 'Unpin a project from the top of the dashboard' }) @ApiResponse({ status: 200, description: 'Project unpinned successfully' }) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') async unpinProject( @Param('id') projectId: string, @CurrentUserId() userId: string, @@ -526,6 +536,7 @@ export class ProjectController { @Post('/') @ApiResponse({ status: 201, type: Project }) @Auth(true) + @ApiKeyAccess('projects:write', 'create', 'pid') async create( @Body() projectDTO: CreateProjectDTO, @CurrentUserId() userId: string, @@ -558,6 +569,7 @@ export class ProjectController { @Delete('/reset/:id') @HttpCode(204) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') @ApiResponse({ status: 204, description: 'Empty body' }) async reset( @Param('id') id: string, @@ -657,6 +669,7 @@ export class ProjectController { @Post('/funnel') @ApiResponse({ status: 201 }) @Auth(true) + @ApiKeyAccess('funnels:write', 'body', 'pid') async createFunnel( @Body() funnelDTO: FunnelCreateDTO, @CurrentUserId() userId: string, @@ -697,6 +710,7 @@ export class ProjectController { @Patch('/funnel') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('funnels:write', 'body', 'pid') async updateFunnel( @Body() funnelDTO: FunnelUpdateDTO, @CurrentUserId() userId: string, @@ -735,6 +749,7 @@ export class ProjectController { @Delete('/funnel/:id/:pid') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('funnels:write', 'params', 'pid') async deleteFunnel( @Param('id') id: string, @Param('pid') pid: string, @@ -766,6 +781,7 @@ export class ProjectController { @Get('/funnels/:pid') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('funnels:read', 'params', 'pid') async getFunnels( @Param('pid') pid: string, @CurrentUserId() userId: string, @@ -793,6 +809,7 @@ export class ProjectController { @Post('/annotation') @ApiResponse({ status: 201 }) @Auth(true) + @ApiKeyAccess('annotations:write', 'body', 'pid') async createAnnotation( @Body() annotationDTO: AnnotationCreateDTO, @CurrentUserId() userId: string, @@ -826,6 +843,7 @@ export class ProjectController { @Patch('/annotation') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('annotations:write', 'body', 'pid') async updateAnnotation( @Body() annotationDTO: AnnotationUpdateDTO, @CurrentUserId() userId: string, @@ -863,6 +881,7 @@ export class ProjectController { @Delete('/annotation/:id/:pid') @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('annotations:write', 'params', 'pid') async deleteAnnotation( @Param('id') id: string, @Param('pid') pid: string, @@ -894,6 +913,7 @@ export class ProjectController { @Get('/annotations/:pid') @ApiResponse({ status: 200 }) @Auth(true, true) + @ApiKeyAccess('annotations:read', 'params', 'pid') async getAnnotations( @Param('pid') pid: string, @CurrentUserId() userId: string, @@ -914,6 +934,7 @@ export class ProjectController { @Get('password/:projectId') @Auth(true, true) + @ApiKeyAccess('projects:read', 'params', 'projectId') @ApiResponse({ status: 200, type: Project }) async checkPassword( @Param('projectId') projectId: string, @@ -954,6 +975,7 @@ export class ProjectController { }) @ApiResponse({ status: 200 }) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'pid') async deletePartially( @Param('pid') pid: string, @Query('from') from: string, @@ -996,6 +1018,7 @@ export class ProjectController { @Delete('/:id') @HttpCode(204) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') @ApiResponse({ status: 204, description: 'Empty body' }) async delete( @Param('id') id: string, @@ -1051,6 +1074,7 @@ export class ProjectController { @Put('/:id') @HttpCode(200) @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'id') @ApiResponse({ status: 200, type: Project }) async update( @Param('id') id: string, @@ -1199,6 +1223,7 @@ export class ProjectController { @Get('/:id') @Auth(true, true) + @ApiKeyAccess('projects:read', 'params', 'id', ['funnels:read']) @ApiResponse({ status: 200, type: Project }) async getOne( @Param('id') id: string, @@ -1313,6 +1338,7 @@ export class ProjectController { @ApiBearerAuth() @Get(':projectId/views/:viewId') @Auth(true, true) + @ApiKeyAccess('projects:read', 'params', 'projectId') async getProjectView( @Param() params: ProjectViewIdsDto, @CurrentUserId() userId: string, @@ -1334,6 +1360,7 @@ export class ProjectController { @ApiBearerAuth() @Post(':projectId/views') @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'projectId') async createProjectView( @Param() params: ProjectIdDto, @Body() body: CreateProjectViewDto, @@ -1387,6 +1414,7 @@ export class ProjectController { @ApiBearerAuth() @Get(':projectId/views') @Auth(true, true) + @ApiKeyAccess('projects:read', 'params', 'projectId') async getProjectViews( @Param() params: ProjectIdDto, @CurrentUserId() userId: string, @@ -1410,6 +1438,7 @@ export class ProjectController { @ApiBearerAuth() @Patch(':projectId/views/:viewId') @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'projectId') async updateProjectView( @Param() params: ProjectViewIdsDto, @Body() body: UpdateProjectViewDto, @@ -1495,6 +1524,7 @@ export class ProjectController { @HttpCode(HttpStatus.NO_CONTENT) @Delete(':projectId/views/:viewId') @Auth(true) + @ApiKeyAccess('projects:write', 'params', 'projectId') async deleteProjectView( @Param() params: ProjectViewIdsDto, @CurrentUserId() userId: string, diff --git a/backend/apps/community/src/user/user.controller.ts b/backend/apps/community/src/user/user.controller.ts index 21223829f..a2425aace 100644 --- a/backend/apps/community/src/user/user.controller.ts +++ b/backend/apps/community/src/user/user.controller.ts @@ -1,3 +1,4 @@ +import { ApiKeyService } from '../api-key/api-key.service' import { Controller, Get, @@ -5,8 +6,6 @@ import { Body, BadRequestException, Post, - Headers, - Ip, ConflictException, Delete, Param, @@ -32,7 +31,6 @@ import { deleteAllRefreshTokensClickhouse, deleteProjectsByUserIdClickhouse, deleteProjectSharesByUserIdClickhouse, - getIPFromHeaders, getProjectsClickhouse, } from '../common/utils' import { @@ -57,6 +55,7 @@ import { Auth } from '../auth/decorators' @Auth() export class UserController { constructor( + private readonly apiKeys: ApiKeyService, private readonly logger: AppLoggerService, private readonly userService: UserService, private readonly mailerService: MailerService, @@ -291,32 +290,10 @@ export class UserController { @ApiBearerAuth() @Post('api-key') - async generateApiKey( - @CurrentUserId() userId: string, - @Headers() headers, - @Ip() reqIP, - ) { - this.logger.log({ userId }, 'POST /user/api-key') - - const ip = getIPFromHeaders(headers) || reqIP || '' - - await checkRateLimit(ip, 'generate-api-key', 5, 3600) - - const user = await this.userService.findOne({ id: userId }) - - if (!user) { - throw new BadRequestException('User not found') - } - - if (!_isNull(user.apiKey)) { - throw new ConflictException('You already have an API key') - } - - const apiKey: string = randomUUID() - - await this.userService.update(userId, { apiKey }) - - return { apiKey } + async generateApiKey() { + throw new BadRequestException( + 'Create scoped API keys through /user/api-keys', + ) } @ApiBearerAuth() @@ -334,7 +311,7 @@ export class UserController { throw new ConflictException("You don't have an API key") } - await this.userService.update(userId, { apiKey: null }) + await this.apiKeys.remove(userId, 'legacy') } @ApiBearerAuth() diff --git a/backend/apps/community/src/user/user.service.ts b/backend/apps/community/src/user/user.service.ts index c7c63c8d3..4f3585a9e 100644 --- a/backend/apps/community/src/user/user.service.ts +++ b/backend/apps/community/src/user/user.service.ts @@ -133,6 +133,11 @@ export class UserService { } async delete(userId: string) { + await clickhouse.command({ + query: 'ALTER TABLE api_key DELETE WHERE userId = {userId:String}', + query_params: { userId }, + clickhouse_settings: { mutations_sync: '2' }, + }) await clickhouse.command({ query: `ALTER TABLE user DELETE WHERE id = {id:FixedString(36)}`, query_params: { id: userId }, @@ -180,6 +185,8 @@ export class UserService { omitSensitiveData(user: Partial): Partial { return _omit(user, [ 'password', + 'apiKey', + 'apiKeyAccess', 'twoFactorAuthenticationSecret', 'twoFactorRecoveryCode', ]) diff --git a/backend/jest.api-keys.config.js b/backend/jest.api-keys.config.js new file mode 100644 index 000000000..16eda0161 --- /dev/null +++ b/backend/jest.api-keys.config.js @@ -0,0 +1,9 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/apps/cloud/src', '/apps/community/src'], + testMatch: [ + '**/api-key/*.spec.ts', + '**/auth/guards/multi-auth.guard.spec.ts', + ], +} diff --git a/backend/migrations/clickhouse/initialise_selfhosted.js b/backend/migrations/clickhouse/initialise_selfhosted.js index 0364f9dda..aac09b5c4 100644 --- a/backend/migrations/clickhouse/initialise_selfhosted.js +++ b/backend/migrations/clickhouse/initialise_selfhosted.js @@ -3,6 +3,13 @@ const { queriesRunner, dbName } = require('./setup') const { initialiseDatabase } = require('./initialise_database') const CLICKHOUSE_INIT_QUERIES = [ + `CREATE TABLE IF NOT EXISTS ${dbName}.api_key ( + id String, userId String, name String, keyHash String, + encryptedKey String, keyPreview String, scopes String, projectIds String, + allProjects UInt8 DEFAULT 0, unrestricted UInt8 DEFAULT 0, + created Nullable(String), rotated Nullable(String) + ) ENGINE = MergeTree() ORDER BY (userId, id)`, + `CREATE TABLE IF NOT EXISTS ${dbName}.session_replay_chunks ( pid FixedString(12), diff --git a/backend/migrations/clickhouse/selfhosted_2026_10_01_api_keys.js b/backend/migrations/clickhouse/selfhosted_2026_10_01_api_keys.js new file mode 100644 index 000000000..3e5108090 --- /dev/null +++ b/backend/migrations/clickhouse/selfhosted_2026_10_01_api_keys.js @@ -0,0 +1,10 @@ +const { queriesRunner, dbName } = require('./setup') + +queriesRunner([ + `CREATE TABLE IF NOT EXISTS ${dbName}.api_key ( + id String, userId String, name String, keyHash String, + encryptedKey String, keyPreview String, scopes String, projectIds String, + allProjects UInt8 DEFAULT 0, unrestricted UInt8 DEFAULT 0, + created Nullable(String), rotated Nullable(String) + ) ENGINE = MergeTree() ORDER BY (userId, id)`, +]) diff --git a/backend/migrations/mysql/2026_10_01_api_keys.sql b/backend/migrations/mysql/2026_10_01_api_keys.sql new file mode 100644 index 000000000..954ea5c25 --- /dev/null +++ b/backend/migrations/mysql/2026_10_01_api_keys.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS `api_key` ( + `id` varchar(36) NOT NULL, + `userId` varchar(36) NOT NULL, + `name` varchar(80) NOT NULL, + `keyHash` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `encryptedKey` text NOT NULL, + `keyPreview` varchar(32) NOT NULL, + `scopes` json NOT NULL, + `projectIds` json NOT NULL, + `allProjects` boolean NOT NULL DEFAULT false, + `unrestricted` boolean NOT NULL DEFAULT false, + `created` varchar(30) DEFAULT NULL, + `rotated` varchar(30) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `IDX_api_key_hash` (`keyHash`), + KEY `IDX_api_key_user` (`userId`), + CONSTRAINT `FK_api_key_user` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/docs/content/docs/accountsettings/api-keys.mdx b/docs/content/docs/accountsettings/api-keys.mdx index fd504ea33..50132e75f 100644 --- a/docs/content/docs/accountsettings/api-keys.mdx +++ b/docs/content/docs/accountsettings/api-keys.mdx @@ -1,46 +1,86 @@ --- title: API keys slug: /settings/api-keys -description: "Create and manage Swetrix API keys to access analytics data and integrate your account with scripts, dashboards, and other tools." +description: "Create scoped Swetrix API keys, choose project access, and manage permissions, rotation, and revocation." --- -API keys allow you to authenticate with the Swetrix API programmatically. You can use them to retrieve statistics, manage your account, and more. +API keys connect scripts, dashboards, and integrations to Swetrix. Create a separate key for each integration and grant only the permissions it needs. -## Creating an API key +## Create a key -To create a new API key: +1. Open **Account Settings → Account → API keys**. +2. Click **Create API key** to open the editor modal and give it a descriptive name. +3. Select individual projects, or enable **All projects**. Selected projects must belong to your account. All projects includes projects you own now and those you create later. +4. Set each permission group to **No access**, **Read only**, or **Read & write**. Read-only resources have no write option. +5. Create the key, reveal it with the eye button, and copy it into your integration. -1. Go to your **[Account Settings](https://swetrix.com/user-settings)**. -2. Open the **Account** tab. -3. Scroll down to the **API key** section. -4. Click the **Generate API key** button. - -Your new API key will be displayed. +New keys start with `swx_`. Nothing is granted until you choose permissions. A key cannot access a selected project after you transfer ownership away from your account. -Treat API keys like passwords. Keep them secret, do not hardcode them, and do not share them. - + Keep keys in a server-side secret store or environment variable. Never put an account API key in + browser tracking code or a public repository. -## Deleting an API key +## Permissions + +| Group | Read only | Read & write | +| --------------------- | --------------------------------------------------------------------- | ------------------------------------------------- | +| Analytics | Traffic, performance, sessions, profiles, SEO, and CAPTCHA statistics | Not available | +| Events | Custom events and their metadata | Also send tracking events | +| Project settings | Project lists, settings, and saved views | Also create, update, or delete projects and views | +| Funnels | Saved funnels and funnel analysis | Also manage saved funnels | +| Annotations | Timeline annotations | Also create, edit, or delete annotations | +| Errors | Error reports and affected sessions | Also send errors and update error status | +| Session replays | View and export recordings | Also record and delete replays | +| Goals | Goals, conversion statistics, and associated sessions | Not available | +| Feature flags | Configuration, statistics, and evaluations | Not available | +| Revenue (Cloud) | Revenue statistics | Also record transactions | +| Organisations (Cloud) | Organisations and members | Also manage organisations and members | + +Project creation requires **All projects** and **Project settings: Read & write**. Organisation permissions apply account-wide and also require **All projects**. Read & write includes read access. + +Some responses combine resources and require multiple read permissions: + +- The v1 `/log` response includes custom events, so it needs **Analytics** and **Events**. `/log/chart` and the v2 traffic summary work with **Analytics** alone. +- Session lists and details (including goal sessions) need **Analytics**, **Events**, **Errors**, and **Session replays**, plus **Revenue** on Cloud. +- Profile lists and sessions need **Analytics**, **Events**, and **Errors**. Profile details also need **Revenue** on Cloud. +- Project lists and details include saved funnels, so they need **Project settings** and **Funnels**. +- Funnel and journey session lists also need **Analytics**, **Events**, and **Errors**. Error session lists also need **Analytics**. +- Queries that filter on custom events or event metadata also require **Events**. + +Permissions apply to API-enabled endpoints. Account credentials, billing, and API-key management still require a signed-in session. Public tracking endpoints continue to accept ordinary tracker requests without a key; when a key is supplied, its permissions and project access are checked. A key does not grant any rights beyond those of its account. + +## View and edit access -If your API key is compromised or you no longer need it, you can delete it: +The API keys list shows names, creation dates, project access, and permissions. Secrets are masked. Use the eye button to reveal a key, then copy it. Revealed list entries hide again after a minute or when you leave the browser tab. -1. Navigate to the **API key** section in your [Account Settings](https://swetrix.com/user-settings). -2. Click the **Delete API key** button. -3. Confirm the deletion in the modal window. +Choose **Edit permissions** to open the editor modal and change a key's name, projects, or permissions without changing its secret. Save your changes, or cancel to discard them. -Delete API key +Project owners can open **Project Settings → Access → API keys** to see keys with access to that project, including existing unrestricted keys belonging to collaborators. This view shows metadata only; another person's key cannot be revealed, rotated, or revoked there. -Once deleted, any applications or scripts using this key will no longer be able to access the API. +## Rotate or revoke a key -## Using the API key +**Rotate key** creates a replacement secret with the same permissions and project access. The old secret stops working immediately. Update every integration using it. -You can use your API key to authenticate requests to the Swetrix API. For more information on available endpoints and how to use them, please refer to the [Statistics API reference](/statistics-api). +For a deployment without interruption, create a second key with the required permissions, switch your integration, then revoke the old key. -Example usage: +**Revoke** permanently removes the key and immediately stops future authenticated requests. Requests already authorised may finish. + +## Existing keys + +Keys created before scoped API keys retain their original unrestricted access. Their UUID format remains valid. They appear as **Unrestricted**, and their creation date is shown as predating scoped keys because that date was not recorded. + +Rotating an existing key gives it the `swx_` prefix while retaining unrestricted access. Editing its permissions replaces unrestricted access with your chosen scopes and projects, without changing its secret. + +## Use a key + +Send the key in the `X-Api-Key` header. For example, a key with **Analytics: Read only** and access to the requested project can fetch traffic statistics: ```bash -curl 'https://api.swetrix.com/v1/log?pid=YOUR_PROJECT_ID&timeBucket=day&period=7d' \ - -H "X-Api-Key: YOUR_API_KEY" +curl 'https://api.swetrix.com/v1/log/chart?pid=YOUR_PROJECT_ID&timeBucket=day&period=7d' \ + -H "X-Api-Key: $SWETRIX_API_KEY" ``` + +Invalid or revoked credentials return `401`. A valid key without the required permission or project access returns `403`. Multi-project requests require access to every requested project. Cloud keys share the account's API rate limit. + +See the [Statistics API reference](/statistics-api) for available endpoints. diff --git a/docs/content/docs/analytics-dashboard/revenue-tracking.mdx b/docs/content/docs/analytics-dashboard/revenue-tracking.mdx index e8a16b3be..76dc5ed1f 100644 --- a/docs/content/docs/analytics-dashboard/revenue-tracking.mdx +++ b/docs/content/docs/analytics-dashboard/revenue-tracking.mdx @@ -60,7 +60,7 @@ Use this option to track revenue from any platform — JTL Shop, Shopify, WooCom ### Get your API key - Open your [account settings](https://swetrix.com/user-settings) and copy your personal API key. It's the same key used for the [Statistics API](/statistics-api). + Open your [account settings](https://swetrix.com/user-settings) and create an API key with **Revenue: Read & write** and access to the project you want to track. See [API keys](/settings/api-keys) for permission and rotation details. Treat the key like a password — never embed it in client-side code. The endpoint must be called from your backend. diff --git a/docs/content/docs/api/stats.mdx b/docs/content/docs/api/stats.mdx index f8c52927a..ff336203b 100644 --- a/docs/content/docs/api/stats.mdx +++ b/docs/content/docs/api/stats.mdx @@ -10,7 +10,7 @@ Swetrix provides the ability to retrieve aggregated data not only through the UI The statistics API provides several GET endpoints. These endpoints require some query parameters and return standard HTTP responses and a JSON-encoded body. API requests without a valid API key will fail (except for the publicly available API endpoints). -Each request must be authenticated with an API key using `X-Api-Key` HTTP header. You can obtain an API key in your Swetrix [account settings](https://swetrix.com/user-settings). +Each request must be authenticated with an API key using `X-Api-Key` HTTP header. You can obtain an API key in your Swetrix [account settings](https://swetrix.com/user-settings). Keys must have the permission for the requested resource and access to every requested project. See [API key permissions](/settings/api-keys) for the scope groups and project restrictions. Rate limit for the API depends on your plan. You can find more information on the billing page. The current rate limits are: diff --git a/docs/content/docs/selfhosting/configuring.mdx b/docs/content/docs/selfhosting/configuring.mdx index a6a3e3c58..833f689cd 100644 --- a/docs/content/docs/selfhosting/configuring.mdx +++ b/docs/content/docs/selfhosting/configuring.mdx @@ -57,6 +57,12 @@ Swetrix will send transactional emails for some actions, like password reset or If you don't have an SMTP provider, some regular email providers offer this functionality. For example, you can [configure Gmail for app specific passwords](https://support.google.com/mail/answer/185833) and use them as SMTP credentials (in this case, the `SMTP_HOST` should be `smtp.gmail.com` with port `587`). +### API key encryption + +`SECRET_KEY_BASE` variable is required for encrypting API keys at rest. Set it before creating keys and keep it stable across all API instances and backups. Changing the effective encryption secret prevents revealing previously stored keys; authentication still works because it uses a separate SHA-256 digest. Rotate affected keys to create secrets encrypted with the new configuration. + +Existing UUID keys remain valid. The self-hosted database initializer creates the `api_key` table automatically. If you manage schema upgrades manually, apply `backend/migrations/clickhouse/selfhosted_2026_10_01_api_keys.js` before starting the updated API. Cloud installations use `backend/migrations/mysql/2026_10_01_api_keys.sql`. + ### Google Search Console integration To let users connect their Google Search Console account from project settings (and populate the SEO dashboard), the API needs an OAuth client created in your own Google Cloud project. The full step-by-step guide lives in [Google Search Console](/selfhosting/google-search-console); the relevant variables are: diff --git a/web/app/components/ApiKeys/ApiKeys.tsx b/web/app/components/ApiKeys/ApiKeys.tsx new file mode 100644 index 000000000..4558bced5 --- /dev/null +++ b/web/app/components/ApiKeys/ApiKeys.tsx @@ -0,0 +1,697 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + CopyIcon, + EyeIcon, + EyeSlashIcon, + KeyIcon, + PlusIcon, +} from '@phosphor-icons/react' +import { toast } from 'sonner' +import type { ApiKey, ApiKeyInput, ApiKeyList } from '~/lib/models/ApiKey' +import ProjectAccessSelector from '~/components/ProjectAccessSelector' +import { DOCS_URL } from '~/lib/constants' +import Button from '~/ui/Button' +import Input from '~/ui/Input' +import Modal from '~/ui/Modal' +import { Link } from '~/ui/Link' +import { Text } from '~/ui/Text' +import Tooltip from '~/ui/Tooltip' + +const requestKeys = async ( + operation?: string, + id?: string, + input?: ApiKeyInput, + projectId?: string, +): Promise => { + const response = await fetch( + `/api/api-keys${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ''}`, + { + method: operation ? 'POST' : 'GET', + headers: operation ? { 'Content-Type': 'application/json' } : undefined, + body: operation ? JSON.stringify({ operation, id, input }) : undefined, + cache: 'no-store', + }, + ) + const result = await response.json() + if (!response.ok || result?.error) + throw new Error( + Array.isArray(result?.error) + ? result.error.join(', ') + : result?.error || 'Request failed', + ) + return result +} + +const emptyInput = (): ApiKeyInput => ({ + name: '', + scopes: [], + allProjects: false, + projectIds: [], +}) + +export default function ApiKeys({ projectId }: { projectId?: string }) { + const { t, i18n } = useTranslation('common') + const [data, setData] = useState(null) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [editor, setEditor] = useState(null) + const [input, setInput] = useState(emptyInput) + const [secrets, setSecrets] = useState>({}) + const [confirmation, setConfirmation] = useState<{ + operation: 'rotate' | 'delete' + key: ApiKey + } | null>(null) + const [newSecret, setNewSecret] = useState<{ + name: string + secret: string + } | null>(null) + const mounted = useRef(true) + + const load = useCallback(async () => { + try { + const result = await requestKeys( + undefined, + undefined, + undefined, + projectId, + ) + if (mounted.current) { + setData(result) + setError('') + } + } catch (reason) { + if (mounted.current) setError((reason as Error).message) + } + }, [projectId]) + + useEffect(() => { + mounted.current = true + void load() + const hide = () => { + if (document.hidden) { + setSecrets({}) + setNewSecret(null) + } + } + document.addEventListener('visibilitychange', hide) + return () => { + mounted.current = false + document.removeEventListener('visibilitychange', hide) + } + }, [load]) + + useEffect(() => { + if (!Object.keys(secrets).length) return + const timer = setTimeout(() => setSecrets({}), 60_000) + return () => clearTimeout(timer) + }, [secrets]) + + const startEdit = (key: ApiKey | 'new') => { + setEditor(key) + setInput( + key === 'new' + ? emptyInput() + : { + name: key.name, + scopes: key.scopes, + allProjects: key.allProjects, + projectIds: key.projectIds, + }, + ) + setError('') + } + + const closeEditor = () => { + if (busy) return + setEditor(null) + setError('') + } + + const canSave = + Boolean(input.name.trim()) && + input.scopes.length > 0 && + (input.allProjects || input.projectIds.length > 0) + + const mutate = async (operation: string, key?: ApiKey) => { + if (busy) return + setBusy(true) + setError('') + try { + const result = await requestKeys<{ secret?: string; key?: ApiKey }>( + operation, + key?.id, + ['create', 'update'].includes(operation) ? input : undefined, + ) + setSecrets({}) + setConfirmation(null) + setEditor(null) + if (result?.secret) + setNewSecret({ + name: result.key?.name || input.name, + secret: result.secret, + }) + toast.success(t(`apiKeys.success.${operation}`)) + await load() + } catch (reason) { + setError((reason as Error).message) + } finally { + setBusy(false) + } + } + + const reveal = async (key: ApiKey) => { + if (secrets[key.id]) { + setSecrets((current) => { + const next = { ...current } + delete next[key.id] + return next + }) + return + } + setBusy(true) + try { + const { secret } = await requestKeys<{ secret: string }>('reveal', key.id) + setSecrets((current) => ({ ...current, [key.id]: secret })) + } catch (reason) { + setError((reason as Error).message) + } finally { + setBusy(false) + } + } + + const copy = async (secret: string) => { + try { + await navigator.clipboard.writeText(secret) + toast.success(t('apiKeys.copied')) + } catch { + toast.error(t('apiKeys.copyFailed')) + } + } + + const permissionLabel = (scopes: string[], resource: string) => + scopes.includes(`${resource}:write`) + ? t('apiKeys.readWrite') + : scopes.includes(`${resource}:read`) + ? t('apiKeys.readOnly') + : t('apiKeys.none') + const formatDate = (date: string) => + new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format( + new Date(date), + ) + + return ( +
+
+
+
+ + {t('apiKeys.title')} + + + {t('apiKeys.tooltip')} + + {t('common.learnMore')} + + + } + /> +
+ + {t( + projectId ? 'apiKeys.projectDescription' : 'apiKeys.description', + )} + +
+ {!projectId ? ( + + ) : null} + {projectId ? ( + + {t('apiKeys.manage')} + + ) : null} +
+ + {error && !editor && !confirmation ? ( +
+ {error} + {!data ? ( + + ) : null} +
+ ) : null} + {!data && !error ? ( + + {t('apiKeys.loading')} +
+
+ + ) : null} + + {editor && data ? ( + + {t(editor === 'new' ? 'apiKeys.create' : 'apiKeys.save')} + + } + message={ +
{ + event.preventDefault() + event.stopPropagation() + if (!canSave) return + void mutate( + editor === 'new' ? 'create' : 'update', + editor === 'new' ? undefined : editor, + ) + }} + > + {error ? ( + + {error} + + ) : null} +
+ + setInput((current) => ({ + ...current, + name: event.target.value, + })) + } + /> + + setInput((current) => ({ + ...current, + ...access, + scopes: access.allProjects + ? current.scopes + : current.scopes.filter( + (scope) => !scope.startsWith('organisations:'), + ), + })) + } + /> +
+ + {t('apiKeys.permissions')} + + + {t('apiKeys.permissionsHint')} + + {editor !== 'new' && editor.unrestricted ? ( +

+ {t('apiKeys.restrictHint')} +

+ ) : null} +
+ {Object.entries(data.scopes).map(([scope, actions]) => ( +
+ + {t(`apiKeys.resources.${scope}.name`)} + +
+ + {t(`apiKeys.resources.${scope}.name`)} + + + {t(`apiKeys.resources.${scope}.description`)} + +
+
+ {['none', 'read', 'write'].map((access) => { + const selected = input.scopes.includes( + `${scope}:write`, + ) + ? 'write' + : input.scopes.includes(`${scope}:read`) + ? 'read' + : 'none' + const disabled = + access !== 'none' && + (!actions.includes(access) || + (scope === 'organisations' && + !input.allProjects)) + return ( + + ) + })} +
+
+ ))} +
+
+
+
+ } + /> + ) : null} + + {data && !data.keys.length && !editor ? ( +
+ + + {t('apiKeys.emptyTitle')} + + + {t(projectId ? 'apiKeys.projectEmpty' : 'apiKeys.emptyDescription')} + +
+ ) : null} + {data?.keys.length ? ( +
    + {data.keys.map((key) => ( +
  • +
    +
    +
    + + {key.name} + + {key.unrestricted ? ( + + {t('apiKeys.unrestricted')} + + ) : null} +
    + + {key.created + ? t('apiKeys.created', { date: formatDate(key.created) }) + : t('apiKeys.createdBefore')} + {key.rotated + ? ` · ${t('apiKeys.rotated', { date: formatDate(key.rotated) })}` + : ''} + + {projectId && key.owner ? ( + + {key.owner} + + ) : null} + {!projectId ? ( +
    + + {secrets[key.id] || key.keyPreview} + + + {secrets[key.id] ? ( + + ) : null} +
    + ) : null} +
    + {!projectId ? ( +
    + + + +
    + ) : null} +
    + + {key.allProjects + ? t('apiKeys.allProjects') + : key.projectIds + .map( + (id) => + data.projects.find((project) => project.id === id) + ?.name || id, + ) + .join(', ')} + +
    + + {key.unrestricted + ? t( + key.projectReadOnly + ? 'apiKeys.readOnly' + : 'apiKeys.fullAccess', + ) + : t('apiKeys.scopeCount', { + count: new Set( + key.scopes.map((scope) => scope.split(':')[0]), + ).size, + })} + + {key.unrestricted ? ( + + {t('apiKeys.unrestrictedHint')} + {key.projectReadOnly + ? ` ${t('apiKeys.projectReadOnly')}` + : ''} + + ) : ( +
    + {Object.keys(data.scopes) + .filter((scope) => + key.scopes.some((permission) => + permission.startsWith(`${scope}:`), + ), + ) + .map((scope) => ( +
    + + {t(`apiKeys.resources.${scope}.name`)} + +
    {permissionLabel(key.scopes, scope)}
    +
    + ))} +
    + )} +
    +
  • + ))} +
+ ) : null} + + { + if (!busy) setConfirmation(null) + }} + onSubmit={() => { + if (confirmation) + void mutate(confirmation.operation, confirmation.key) + }} + title={t( + confirmation?.operation === 'rotate' + ? 'apiKeys.rotateTitle' + : 'apiKeys.revokeTitle', + { name: confirmation?.key.name }, + )} + message={ + <> +

+ {t( + confirmation?.operation === 'rotate' + ? 'apiKeys.rotateDescription' + : 'apiKeys.revokeDescription', + )} +

+ {error ? ( +

+ {error} +

+ ) : null} + + } + submitText={t( + confirmation?.operation === 'rotate' + ? 'apiKeys.rotate' + : 'apiKeys.revoke', + )} + submitType='danger' + isLoading={busy} + /> + setNewSecret(null)} + title={t('apiKeys.ready', { name: newSecret?.name })} + message={ +
+

{t('apiKeys.secretHint')}

+ + +
+ } + /> +
+ ) +} diff --git a/web/app/components/ProjectAccessSelector.tsx b/web/app/components/ProjectAccessSelector.tsx new file mode 100644 index 000000000..0254181b4 --- /dev/null +++ b/web/app/components/ProjectAccessSelector.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import Checkbox from '~/ui/Checkbox' +import Input from '~/ui/Input' +import { Text } from '~/ui/Text' + +interface ProjectAccess { + allProjects: boolean + projectIds: string[] +} + +interface ProjectAccessSelectorProps { + projects: { id: string; name: string }[] + value: ProjectAccess + onChange: (value: ProjectAccess) => void + disabled?: boolean +} + +export default function ProjectAccessSelector({ + projects, + value, + onChange, + disabled, +}: ProjectAccessSelectorProps) { + const { t } = useTranslation('common') + const [search, setSearch] = useState('') + const visibleProjects = projects.filter((project) => + `${project.name} ${project.id}` + .toLowerCase() + .includes(search.toLowerCase()), + ) + + return ( +
+ {t('apiKeys.projects')} + + {t('apiKeys.projectsHint')} + + onChange({ ...value, allProjects })} + classes={{ label: 'mt-3' }} + /> + {value.allProjects ? ( + + {t('apiKeys.allProjectsHint')} + + ) : ( +
+ setSearch(event.target.value)} + /> +
+ {visibleProjects.map((project) => ( + + onChange({ + ...value, + projectIds: checked + ? [...value.projectIds, project.id] + : value.projectIds.filter((id) => id !== project.id), + }) + } + classes={{ + label: + 'px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-slate-800 [&>label]:min-w-0 [&>label]:flex-1', + }} + label={ + + {project.name} + + {project.id} + + + } + /> + ))} + {!visibleProjects.length ? ( + + {t('apiKeys.noProjects')} + + ) : null} +
+ + {t('apiKeys.projectsSelected', { count: value.projectIds.length })} + +
+ )} +
+ ) +} diff --git a/web/app/lib/models/ApiKey.ts b/web/app/lib/models/ApiKey.ts new file mode 100644 index 000000000..9ed1582b7 --- /dev/null +++ b/web/app/lib/models/ApiKey.ts @@ -0,0 +1,26 @@ +export interface ApiKey { + id: string + name: string + owner?: string + projectReadOnly?: boolean + keyPreview: string + scopes: string[] + projectIds: string[] + allProjects: boolean + unrestricted: boolean + created: string | null + rotated: string | null +} + +export interface ApiKeyList { + keys: ApiKey[] + projects: { id: string; name: string }[] + scopes: Record +} + +export interface ApiKeyInput { + name: string + scopes: string[] + allProjects: boolean + projectIds: string[] +} diff --git a/web/app/lib/models/User.ts b/web/app/lib/models/User.ts index 0c28673db..6ba10190e 100644 --- a/web/app/lib/models/User.ts +++ b/web/app/lib/models/User.ts @@ -101,7 +101,6 @@ export interface User { billingFrequency: string | null nextBillDate: string | null cancellationEffectiveDate: string | null - apiKey: string | null telegramChatId: string | null isTelegramChatIdConfirmed: boolean timeFormat: '12-hour' | '24-hour' diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index cd4cf9dcb..8a91a0703 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -1,3 +1,4 @@ +import ApiKeys from '~/components/ApiKeys/ApiKeys' import _isEmpty from 'lodash/isEmpty' import _isString from 'lodash/isString' import _join from 'lodash/join' @@ -1106,6 +1107,12 @@ const ProjectSettings = () => { ) : null} + {activeTab === 'access' && project.role === 'owner' ? ( +
+ +
+ ) : null} + {activeTab === 'shields' ? (
diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index be8d8cd76..c0e38455b 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -1,3 +1,4 @@ +import ApiKeys from '~/components/ApiKeys/ApiKeys' import cx from 'clsx' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' @@ -578,7 +579,6 @@ const UserSettings = () => { const [isSessionReplayAddonModalOpened, setIsSessionReplayAddonModalOpened] = useState(false) const [showModal, setShowModal] = useState(false) - const [showAPIDeleteModal, setShowAPIDeleteModal] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const translatedFrequencies = useMemo( () => _map(reportFrequencies, (key) => t(`profileSettings.${key}`)), @@ -614,7 +614,6 @@ const UserSettings = () => { const pendingPasswordChangeRef = useRef(false) const pendingToggles = useRef>(new Map()) - const isSubmitting = fetcher.state === 'submitting' const submittedIntent = fetcher.formData?.get('intent')?.toString() const isProfileUpdateSubmitting = fetcher.state !== 'idle' && submittedIntent === 'update-profile' @@ -982,7 +981,7 @@ const UserSettings = () => { if (!shouldHandleFetcherData(fetcher.data)) return if (fetcher.data.success) { - const { intent, user: updatedUser, apiKey } = fetcher.data + const { intent, user: updatedUser } = fetcher.data if (intent === 'update-profile' && updatedUser) { if (pendingPasswordChangeRef.current) { @@ -998,12 +997,6 @@ const UserSettings = () => { passwordChangedRef.current = false logout() } - } else if (intent === 'generate-api-key' && apiKey) { - mergeUser({ apiKey }) - toast.success(t('profileSettings.autosave.apiKeyGenerated')) - } else if (intent === 'delete-api-key') { - mergeUser({ apiKey: null }) - toast.success(t('profileSettings.autosave.apiKeyDeleted')) } else if (intent === 'toggle-live-visitors' && updatedUser) { pendingToggles.current.delete('live-visitors') mergeUser(updatedUser) @@ -1485,22 +1478,6 @@ const UserSettings = () => { fetcher.submit(formData, { method: 'post' }) } - const onApiKeyGenerate = () => { - if (isSubmitting) { - return - } - - const formData = new FormData() - formData.set('intent', 'generate-api-key') - fetcher.submit(formData, { method: 'post' }) - } - - const onApiKeyDelete = () => { - const formData = new FormData() - formData.set('intent', 'delete-api-key') - fetcher.submit(formData, { method: 'post' }) - } - const handleTimeFormatChange = ( timeFormat: NonNullable, ) => { @@ -1630,50 +1607,9 @@ const UserSettings = () => {
- - {user?.apiKey ? ( - <> - - {t('profileSettings.apiKeyWarning')} - -
- -
- - - ) : ( - <> - - {t('profileSettings.noApiKey')} - - - - )} -
+
+ +
{isSelfhosted ? ( <> @@ -3068,20 +3004,6 @@ const UserSettings = () => { } isOpened={showModal} /> - setShowAPIDeleteModal(false)} - onSubmit={() => { - setShowAPIDeleteModal(false) - onApiKeyDelete() - }} - submitText={t('profileSettings.deleteApiKeyBtn')} - closeText={t('common.close')} - title={t('profileSettings.apiKeyDelete')} - submitType='danger' - type='error' - message={t('profileSettings.apiKeyDeleteConf')} - isOpened={showAPIDeleteModal} - /> { setIsPasswordChangeModalOpened(false) diff --git a/web/app/routes/api.api-keys.ts b/web/app/routes/api.api-keys.ts new file mode 100644 index 000000000..59dbe5464 --- /dev/null +++ b/web/app/routes/api.api-keys.ts @@ -0,0 +1,74 @@ +import { + data, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router' +import { serverFetch } from '~/api/api.server' +import { + createHeadersWithCookies, + redirectIfNotAuthenticated, +} from '~/utils/session.server' + +const respond = (result: Awaited>) => { + const headers = createHeadersWithCookies(result.cookies) + headers.set('Cache-Control', 'no-store') + return data(result.error ? { error: result.error } : result.data, { + status: result.status, + headers, + }) +} + +export async function loader({ request }: LoaderFunctionArgs) { + redirectIfNotAuthenticated(request) + const projectId = new URL(request.url).searchParams.get('projectId') + return respond( + await serverFetch( + request, + `user/api-keys${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ''}`, + ), + ) +} + +export async function action({ request }: ActionFunctionArgs) { + redirectIfNotAuthenticated(request) + const origin = request.headers.get('origin') + if ( + request.headers.get('sec-fetch-site') === 'cross-site' || + (origin && origin !== new URL(request.url).origin) || + !request.headers.get('content-type')?.startsWith('application/json') + ) { + return data( + { error: 'Invalid request origin or content type' }, + { status: 403 }, + ) + } + let body + try { + body = await request.json() + } catch { + return data({ error: 'Invalid request' }, { status: 400 }) + } + const { operation, id, input } = body || {} + if ( + !['create', 'update', 'reveal', 'rotate', 'delete'].includes(operation) || + (operation !== 'create' && + (typeof id !== 'string' || !/^(legacy|[0-9a-f-]{36})$/.test(id))) + ) { + return data({ error: 'Invalid request' }, { status: 400 }) + } + const path = + operation === 'create' + ? '' + : `/${id}${['reveal', 'rotate'].includes(operation) ? `/${operation}` : ''}` + return respond( + await serverFetch(request, `user/api-keys${path}`, { + method: + operation === 'delete' + ? 'DELETE' + : operation === 'update' + ? 'PATCH' + : 'POST', + body: ['create', 'update'].includes(operation) ? input : undefined, + }), + ) +} diff --git a/web/app/routes/user-settings.tsx b/web/app/routes/user-settings.tsx index 51e59e6a8..14007da98 100644 --- a/web/app/routes/user-settings.tsx +++ b/web/app/routes/user-settings.tsx @@ -94,7 +94,6 @@ export interface UserSettingsActionData { repeat?: string } user?: Partial - apiKey?: string twoFAData?: { secret?: string otpauthUrl?: string @@ -307,47 +306,6 @@ export async function action({ request }: ActionFunctionArgs) { ) } - case 'generate-api-key': { - const result = await serverFetch<{ apiKey: string }>( - request, - 'user/api-key', - { - method: 'POST', - }, - ) - - if (result.error) { - const error = Array.isArray(result.error) - ? result.error[0] - : (result.error as string) - - return data({ intent, error }, { status: 400 }) - } - - return data( - { intent, success: true, apiKey: result.data?.apiKey }, - { headers: createHeadersWithCookies(result.cookies) }, - ) - } - - case 'delete-api-key': { - const result = await serverFetch(request, 'user/api-key', { - method: 'DELETE', - }) - - if (result.error) { - return data( - { intent, error: result.error as string }, - { status: 400 }, - ) - } - - return data( - { intent, success: true }, - { headers: createHeadersWithCookies(result.cookies) }, - ) - } - case 'toggle-live-visitors': { const show = formData.get('show') === 'true' diff --git a/web/app/ui/Button.tsx b/web/app/ui/Button.tsx index 6c8fcc13c..881b5bdfa 100644 --- a/web/app/ui/Button.tsx +++ b/web/app/ui/Button.tsx @@ -49,6 +49,7 @@ export interface ButtonProps extends Omit< variant?: ButtonVariant size?: ButtonSize loading?: boolean + form?: string to?: LinkProps['to'] linkProps?: Omit /** diff --git a/web/public/locales/en.json b/web/public/locales/en.json index b32ce7f26..be6c3077d 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -3742,5 +3742,115 @@ "imprint": "Legal notice", "cloudEdition": "Cloud edition", "starOnGithub": "Star us on GitHub" + }, + "apiKeys": { + "title": "API keys", + "tooltip": "API keys connect your scripts, dashboards, and integrations to Swetrix. Choose which projects and permissions each key can access. Rotate a key to replace its secret, or revoke it to stop access.", + "description": "Give each integration its own key, with only the permissions and projects it needs.", + "projectDescription": "API keys that can access this project, and the permissions they have.", + "create": "Create API key", + "manage": "Manage API keys", + "retry": "Try again", + "loading": "Loading API keys", + "edit": "Edit permissions", + "name": "Key name", + "namePlaceholder": "e.g. Reporting dashboard", + "projects": "Project access", + "projectsHint": "Only projects you own can be assigned to a key.", + "allProjects": "All projects", + "selectedProjects": "Selected projects", + "searchProjects": "Search projects", + "noProjects": "No projects found", + "projectsSelected_one": "{{count}} project selected", + "projectsSelected_other": "{{count}} projects selected", + "allProjectsHint": "Includes every project you own now and any you create later.", + "permissions": "Permissions", + "permissionsHint": "Choose what this key can read or change. Combined responses may require more than one read permission.", + "restrictHint": "This key currently has unrestricted access. Saving will replace that access with the permissions you choose below.", + "none": "No access", + "readOnly": "Read only", + "readWrite": "Read & write", + "readOnlyHint": "This resource only supports read access through the API.", + "organisationsHint": "Organisation permissions require access to all projects.", + "save": "Save permissions", + "cancel": "Cancel", + "emptyTitle": "No API keys yet", + "emptyDescription": "Create a key to connect a script, dashboard, or integration.", + "projectEmpty": "No API keys have access to this project.", + "unrestricted": "Unrestricted", + "created": "Created {{date}}", + "createdBefore": "Created before scoped API keys", + "rotated": "Rotated {{date}}", + "hide": "Hide key", + "reveal": "Reveal key", + "copy": "Copy key", + "copied": "API key copied", + "copyFailed": "Could not copy. Select and copy the key manually.", + "secret": "API key", + "rotate": "Rotate key", + "revoke": "Revoke", + "fullAccess": "Full API access", + "scopeCount_one": "{{count}} permission group", + "scopeCount_other": "{{count}} permission groups", + "unrestrictedHint": "This key retains its original access to all API-enabled resources. Edit its permissions to limit access.", + "rotateTitle": "Rotate “{{name}}”?", + "rotateDescription": "The current secret will stop working immediately. Permissions stay the same. Update your integrations with the replacement key.", + "revokeTitle": "Revoke “{{name}}”?", + "revokeDescription": "Any integration using this key will lose access immediately. This cannot be undone.", + "ready": "“{{name}}” is ready", + "secretHint": "Store this key securely in your integration. You can reveal it again from this list.", + "success": { + "create": "API key created", + "update": "Permissions saved", + "rotate": "API key rotated", + "delete": "API key revoked" + }, + "resources": { + "analytics": { + "name": "Analytics", + "description": "Traffic, performance, sessions, profiles, SEO, and CAPTCHA statistics." + }, + "events": { + "name": "Events", + "description": "Read custom events or send tracking events." + }, + "projects": { + "name": "Project settings", + "description": "Read settings; create, update, or delete projects and saved views." + }, + "funnels": { + "name": "Funnels", + "description": "Read funnel analysis or manage saved funnels." + }, + "annotations": { + "name": "Annotations", + "description": "Read or manage timeline annotations." + }, + "errors": { + "name": "Errors", + "description": "Read error reports, send errors, or update their status." + }, + "replays": { + "name": "Session replays", + "description": "View and export recordings; write access allows recording and deletion." + }, + "goals": { + "name": "Goals", + "description": "View goals, conversions, and associated sessions." + }, + "flags": { + "name": "Feature flags", + "description": "View flag configuration, statistics, and evaluations." + }, + "revenue": { + "name": "Revenue", + "description": "Read revenue statistics or record transactions." + }, + "organisations": { + "name": "Organisations", + "description": "Read or manage organisations and their members. Applies account-wide." + } + }, + "projectReadOnly": "This account has a viewer role on this project, so its key can only read project data." } }