diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1d6427109..ce1a982fc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { LeagueModule } from './domain/league/LeagueModule'; import { LoggingModule } from './domain/logging/LoggingModule'; import { MediaModule } from './domain/media/MediaModule'; import { PaymentsModule } from './domain/payments/PaymentsModule'; +import { PolicyModule } from './domain/policy/PolicyModule'; import { ProtestsModule } from './domain/protests/ProtestsModule'; import { RaceModule } from './domain/race/RaceModule'; import { SponsorModule } from './domain/sponsor/SponsorModule'; @@ -33,6 +34,7 @@ import { TeamModule } from './domain/team/TeamModule'; DriverModule, MediaModule, PaymentsModule, + PolicyModule, ], }) export class AppModule {} diff --git a/apps/api/src/domain/analytics/AnalyticsController.test.ts b/apps/api/src/domain/analytics/AnalyticsController.test.ts index 42b86445e..bae7ec8a6 100644 --- a/apps/api/src/domain/analytics/AnalyticsController.test.ts +++ b/apps/api/src/domain/analytics/AnalyticsController.test.ts @@ -1,3 +1,8 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; import { vi } from 'vitest'; import { AnalyticsController } from './AnalyticsController'; import { AnalyticsService } from './AnalyticsService'; @@ -6,6 +11,11 @@ import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView'; import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent'; import type { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO'; import type { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; describe('AnalyticsController', () => { let controller: AnalyticsController; @@ -109,4 +119,83 @@ describe('AnalyticsController', () => { expect(result).toEqual(dto); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [AnalyticsController], + providers: [ + { + provide: AnalyticsService, + useValue: { + recordPageView: vi.fn(async () => ({})), + recordEngagement: vi.fn(async () => ({})), + getDashboardData: vi.fn(async () => ({})), + getAnalyticsMetrics: vi.fn(async () => ({})), + }, + }, + ], + }) + .overrideProvider(AnalyticsController) + .useFactory({ + factory: (analyticsService: AnalyticsService) => new AnalyticsController(analyticsService), + inject: [AnalyticsService], + }) + .compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows @Public() endpoint without a session', async () => { + await request(app.getHttpServer()).post('/analytics/page-view').send({}).expect(201); + }); + + it('denies internal dashboard endpoint by default when not authenticated (401)', async () => { + await request(app.getHttpServer()).get('/analytics/dashboard').expect(401); + }); + + it('allows internal dashboard endpoint when authenticated via session port', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + await request(app.getHttpServer()).get('/analytics/dashboard').expect(200); + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/analytics/AnalyticsController.ts b/apps/api/src/domain/analytics/AnalyticsController.ts index efd2c7c49..634731970 100644 --- a/apps/api/src/domain/analytics/AnalyticsController.ts +++ b/apps/api/src/domain/analytics/AnalyticsController.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Body, Res, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Post, Body, Res, HttpStatus, Inject } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger'; import type { Response } from 'express'; +import { Public } from '../auth/Public'; import { RecordPageViewInputDTO } from './dtos/RecordPageViewInputDTO'; import { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO'; import { RecordEngagementInputDTO } from './dtos/RecordEngagementInputDTO'; @@ -16,9 +17,10 @@ type RecordEngagementInput = RecordEngagementInputDTO; @Controller('analytics') export class AnalyticsController { constructor( - private readonly analyticsService: AnalyticsService, + @Inject(AnalyticsService) private readonly analyticsService: AnalyticsService, ) {} + @Public() @Post('page-view') @ApiOperation({ summary: 'Record a page view' }) @ApiBody({ type: RecordPageViewInputDTO }) @@ -31,6 +33,7 @@ export class AnalyticsController { res.status(HttpStatus.CREATED).json(dto); } + @Public() @Post('engagement') @ApiOperation({ summary: 'Record an engagement event' }) @ApiBody({ type: RecordEngagementInputDTO }) @@ -43,6 +46,7 @@ export class AnalyticsController { res.status(HttpStatus.CREATED).json(dto); } + // Dashboard/metrics are internal and should not be publicly exposed. @Get('dashboard') @ApiOperation({ summary: 'Get analytics dashboard data' }) @ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO }) diff --git a/apps/api/src/domain/auth/AuthController.test.ts b/apps/api/src/domain/auth/AuthController.test.ts index fe88b8615..847281417 100644 --- a/apps/api/src/domain/auth/AuthController.test.ts +++ b/apps/api/src/domain/auth/AuthController.test.ts @@ -1,8 +1,18 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; import { Mock, vi } from 'vitest'; import { AuthController } from './AuthController'; import { AuthService } from './AuthService'; import { AuthSessionDTO, LoginParamsDTO, SignupParamsDTO } from './dtos/AuthDto'; import type { CommandResultDTO } from './presenters/CommandResultPresenter'; +import { AuthenticationGuard } from './AuthenticationGuard'; +import { AuthorizationGuard } from './AuthorizationGuard'; +import type { AuthorizationService } from './AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; describe('AuthController', () => { let controller: AuthController; @@ -107,4 +117,72 @@ describe('AuthController', () => { expect(result).toEqual(dto); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { + provide: AuthService, + useValue: { + getCurrentSession: vi.fn(async () => null), + loginWithEmail: vi.fn(), + signupWithEmail: vi.fn(), + logout: vi.fn(), + startIracingAuth: vi.fn(), + iracingCallback: vi.fn(), + }, + }, + ], + }) + .overrideProvider(AuthController) + .useFactory({ + factory: (authService: AuthService) => new AuthController(authService), + inject: [AuthService], + }) + .compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows @Public() endpoint without a session', async () => { + await request(app.getHttpServer()).get('/auth/session').expect(200); + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthController.ts b/apps/api/src/domain/auth/AuthController.ts index a36800901..c06511067 100644 --- a/apps/api/src/domain/auth/AuthController.ts +++ b/apps/api/src/domain/auth/AuthController.ts @@ -1,11 +1,13 @@ -import { Controller, Get, Post, Body, Query } from '@nestjs/common'; +import { Controller, Get, Post, Body, Query, Inject } from '@nestjs/common'; +import { Public } from './Public'; import { AuthService } from './AuthService'; import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO } from './dtos/AuthDto'; import type { CommandResultDTO } from './presenters/CommandResultPresenter'; +@Public() @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor(@Inject(AuthService) private readonly authService: AuthService) {} @Post('signup') async signup(@Body() params: SignupParamsDTO): Promise { diff --git a/apps/api/src/domain/auth/AuthGuards.http.test.ts b/apps/api/src/domain/auth/AuthGuards.http.test.ts new file mode 100644 index 000000000..15c8cd64c --- /dev/null +++ b/apps/api/src/domain/auth/AuthGuards.http.test.ts @@ -0,0 +1,164 @@ +import 'reflect-metadata'; + +import { Controller, Get } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthenticationGuard } from './AuthenticationGuard'; +import { AuthorizationGuard } from './AuthorizationGuard'; +import { AuthorizationService } from './AuthorizationService'; +import { Public } from './Public'; +import { RequireRoles } from './RequireRoles'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import { PolicyService, type PolicySnapshot } from '../policy/PolicyService'; +import { RequireCapability } from '../policy/RequireCapability'; + +@Controller('authz-test') +class AuthzTestController { + @Public() + @Get('public') + publicRoute() { + return { ok: true }; + } + + @Get('protected') + protectedRoute() { + return { ok: true }; + } + + @RequireRoles('admin') + @Get('admin') + adminOnlyRoute() { + return { ok: true }; + } + + @RequireCapability('demo.feature', 'view') + @Get('feature') + featureGatedRoute() { + return { ok: true }; + } +} + +type SessionPort = { + getCurrentSession: () => Promise; +}; + +describe('Auth guards (HTTP)', () => { + let app: any; + + const sessionPort: SessionPort = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'demo.feature': 'enabled' }, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [AuthzTestController], + }).compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows `@Public()` routes without a session', async () => { + await request(app.getHttpServer()).get('/authz-test/public').expect(200).expect({ ok: true }); + expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1); + }); + + it('denies non-public routes by default when not authenticated (401)', async () => { + await request(app.getHttpServer()).get('/authz-test/protected').expect(401); + }); + + it('allows non-public routes when authenticated via session port', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + await request(app.getHttpServer()).get('/authz-test/protected').expect(200).expect({ ok: true }); + }); + + it('returns 403 when route requires a role and the user does not have it', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']); + + await request(app.getHttpServer()).get('/authz-test/admin').expect(403); + }); + + it('allows access when route requires a role and the user has it', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']); + + await request(app.getHttpServer()).get('/authz-test/admin').expect(200).expect({ ok: true }); + }); + + it('returns 404 when a `@RequireCapability()` feature is disabled', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'demo.feature': 'disabled' }, + loadedFrom: 'env', + loadedAtIso: new Date(0).toISOString(), + }); + + await request(app.getHttpServer()).get('/authz-test/feature').expect(404); + }); + + it('returns 503 during maintenance when capability is not allowlisted', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({ + policyVersion: 1, + operationalMode: 'maintenance', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'demo.feature': 'enabled' }, + loadedFrom: 'env', + loadedAtIso: new Date(0).toISOString(), + }); + + await request(app.getHttpServer()).get('/authz-test/feature').expect(503); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthModule.ts b/apps/api/src/domain/auth/AuthModule.ts index 1ac2d8f6e..7e1b42422 100644 --- a/apps/api/src/domain/auth/AuthModule.ts +++ b/apps/api/src/domain/auth/AuthModule.ts @@ -2,10 +2,19 @@ import { Module } from '@nestjs/common'; import { AuthService } from './AuthService'; import { AuthController } from './AuthController'; import { AuthProviders } from './AuthProviders'; +import { AuthenticationGuard } from './AuthenticationGuard'; +import { AuthorizationGuard } from './AuthorizationGuard'; +import { AuthorizationService } from './AuthorizationService'; @Module({ controllers: [AuthController], - providers: [AuthService, ...AuthProviders], - exports: [AuthService], + providers: [ + AuthService, + ...AuthProviders, + AuthenticationGuard, + AuthorizationService, + AuthorizationGuard, + ], + exports: [AuthService, AuthenticationGuard, AuthorizationService, AuthorizationGuard], }) export class AuthModule {} diff --git a/apps/api/src/domain/auth/AuthenticationGuard.test.ts b/apps/api/src/domain/auth/AuthenticationGuard.test.ts new file mode 100644 index 000000000..c53a2df32 --- /dev/null +++ b/apps/api/src/domain/auth/AuthenticationGuard.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AuthenticationGuard } from './AuthenticationGuard'; + +function createExecutionContext(request: Record) { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + }; +} + +describe('AuthenticationGuard', () => { + it('attaches request.user.userId from session when missing', async () => { + const request: any = {}; + const sessionPort = { + getCurrentSession: vi.fn(async () => ({ token: 't', user: { id: 'user-1' } })), + }; + + const guard = new AuthenticationGuard(sessionPort as any); + + await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true); + + expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1); + expect(request.user).toEqual({ userId: 'user-1' }); + }); + + it('does not override request.user.userId if already present', async () => { + const request: any = { user: { userId: 'already-set' } }; + const sessionPort = { + getCurrentSession: vi.fn(async () => ({ token: 't', user: { id: 'user-1' } })), + }; + + const guard = new AuthenticationGuard(sessionPort as any); + + await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true); + + expect(sessionPort.getCurrentSession).not.toHaveBeenCalled(); + expect(request.user).toEqual({ userId: 'already-set' }); + }); + + it('leaves request.user undefined when no session exists', async () => { + const request: any = {}; + const sessionPort = { + getCurrentSession: vi.fn(async () => null), + }; + + const guard = new AuthenticationGuard(sessionPort as any); + + await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true); + + expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1); + expect(request.user).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthenticationGuard.ts b/apps/api/src/domain/auth/AuthenticationGuard.ts new file mode 100644 index 000000000..b28acb14b --- /dev/null +++ b/apps/api/src/domain/auth/AuthenticationGuard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common'; +import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; +import { IDENTITY_SESSION_PORT_TOKEN } from './AuthProviders'; + +type AuthenticatedRequest = { + user?: { userId: string }; +}; + +@Injectable() +export class AuthenticationGuard implements CanActivate { + constructor( + @Inject(IDENTITY_SESSION_PORT_TOKEN) + private readonly sessionPort: IdentitySessionPort, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + if (request.user?.userId) { + return true; + } + + const session = await this.sessionPort.getCurrentSession(); + if (session?.user?.id) { + request.user = { userId: session.user.id }; + } + + return true; + } +} \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthorizationGuard.test.ts b/apps/api/src/domain/auth/AuthorizationGuard.test.ts new file mode 100644 index 000000000..6fa96ae10 --- /dev/null +++ b/apps/api/src/domain/auth/AuthorizationGuard.test.ts @@ -0,0 +1,89 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { describe, expect, it, vi } from 'vitest'; +import { AuthorizationGuard } from './AuthorizationGuard'; +import { Public } from './Public'; +import { RequireRoles } from './RequireRoles'; + +class DummyController { + @Public() + publicHandler(): void {} + + protectedHandler(): void {} + + @RequireRoles('admin') + adminHandler(): void {} +} + +function createExecutionContext(options: { handler: Function; userId?: string }) { + const request = options.userId ? { user: { userId: options.userId } } : {}; + + return { + getHandler: () => options.handler, + getClass: () => DummyController, + switchToHttp: () => ({ + getRequest: () => request, + }), + }; +} + +describe('AuthorizationGuard', () => { + it('allows public routes without a user session', () => { + const authorizationService = { getRolesForUser: vi.fn() }; + const guard = new AuthorizationGuard(new Reflector(), authorizationService as any); + + const ctx = createExecutionContext({ + handler: DummyController.prototype.publicHandler, + }); + + expect(guard.canActivate(ctx as any)).toBe(true); + expect(authorizationService.getRolesForUser).not.toHaveBeenCalled(); + }); + + it('denies non-public routes by default when not authenticated', () => { + const authorizationService = { getRolesForUser: vi.fn() }; + const guard = new AuthorizationGuard(new Reflector(), authorizationService as any); + + const ctx = createExecutionContext({ + handler: DummyController.prototype.protectedHandler, + }); + + expect(() => guard.canActivate(ctx as any)).toThrow(UnauthorizedException); + }); + + it('allows non-public routes when authenticated', () => { + const authorizationService = { getRolesForUser: vi.fn().mockReturnValue([]) }; + const guard = new AuthorizationGuard(new Reflector(), authorizationService as any); + + const ctx = createExecutionContext({ + handler: DummyController.prototype.protectedHandler, + userId: 'user-1', + }); + + expect(guard.canActivate(ctx as any)).toBe(true); + }); + + it('denies routes requiring roles when user does not have any required role', () => { + const authorizationService = { getRolesForUser: vi.fn().mockReturnValue(['user']) }; + const guard = new AuthorizationGuard(new Reflector(), authorizationService as any); + + const ctx = createExecutionContext({ + handler: DummyController.prototype.adminHandler, + userId: 'user-1', + }); + + expect(() => guard.canActivate(ctx as any)).toThrow(ForbiddenException); + }); + + it('allows routes requiring roles when user has a required role', () => { + const authorizationService = { getRolesForUser: vi.fn().mockReturnValue(['admin']) }; + const guard = new AuthorizationGuard(new Reflector(), authorizationService as any); + + const ctx = createExecutionContext({ + handler: DummyController.prototype.adminHandler, + userId: 'user-1', + }); + + expect(guard.canActivate(ctx as any)).toBe(true); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthorizationGuard.ts b/apps/api/src/domain/auth/AuthorizationGuard.ts new file mode 100644 index 000000000..48c9989c6 --- /dev/null +++ b/apps/api/src/domain/auth/AuthorizationGuard.ts @@ -0,0 +1,67 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthorizationService } from './AuthorizationService'; +import { PUBLIC_ROUTE_METADATA_KEY } from './Public'; +import { REQUIRE_AUTHENTICATED_USER_METADATA_KEY } from './RequireAuthenticatedUser'; +import { REQUIRE_ROLES_METADATA_KEY, RequireRolesMetadata } from './RequireRoles'; + +type AuthenticatedRequest = { + user?: { userId: string }; +}; + +@Injectable() +export class AuthorizationGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly authorizationService: AuthorizationService, + ) {} + + canActivate(context: ExecutionContext): boolean { + const handler = context.getHandler(); + const controllerClass = context.getClass(); + + const isPublic = + this.reflector.getAllAndOverride<{ public: true } | undefined>( + PUBLIC_ROUTE_METADATA_KEY, + [handler, controllerClass], + )?.public ?? false; + + if (isPublic) { + return true; + } + + const requiresAuth = + this.reflector.getAllAndOverride<{ required: true } | undefined>( + REQUIRE_AUTHENTICATED_USER_METADATA_KEY, + [handler, controllerClass], + )?.required ?? false; + + const rolesMetadata = + this.reflector.getAllAndOverride( + REQUIRE_ROLES_METADATA_KEY, + [handler, controllerClass], + ) ?? null; + + // Protect all endpoints by default: + // - if a route is not marked public, it requires an authenticated user. + const request = context.switchToHttp().getRequest(); + const userId = request.user?.userId; + + if (!userId) { + throw new UnauthorizedException('Unauthorized'); + } + + // If the route explicitly requires auth, that's already satisfied by userId. + void requiresAuth; + + if (rolesMetadata && rolesMetadata.anyOf.length > 0) { + const userRoles = this.authorizationService.getRolesForUser(userId); + const hasAnyRole = rolesMetadata.anyOf.some((r) => userRoles.includes(r)); + if (!hasAnyRole) { + throw new ForbiddenException('Forbidden'); + } + } + + return true; + } +} \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthorizationService.ts b/apps/api/src/domain/auth/AuthorizationService.ts new file mode 100644 index 000000000..071f006fb --- /dev/null +++ b/apps/api/src/domain/auth/AuthorizationService.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; + +export type SystemRole = string; + +@Injectable() +export class AuthorizationService { + private cache: + | { + rolesByUserId: Readonly>; + expiresAtMs: number; + } + | null = null; + + getRolesForUser(userId: string): readonly SystemRole[] { + const now = Date.now(); + if (this.cache && now < this.cache.expiresAtMs) { + return this.cache.rolesByUserId[userId] ?? []; + } + + const cacheMs = parseCacheMs(process.env.GRIDPILOT_AUTHZ_CACHE_MS); + const rolesByUserId = parseUserRolesJson(process.env.GRIDPILOT_USER_ROLES_JSON); + + this.cache = { + rolesByUserId, + expiresAtMs: now + cacheMs, + }; + + return rolesByUserId[userId] ?? []; + } +} + +function parseCacheMs(raw: string | undefined): number { + if (!raw) { + return 5_000; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + return 5_000; + } + return parsed; +} + +function parseUserRolesJson(raw: string | undefined): Readonly> { + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') { + return {}; + } + + const record = parsed as Record; + const normalized: Record = {}; + + for (const [userId, roles] of Object.entries(record)) { + if (!userId || !Array.isArray(roles)) { + continue; + } + + const cleaned = roles + .filter((v) => typeof v === 'string') + .map((v) => v.trim()) + .filter(Boolean); + + if (cleaned.length > 0) { + normalized[userId] = cleaned; + } + } + + return normalized; + } catch { + return {}; + } +} \ No newline at end of file diff --git a/apps/api/src/domain/auth/Public.ts b/apps/api/src/domain/auth/Public.ts new file mode 100644 index 000000000..607eddf0d --- /dev/null +++ b/apps/api/src/domain/auth/Public.ts @@ -0,0 +1,13 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PUBLIC_ROUTE_METADATA_KEY = 'gridpilot:publicRoute'; + +export type PublicRouteMetadata = { + readonly public: true; +}; + +export function Public(): MethodDecorator & ClassDecorator { + return SetMetadata(PUBLIC_ROUTE_METADATA_KEY, { + public: true, + } satisfies PublicRouteMetadata); +} \ No newline at end of file diff --git a/apps/api/src/domain/auth/RequireAuthenticatedUser.ts b/apps/api/src/domain/auth/RequireAuthenticatedUser.ts new file mode 100644 index 000000000..05a37cdd4 --- /dev/null +++ b/apps/api/src/domain/auth/RequireAuthenticatedUser.ts @@ -0,0 +1,13 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRE_AUTHENTICATED_USER_METADATA_KEY = 'gridpilot:requireAuthenticatedUser'; + +export type RequireAuthenticatedUserMetadata = { + readonly required: true; +}; + +export function RequireAuthenticatedUser(): MethodDecorator & ClassDecorator { + return SetMetadata(REQUIRE_AUTHENTICATED_USER_METADATA_KEY, { + required: true, + } satisfies RequireAuthenticatedUserMetadata); +} \ No newline at end of file diff --git a/apps/api/src/domain/auth/RequireRoles.ts b/apps/api/src/domain/auth/RequireRoles.ts new file mode 100644 index 000000000..926073aa7 --- /dev/null +++ b/apps/api/src/domain/auth/RequireRoles.ts @@ -0,0 +1,13 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRE_ROLES_METADATA_KEY = 'gridpilot:requireRoles'; + +export type RequireRolesMetadata = { + readonly anyOf: readonly string[]; +}; + +export function RequireRoles(...anyOf: readonly string[]): MethodDecorator & ClassDecorator { + return SetMetadata(REQUIRE_ROLES_METADATA_KEY, { + anyOf, + } satisfies RequireRolesMetadata); +} \ No newline at end of file diff --git a/apps/api/src/domain/dashboard/DashboardController.test.ts b/apps/api/src/domain/dashboard/DashboardController.test.ts index 0b278304a..74ad449c8 100644 --- a/apps/api/src/domain/dashboard/DashboardController.test.ts +++ b/apps/api/src/domain/dashboard/DashboardController.test.ts @@ -1,6 +1,17 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; import { vi } from 'vitest'; import { DashboardController } from './DashboardController'; import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; +import { DashboardService } from './DashboardService'; describe('DashboardController', () => { let controller: DashboardController; @@ -34,10 +45,93 @@ describe('DashboardController', () => { }; mockService.getDashboardOverview.mockResolvedValue(overview); - const result = await controller.getDashboardOverview(driverId); + const result = await controller.getDashboardOverview(driverId, { user: { userId: driverId } }); expect(mockService.getDashboardOverview).toHaveBeenCalledWith(driverId); expect(result).toEqual(overview); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [DashboardController], + providers: [ + { + provide: DashboardService, + useValue: { + getDashboardOverview: vi.fn(async () => ({ + currentDriver: null, + myUpcomingRaces: [], + otherUpcomingRaces: [], + upcomingRaces: [], + activeLeaguesCount: 0, + nextRace: null, + recentResults: [], + leagueStandingsSummaries: [], + feedSummary: { notificationCount: 0, items: [] }, + friends: [], + })), + }, + }, + ], + }) + .overrideProvider(DashboardController) + .useFactory({ + factory: (dashboardService: DashboardService) => new DashboardController(dashboardService), + inject: [DashboardService], + }) + .compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('denies endpoint by default when not authenticated (401)', async () => { + await request(app.getHttpServer()).get('/dashboard/overview?driverId=d1').expect(401); + }); + + it('allows endpoint when authenticated via session port', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + await request(app.getHttpServer()).get('/dashboard/overview?driverId=d1').expect(200); + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/dashboard/DashboardController.ts b/apps/api/src/domain/dashboard/DashboardController.ts index cbc7a2bfc..db853f555 100644 --- a/apps/api/src/domain/dashboard/DashboardController.ts +++ b/apps/api/src/domain/dashboard/DashboardController.ts @@ -1,18 +1,29 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Query, Req, UnauthorizedException, Inject } from '@nestjs/common'; import { ApiTags, ApiResponse, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { DashboardService } from './DashboardService'; import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO'; +type AuthenticatedRequest = { + user?: { userId: string }; +}; + @ApiTags('dashboard') @Controller('dashboard') export class DashboardController { - constructor(private readonly dashboardService: DashboardService) {} + constructor(@Inject(DashboardService) private readonly dashboardService: DashboardService) {} @Get('overview') @ApiOperation({ summary: 'Get dashboard overview' }) @ApiQuery({ name: 'driverId', description: 'Driver ID' }) @ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO }) - async getDashboardOverview(@Query('driverId') driverId: string): Promise { - return this.dashboardService.getDashboardOverview(driverId); + async getDashboardOverview( + @Query('driverId') _driverId: string, + @Req() req: AuthenticatedRequest, + ): Promise { + const userId = req.user?.userId; + if (!userId) { + throw new UnauthorizedException('Unauthorized'); + } + return this.dashboardService.getDashboardOverview(userId); } } \ No newline at end of file diff --git a/apps/api/src/domain/driver/DriverController.test.ts b/apps/api/src/domain/driver/DriverController.test.ts index e6b8847ea..e82559537 100644 --- a/apps/api/src/domain/driver/DriverController.test.ts +++ b/apps/api/src/domain/driver/DriverController.test.ts @@ -1,8 +1,17 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; import { vi } from 'vitest'; import { DriverController } from './DriverController'; import { DriverService } from './DriverService'; import type { Request } from 'express'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; interface AuthenticatedRequest extends Request { user?: { userId: string }; @@ -160,4 +169,75 @@ describe('DriverController', () => { expect(result).toEqual(updated); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [DriverController], + providers: [ + { + provide: DriverService, + useValue: { + getDriversLeaderboard: vi.fn(async () => ({ drivers: [], totalRaces: 0, totalWins: 0, activeCount: 0 })), + getCurrentDriver: vi.fn(async () => ({ id: 'd1' })), + }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows @Public() endpoint without a session', async () => { + await request(app.getHttpServer()).get('/drivers/leaderboard').expect(200); + }); + + it('denies non-public endpoint by default when not authenticated (401)', async () => { + await request(app.getHttpServer()).get('/drivers/current').expect(401); + }); + + it('allows non-public endpoint when authenticated via session port', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + await request(app.getHttpServer()).get('/drivers/current').expect(200); + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/driver/DriverController.ts b/apps/api/src/domain/driver/DriverController.ts index cbe5e9878..78901fd9a 100644 --- a/apps/api/src/domain/driver/DriverController.ts +++ b/apps/api/src/domain/driver/DriverController.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, Post, Put, Req, Inject } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from '../auth/Public'; import { DriverService } from './DriverService'; import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO'; @@ -20,6 +21,7 @@ import { GetDriverProfileOutputDTO } from './dtos/GetDriverProfileOutputDTO'; export class DriverController { constructor(@Inject(DriverService) private readonly driverService: DriverService) {} + @Public() @Get('leaderboard') @ApiOperation({ summary: 'Get drivers leaderboard' }) @ApiResponse({ status: 200, description: 'List of drivers for the leaderboard', type: DriversLeaderboardDTO }) @@ -27,6 +29,7 @@ export class DriverController { return await this.driverService.getDriversLeaderboard(); } + @Public() @Get('total-drivers') @ApiOperation({ summary: 'Get the total number of drivers' }) @ApiResponse({ status: 200, description: 'Total number of drivers', type: DriverStatsDTO }) @@ -61,6 +64,7 @@ export class DriverController { return await this.driverService.completeOnboarding(userId, input); } + @Public() @Get(':driverId/races/:raceId/registration-status') @ApiOperation({ summary: 'Get driver registration status for a specific race' }) @ApiResponse({ status: 200, description: 'Driver registration status', type: DriverRegistrationStatusDTO }) @@ -71,6 +75,7 @@ export class DriverController { return await this.driverService.getDriverRegistrationStatus({ driverId, raceId }); } + @Public() @Get(':driverId') @ApiOperation({ summary: 'Get driver by ID' }) @ApiResponse({ status: 200, description: 'Driver data', type: GetDriverOutputDTO }) @@ -79,6 +84,7 @@ export class DriverController { return await this.driverService.getDriver(driverId); } + @Public() @Get(':driverId/profile') @ApiOperation({ summary: 'Get driver profile with full details' }) @ApiResponse({ status: 200, description: 'Driver profile data', type: GetDriverProfileOutputDTO }) diff --git a/apps/api/src/domain/hello/HelloController.ts b/apps/api/src/domain/hello/HelloController.ts index ea3910ed3..da79c8e80 100644 --- a/apps/api/src/domain/hello/HelloController.ts +++ b/apps/api/src/domain/hello/HelloController.ts @@ -1,8 +1,10 @@ import { Controller, Get } from '@nestjs/common'; +import { Public } from '../auth/Public'; import { HelloService } from './HelloService'; +@Public() @Controller() export class HelloController { constructor(private readonly helloService: HelloService) {} diff --git a/apps/api/src/domain/league/LeagueController.test.ts b/apps/api/src/domain/league/LeagueController.test.ts index 83262705e..f02f2100d 100644 --- a/apps/api/src/domain/league/LeagueController.test.ts +++ b/apps/api/src/domain/league/LeagueController.test.ts @@ -1,7 +1,16 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; import { vi } from 'vitest'; import { LeagueController } from './LeagueController'; import { LeagueService } from './LeagueService'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; describe('LeagueController', () => { let controller: LeagueController; @@ -55,4 +64,75 @@ describe('LeagueController', () => { expect(result).toEqual(mockResult); expect(leagueService.getLeagueStandings).toHaveBeenCalledWith('league-1'); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [LeagueController], + providers: [ + { + provide: LeagueService, + useValue: { + getTotalLeagues: vi.fn(async () => ({ totalLeagues: 0 })), + getLeagueAdmin: vi.fn(async () => ({}) ), + }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows @Public() endpoint without a session', async () => { + await request(app.getHttpServer()).get('/leagues/total-leagues').expect(200); + }); + + it('denies non-public endpoint by default when not authenticated (401)', async () => { + await request(app.getHttpServer()).get('/leagues/l1/admin').expect(401); + }); + + it('allows non-public endpoint when authenticated via session port', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + await request(app.getHttpServer()).get('/leagues/l1/admin').expect(200); + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/league/LeagueController.ts b/apps/api/src/domain/league/LeagueController.ts index 0af8c4694..861d803df 100644 --- a/apps/api/src/domain/league/LeagueController.ts +++ b/apps/api/src/domain/league/LeagueController.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post, Inject } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from '../auth/Public'; import { LeagueService } from './LeagueService'; import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO'; import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO'; @@ -40,6 +41,7 @@ import { LeagueScoringPresetsDTO } from './dtos/LeagueScoringPresetsDTO'; export class LeagueController { constructor(@Inject(LeagueService) private readonly leagueService: LeagueService) {} + @Public() @Get('all-with-capacity') @ApiOperation({ summary: 'Get all leagues with their capacity information' }) @ApiResponse({ status: 200, description: 'List of leagues with capacity', type: AllLeaguesWithCapacityDTO }) @@ -47,6 +49,7 @@ export class LeagueController { return this.leagueService.getAllLeaguesWithCapacity(); } + @Public() @Get('total-leagues') @ApiOperation({ summary: 'Get the total number of leagues' }) @ApiResponse({ status: 200, description: 'Total number of leagues', type: TotalLeaguesDTO }) @@ -126,6 +129,7 @@ export class LeagueController { return this.leagueService.updateLeagueMemberRole({ leagueId, performerDriverId, targetDriverId, newRole: input.newRole }); } + @Public() @Get(':leagueId/owner-summary/:ownerId') @ApiOperation({ summary: 'Get owner summary for a league' }) @ApiResponse({ status: 200, description: 'League owner summary', type: LeagueOwnerSummaryDTO }) @@ -187,6 +191,7 @@ export class LeagueController { }; } + @Public() @Get(':leagueId/seasons') @ApiOperation({ summary: 'Get seasons for a league' }) @ApiResponse({ status: 200, description: 'List of seasons for the league', type: [LeagueSeasonSummaryDTO] }) @@ -195,6 +200,7 @@ export class LeagueController { return this.leagueService.getLeagueSeasons(query); } + @Public() @Get(':leagueId/memberships') @ApiOperation({ summary: 'Get league memberships' }) @ApiResponse({ status: 200, description: 'List of league members', type: LeagueMembershipsDTO }) @@ -202,6 +208,7 @@ export class LeagueController { return this.leagueService.getLeagueMemberships(leagueId); } + @Public() @Get(':leagueId/standings') @ApiOperation({ summary: 'Get league standings' }) @ApiResponse({ status: 200, description: 'League standings', type: LeagueStandingsDTO }) @@ -209,6 +216,7 @@ export class LeagueController { return this.leagueService.getLeagueStandings(leagueId); } + @Public() @Get(':leagueId/schedule') @ApiOperation({ summary: 'Get league schedule' }) @ApiResponse({ status: 200, description: 'League schedule', type: LeagueScheduleDTO }) @@ -216,6 +224,7 @@ export class LeagueController { return this.leagueService.getLeagueSchedule(leagueId); } + @Public() @Get(':leagueId/stats') @ApiOperation({ summary: 'Get league stats' }) @ApiResponse({ status: 200, description: 'League stats', type: LeagueStatsDTO }) @@ -238,6 +247,7 @@ export class LeagueController { return this.leagueService.createLeague(input); } + @Public() @Get('scoring-presets') @ApiOperation({ summary: 'Get league scoring presets' }) @ApiResponse({ status: 200, description: 'List of scoring presets', type: LeagueScoringPresetsDTO }) @@ -245,6 +255,7 @@ export class LeagueController { return this.leagueService.listLeagueScoringPresets(); } + @Public() @Get(':leagueId/scoring-config') @ApiOperation({ summary: 'Get league scoring config' }) @ApiResponse({ status: 200, description: 'League scoring config' }) @@ -266,6 +277,7 @@ export class LeagueController { return this.leagueService.transferLeagueOwnership(leagueId, body.currentOwnerId, body.newOwnerId); } + @Public() @Get('seasons/:seasonId/sponsorships') @ApiOperation({ summary: 'Get season sponsorships' }) @ApiResponse({ status: 200, description: 'Season sponsorships', type: GetSeasonSponsorshipsOutputDTO }) @@ -273,6 +285,7 @@ export class LeagueController { return this.leagueService.getSeasonSponsorships(seasonId); } + @Public() @Get(':leagueId/races') @ApiOperation({ summary: 'Get league races' }) @ApiResponse({ status: 200, description: 'League races', type: GetLeagueRacesOutputDTO }) diff --git a/apps/api/src/domain/media/MediaController.test.ts b/apps/api/src/domain/media/MediaController.test.ts index ef3ebbcaa..9d33302b4 100644 --- a/apps/api/src/domain/media/MediaController.test.ts +++ b/apps/api/src/domain/media/MediaController.test.ts @@ -1,8 +1,17 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; import { vi } from 'vitest'; import { MediaController } from './MediaController'; import { MediaService } from './MediaService'; import type { Response } from 'express'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO'; import { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO'; import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO'; @@ -248,4 +257,75 @@ describe('MediaController', () => { expect(res.json).toHaveBeenCalledWith(dto); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [MediaController], + providers: [ + { + provide: MediaService, + useValue: { + getMedia: vi.fn(async () => ({ id: 'm1' })), + deleteMedia: vi.fn(async () => ({ success: true })), + }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows @Public() endpoint without a session', async () => { + await request(app.getHttpServer()).get('/media/m1').expect(200); + }); + + it('denies non-public endpoint by default when not authenticated (401)', async () => { + await request(app.getHttpServer()).delete('/media/m1').expect(401); + }); + + it('allows non-public endpoint when authenticated via session port', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + await request(app.getHttpServer()).delete('/media/m1').expect(200); + }); + }); }); diff --git a/apps/api/src/domain/media/MediaController.ts b/apps/api/src/domain/media/MediaController.ts index 1ca353868..cf8d015af 100644 --- a/apps/api/src/domain/media/MediaController.ts +++ b/apps/api/src/domain/media/MediaController.ts @@ -2,6 +2,7 @@ import { Controller, Post, Get, Delete, Put, Body, HttpStatus, Res, Param, UseIn import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiConsumes } from '@nestjs/swagger'; import type { Response } from 'express'; import { FileInterceptor } from '@nestjs/platform-express'; +import { Public } from '../auth/Public'; import { MediaService } from './MediaService'; import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO'; import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO'; @@ -60,6 +61,7 @@ export class MediaController { } } + @Public() @Get(':mediaId') @ApiOperation({ summary: 'Get media by ID' }) @ApiParam({ name: 'mediaId', description: 'Media ID' }) @@ -90,6 +92,7 @@ export class MediaController { res.status(HttpStatus.OK).json(dto); } + @Public() @Get('avatar/:driverId') @ApiOperation({ summary: 'Get avatar for driver' }) @ApiParam({ name: 'driverId', description: 'Driver ID' }) diff --git a/apps/api/src/domain/payments/PaymentsController.test.ts b/apps/api/src/domain/payments/PaymentsController.test.ts index a109bda4b..a532288d1 100644 --- a/apps/api/src/domain/payments/PaymentsController.test.ts +++ b/apps/api/src/domain/payments/PaymentsController.test.ts @@ -1,7 +1,16 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; import { vi } from 'vitest'; import { PaymentsController } from './PaymentsController'; import { PaymentsService } from './PaymentsService'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; import { GetPaymentsQuery, CreatePaymentInput, @@ -366,4 +375,107 @@ describe('PaymentsController', () => { expect(response).toEqual(result); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'payments.admin': 'enabled' }, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [PaymentsController], + providers: [ + { + provide: PaymentsService, + useValue: { + getPayments: vi.fn(async () => ({ payments: [] })), + }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('denies endpoint when not authenticated (401)', async () => { + await request(app.getHttpServer()).get('/payments').expect(401); + }); + + it('returns 403 when authenticated but missing required role', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']); + + await request(app.getHttpServer()).get('/payments').expect(403); + }); + + it('returns 404 when role is satisfied but capability is disabled', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']); + vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'payments.admin': 'disabled' }, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }); + + await request(app.getHttpServer()).get('/payments').expect(404); + }); + + it('allows access when role is satisfied and capability is enabled', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']); + vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'payments.admin': 'enabled' }, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }); + + await request(app.getHttpServer()).get('/payments').expect(200); + }); + }); }); diff --git a/apps/api/src/domain/payments/PaymentsController.ts b/apps/api/src/domain/payments/PaymentsController.ts index 95cf06aef..1102673e2 100644 --- a/apps/api/src/domain/payments/PaymentsController.ts +++ b/apps/api/src/domain/payments/PaymentsController.ts @@ -1,14 +1,20 @@ import { Controller, Get, Post, Patch, Delete, Body, Query, HttpCode, HttpStatus, Inject } from '@nestjs/common'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; +import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser'; +import { RequireRoles } from '../auth/RequireRoles'; +import { RequireCapability } from '../policy/RequireCapability'; import { PaymentsService } from './PaymentsService'; import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, UpdatePaymentStatusOutput, GetPaymentsQuery, GetPaymentsOutput, GetMembershipFeesQuery, GetMembershipFeesOutput, UpsertMembershipFeeInput, UpsertMembershipFeeOutput, UpdateMemberPaymentInput, UpdateMemberPaymentOutput, GetPrizesQuery, GetPrizesOutput, CreatePrizeInput, CreatePrizeOutput, AwardPrizeInput, AwardPrizeOutput, DeletePrizeInput, DeletePrizeOutput, GetWalletQuery, GetWalletOutput, ProcessWalletTransactionInput, ProcessWalletTransactionOutput } from './dtos/PaymentsDto'; @ApiTags('payments') +@RequireAuthenticatedUser() +@RequireRoles('admin') @Controller('payments') export class PaymentsController { constructor(@Inject(PaymentsService) private readonly paymentsService: PaymentsService) {} @Get() + @RequireCapability('payments.admin', 'view') @ApiOperation({ summary: 'Get payments based on filters' }) @ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput }) async getPayments(@Query() query: GetPaymentsQuery): Promise { @@ -16,6 +22,7 @@ export class PaymentsController { } @Post() + @RequireCapability('payments.admin', 'mutate') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create a new payment' }) @ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput }) @@ -24,6 +31,7 @@ export class PaymentsController { } @Patch('status') + @RequireCapability('payments.admin', 'mutate') @ApiOperation({ summary: 'Update the status of a payment' }) @ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput }) async updatePaymentStatus(@Body() input: UpdatePaymentStatusInput): Promise { @@ -31,6 +39,7 @@ export class PaymentsController { } @Get('membership-fees') + @RequireCapability('payments.admin', 'view') @ApiOperation({ summary: 'Get membership fees and member payments' }) @ApiResponse({ status: 200, description: 'Membership fee configuration and member payments', type: GetMembershipFeesOutput }) async getMembershipFees(@Query() query: GetMembershipFeesQuery): Promise { @@ -38,6 +47,7 @@ export class PaymentsController { } @Post('membership-fees') + @RequireCapability('payments.admin', 'mutate') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create or update membership fee configuration' }) @ApiResponse({ status: 201, description: 'Membership fee configuration created or updated', type: UpsertMembershipFeeOutput }) @@ -46,12 +56,14 @@ export class PaymentsController { } @Patch('membership-fees/member-payment') + @RequireCapability('payments.admin', 'mutate') @ApiOperation({ summary: 'Record or update a member payment' }) @ApiResponse({ status: 200, description: 'Member payment recorded or updated', type: UpdateMemberPaymentOutput }) async updateMemberPayment(@Body() input: UpdateMemberPaymentInput): Promise { return this.paymentsService.updateMemberPayment(input); } @Get('prizes') + @RequireCapability('payments.admin', 'view') @ApiOperation({ summary: 'Get prizes for a league or season' }) @ApiResponse({ status: 200, description: 'List of prizes', type: GetPrizesOutput }) async getPrizes(@Query() query: GetPrizesQuery): Promise { @@ -59,6 +71,7 @@ export class PaymentsController { } @Post('prizes') + @RequireCapability('payments.admin', 'mutate') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create a new prize' }) @ApiResponse({ status: 201, description: 'Prize created', type: CreatePrizeOutput }) @@ -67,6 +80,7 @@ export class PaymentsController { } @Patch('prizes/award') + @RequireCapability('payments.admin', 'mutate') @ApiOperation({ summary: 'Award a prize to a driver' }) @ApiResponse({ status: 200, description: 'Prize awarded', type: AwardPrizeOutput }) async awardPrize(@Body() input: AwardPrizeInput): Promise { @@ -74,12 +88,14 @@ export class PaymentsController { } @Delete('prizes') + @RequireCapability('payments.admin', 'mutate') @ApiOperation({ summary: 'Delete a prize' }) @ApiResponse({ status: 200, description: 'Prize deleted', type: DeletePrizeOutput }) async deletePrize(@Query() query: DeletePrizeInput): Promise { return this.paymentsService.deletePrize(query); } @Get('wallets') + @RequireCapability('payments.admin', 'view') @ApiOperation({ summary: 'Get wallet information and transactions' }) @ApiResponse({ status: 200, description: 'Wallet and transaction data', type: GetWalletOutput }) async getWallet(@Query() query: GetWalletQuery): Promise { @@ -87,6 +103,7 @@ export class PaymentsController { } @Post('wallets/transactions') + @RequireCapability('payments.admin', 'mutate') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Process a wallet transaction (deposit or withdrawal)' }) @ApiResponse({ status: 201, description: 'Wallet transaction processed', type: ProcessWalletTransactionOutput }) diff --git a/apps/api/src/domain/policy/FeatureAvailabilityGuard.ts b/apps/api/src/domain/policy/FeatureAvailabilityGuard.ts new file mode 100644 index 000000000..56f5374d7 --- /dev/null +++ b/apps/api/src/domain/policy/FeatureAvailabilityGuard.ts @@ -0,0 +1,91 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PolicyService, ActionType, FeatureState } from './PolicyService'; +import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability'; + +type Evaluation = { allow: true } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' }; + +@Injectable() +export class FeatureAvailabilityGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly policyService: PolicyService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const handler = context.getHandler(); + const controllerClass = context.getClass(); + + const metadata = + this.reflector.getAllAndOverride( + FEATURE_AVAILABILITY_METADATA_KEY, + [handler, controllerClass], + ) ?? null; + + if (!metadata) { + return true; + } + + const snapshot = await this.policyService.getSnapshot(); + const decision = evaluate(snapshot, metadata); + + if (decision.allow) { + return true; + } + + if (decision.reason === 'maintenance') { + throw new ServiceUnavailableException('Service temporarily unavailable'); + } + + throw new NotFoundException('Not Found'); + } +} + +function evaluate( + snapshot: Awaited>, + metadata: FeatureAvailabilityMetadata, +): Evaluation { + if (snapshot.operationalMode === 'maintenance') { + const allowlist = metadata.actionType === 'mutate' + ? snapshot.maintenanceAllowlist.mutate + : snapshot.maintenanceAllowlist.view; + + if (!allowlist.includes(metadata.capabilityKey)) { + return { allow: false, reason: 'maintenance' }; + } + } + + const state = snapshot.capabilities[metadata.capabilityKey] ?? 'hidden'; + + if (state === 'enabled') { + return { allow: true }; + } + + // Coming soon is treated as "not found" on the public API for now (no disclosure). + if (state === 'coming_soon') { + return { allow: false, reason: 'coming_soon' }; + } + + if (state === 'disabled' || state === 'hidden') { + return { allow: false, reason: state }; + } + + return { allow: false, reason: 'not_configured' }; +} + +export function inferActionTypeFromHttpMethod(method: string): ActionType { + switch (method.toUpperCase()) { + case 'GET': + case 'HEAD': + case 'OPTIONS': + return 'view'; + default: + return 'mutate'; + } +} \ No newline at end of file diff --git a/apps/api/src/domain/policy/PolicyController.ts b/apps/api/src/domain/policy/PolicyController.ts new file mode 100644 index 000000000..797910519 --- /dev/null +++ b/apps/api/src/domain/policy/PolicyController.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from '@nestjs/common'; +import { Public } from '../auth/Public'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PolicyService, PolicySnapshot } from './PolicyService'; + +@ApiTags('policy') +@Public() +@Controller('policy') +export class PolicyController { + constructor(private readonly policyService: PolicyService) {} + + @Get('snapshot') + @ApiOperation({ summary: 'Get current feature availability policy snapshot (read-only)' }) + @ApiResponse({ status: 200, description: 'Policy snapshot', type: Object }) + async getSnapshot(): Promise { + return await this.policyService.getSnapshot(); + } +} \ No newline at end of file diff --git a/apps/api/src/domain/policy/PolicyModule.ts b/apps/api/src/domain/policy/PolicyModule.ts new file mode 100644 index 000000000..034e07a9c --- /dev/null +++ b/apps/api/src/domain/policy/PolicyModule.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PolicyController } from './PolicyController'; +import { PolicyService } from './PolicyService'; +import { FeatureAvailabilityGuard } from './FeatureAvailabilityGuard'; + +@Module({ + controllers: [PolicyController], + providers: [PolicyService, FeatureAvailabilityGuard], + exports: [PolicyService, FeatureAvailabilityGuard], +}) +export class PolicyModule {} \ No newline at end of file diff --git a/apps/api/src/domain/policy/PolicyService.ts b/apps/api/src/domain/policy/PolicyService.ts new file mode 100644 index 000000000..c1d5fcfa3 --- /dev/null +++ b/apps/api/src/domain/policy/PolicyService.ts @@ -0,0 +1,229 @@ +import { Injectable } from '@nestjs/common'; +import { readFile } from 'node:fs/promises'; + +export type OperationalMode = 'normal' | 'maintenance' | 'test'; +export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden'; +export type ActionType = 'view' | 'mutate'; + +export type PolicySnapshot = { + readonly policyVersion: number; + readonly operationalMode: OperationalMode; + readonly maintenanceAllowlist: { + readonly view: readonly string[]; + readonly mutate: readonly string[]; + }; + readonly capabilities: Readonly>; + readonly loadedFrom: 'env' | 'file' | 'defaults'; + readonly loadedAtIso: string; +}; + +const DEFAULT_POLICY_VERSION = 1; +const DEFAULT_CACHE_MS = 5_000; + +type RawPolicySnapshot = Partial<{ + policyVersion: number; + operationalMode: string; + maintenanceAllowlist: Partial<{ + view: unknown; + mutate: unknown; + }>; + capabilities: unknown; +}>; + +@Injectable() +export class PolicyService { + private cache: + | { + snapshot: PolicySnapshot; + expiresAtMs: number; + } + | null = null; + + async getSnapshot(): Promise { + const now = Date.now(); + if (this.cache && now < this.cache.expiresAtMs) { + return this.cache.snapshot; + } + + const cacheMs = parseCacheMs(process.env.GRIDPILOT_POLICY_CACHE_MS); + const loadedAtIso = new Date(now).toISOString(); + const { raw, loadedFrom } = await this.loadRawSnapshot(); + const snapshot = normalizeSnapshot(raw, loadedFrom, loadedAtIso); + + this.cache = { + snapshot, + expiresAtMs: now + cacheMs, + }; + + return snapshot; + } + + private async loadRawSnapshot(): Promise<{ + raw: RawPolicySnapshot; + loadedFrom: PolicySnapshot['loadedFrom']; + }> { + const policyPath = process.env.GRIDPILOT_POLICY_PATH; + + if (policyPath) { + const rawJson = await readFile(policyPath, 'utf8'); + return { + raw: JSON.parse(rawJson) as RawPolicySnapshot, + loadedFrom: 'file', + }; + } + + const anyEnvConfigured = + Boolean(process.env.GRIDPILOT_OPERATIONAL_MODE) || + Boolean(process.env.GRIDPILOT_FEATURES_JSON) || + Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW) || + Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE); + + const raw: RawPolicySnapshot = {}; + + const operationalMode = process.env.GRIDPILOT_OPERATIONAL_MODE; + if (operationalMode) { + raw.operationalMode = operationalMode; + } + + const capabilities = parseFeaturesJson(process.env.GRIDPILOT_FEATURES_JSON); + if (capabilities) { + raw.capabilities = capabilities; + } + + const maintenanceAllowView = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW); + const maintenanceAllowMutate = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE); + + if (maintenanceAllowView || maintenanceAllowMutate) { + raw.maintenanceAllowlist = { + ...(maintenanceAllowView ? { view: maintenanceAllowView } : {}), + ...(maintenanceAllowMutate ? { mutate: maintenanceAllowMutate } : {}), + }; + } + + return { + raw, + loadedFrom: anyEnvConfigured ? 'env' : 'defaults', + }; + } +} + +function normalizeSnapshot( + raw: RawPolicySnapshot, + loadedFrom: PolicySnapshot['loadedFrom'], + loadedAtIso: string, +): PolicySnapshot { + const operationalMode = parseOperationalMode(raw.operationalMode); + + const maintenanceAllowlistView = normalizeStringArray(raw.maintenanceAllowlist?.view); + const maintenanceAllowlistMutate = normalizeStringArray(raw.maintenanceAllowlist?.mutate); + + const capabilities = normalizeCapabilities(raw.capabilities); + + return { + policyVersion: + typeof raw.policyVersion === 'number' ? raw.policyVersion : DEFAULT_POLICY_VERSION, + operationalMode, + maintenanceAllowlist: { + view: maintenanceAllowlistView, + mutate: maintenanceAllowlistMutate, + }, + capabilities, + loadedFrom, + loadedAtIso, + }; +} + +function parseCacheMs(cacheMsRaw: string | undefined): number { + if (!cacheMsRaw) { + return DEFAULT_CACHE_MS; + } + const parsed = Number(cacheMsRaw); + if (!Number.isFinite(parsed) || parsed < 0) { + return DEFAULT_CACHE_MS; + } + return parsed; +} + +function parseOperationalMode(raw: string | undefined): OperationalMode { + switch (raw) { + case 'normal': + case 'maintenance': + case 'test': + return raw; + default: + return 'normal'; + } +} + +function normalizeCapabilities(input: unknown): Readonly> { + if (!input || typeof input !== 'object') { + return {}; + } + + const record = input as Record; + const normalized: Record = {}; + + for (const [key, value] of Object.entries(record)) { + if (!key) { + continue; + } + const state = parseFeatureState(value); + if (state) { + normalized[key] = state; + } + } + + return normalized; +} + +function parseFeatureState(value: unknown): FeatureState | null { + if (typeof value !== 'string') { + return null; + } + switch (value) { + case 'enabled': + case 'disabled': + case 'coming_soon': + case 'hidden': + return value; + default: + return null; + } +} + +function parseFeaturesJson(raw: string | undefined): unknown { + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +} + +function parseCsvList(raw: string | undefined): readonly string[] | undefined { + if (!raw) { + return undefined; + } + + const items = raw + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + + return items.length > 0 ? items : undefined; +} + +function normalizeStringArray(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + + const normalized = value + .filter((v) => typeof v === 'string') + .map((v) => v.trim()) + .filter(Boolean); + + return normalized; +} \ No newline at end of file diff --git a/apps/api/src/domain/policy/RequireCapability.ts b/apps/api/src/domain/policy/RequireCapability.ts new file mode 100644 index 000000000..48b7503cf --- /dev/null +++ b/apps/api/src/domain/policy/RequireCapability.ts @@ -0,0 +1,24 @@ +import { SetMetadata } from '@nestjs/common'; +import type { ActionType } from './PolicyService'; + +export const FEATURE_AVAILABILITY_METADATA_KEY = 'gridpilot:featureAvailability'; + +export type FeatureAvailabilityMetadata = { + readonly capabilityKey: string; + readonly actionType: ActionType; +}; + +/** + * Attach feature availability metadata to a controller or route handler. + * + * The backend Guard is authoritative enforcement (not UX). + */ +export function RequireCapability( + capabilityKey: string, + actionType: ActionType, +): MethodDecorator & ClassDecorator { + return SetMetadata(FEATURE_AVAILABILITY_METADATA_KEY, { + capabilityKey, + actionType, + } satisfies FeatureAvailabilityMetadata); +} \ No newline at end of file diff --git a/apps/api/src/domain/protests/ProtestsController.test.ts b/apps/api/src/domain/protests/ProtestsController.test.ts index 7d0e7759b..53b534db3 100644 --- a/apps/api/src/domain/protests/ProtestsController.test.ts +++ b/apps/api/src/domain/protests/ProtestsController.test.ts @@ -31,10 +31,12 @@ describe('ProtestsController', () => { const successDto = (dto: ReviewProtestResponseDTO): ReviewProtestResponseDTO => dto; describe('reviewProtest', () => { + const stewardUserId = 'steward-1'; + const req = { user: { userId: stewardUserId } }; + it('should call service and not throw on success', async () => { const protestId = 'protest-123'; - const body: Omit = { - stewardId: 'steward-1', + const body: Omit = { decision: 'uphold', decisionNotes: 'Reason', }; @@ -43,20 +45,19 @@ describe('ProtestsController', () => { successDto({ success: true, protestId, - stewardId: body.stewardId, + stewardId: stewardUserId, decision: body.decision, }), ); - await controller.reviewProtest(protestId, body); + await controller.reviewProtest(protestId, body, req); - expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, ...body }); + expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, stewardId: stewardUserId, ...body }); }); it('should throw NotFoundException when protest is not found', async () => { const protestId = 'protest-123'; - const body: Omit = { - stewardId: 'steward-1', + const body: Omit = { decision: 'uphold', decisionNotes: 'Reason', }; @@ -69,13 +70,12 @@ describe('ProtestsController', () => { }), ); - await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(NotFoundException); + await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(NotFoundException); }); it('should throw ForbiddenException when steward is not league admin', async () => { const protestId = 'protest-123'; - const body: Omit = { - stewardId: 'steward-1', + const body: Omit = { decision: 'uphold', decisionNotes: 'Reason', }; @@ -88,13 +88,12 @@ describe('ProtestsController', () => { }), ); - await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(ForbiddenException); + await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(ForbiddenException); }); it('should throw InternalServerErrorException for unexpected error codes', async () => { const protestId = 'protest-123'; - const body: Omit = { - stewardId: 'steward-1', + const body: Omit = { decision: 'uphold', decisionNotes: 'Reason', }; @@ -107,7 +106,7 @@ describe('ProtestsController', () => { }), ); - await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(InternalServerErrorException); + await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(InternalServerErrorException); }); }); }); diff --git a/apps/api/src/domain/protests/ProtestsController.ts b/apps/api/src/domain/protests/ProtestsController.ts index 3a0351805..e330a7ef6 100644 --- a/apps/api/src/domain/protests/ProtestsController.ts +++ b/apps/api/src/domain/protests/ProtestsController.ts @@ -1,9 +1,13 @@ -import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, Inject, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common'; +import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, Inject, InternalServerErrorException, NotFoundException, Param, Post, Req, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ProtestsService } from './ProtestsService'; import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO'; import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter'; +type AuthenticatedRequest = { + user?: { userId: string }; +}; + @ApiTags('protests') @Controller('protests') export class ProtestsController { @@ -16,9 +20,19 @@ export class ProtestsController { @ApiResponse({ status: 200, description: 'Protest reviewed successfully' }) async reviewProtest( @Param('protestId') protestId: string, - @Body() body: Omit, + @Body() body: Omit, + @Req() req: AuthenticatedRequest, ): Promise { - const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({ protestId, ...body }); + const userId = req.user?.userId; + if (!userId) { + throw new UnauthorizedException('Unauthorized'); + } + + const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({ + protestId, + stewardId: userId, + ...body, + }); if (!result.success) { switch (result.errorCode) { diff --git a/apps/api/src/domain/race/RaceController.test.ts b/apps/api/src/domain/race/RaceController.test.ts index ac7b900d6..3083871e0 100644 --- a/apps/api/src/domain/race/RaceController.test.ts +++ b/apps/api/src/domain/race/RaceController.test.ts @@ -1,7 +1,16 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; import { RaceController } from './RaceController'; import { RaceService } from './RaceService'; import { vi, Mocked } from 'vitest'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; describe('RaceController', () => { let controller: RaceController; @@ -70,4 +79,75 @@ describe('RaceController', () => { expect(result).toEqual(mockPresenter.viewModel); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [RaceController], + providers: [ + { + provide: RaceService, + useValue: { + getAllRaces: vi.fn(async () => ({ viewModel: { races: [], filters: { statuses: [], leagues: [] } } })), + registerForRace: vi.fn(async () => ({ viewModel: { success: true } })), + }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows @Public() read without a session', async () => { + await request(app.getHttpServer()).get('/races/all').expect(200); + }); + + it('denies mutation by default when not authenticated (401)', async () => { + await request(app.getHttpServer()).post('/races/r1/register').send({}).expect(401); + }); + + it('allows mutation when authenticated via session port', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + await request(app.getHttpServer()).post('/races/r1/register').send({}).expect(200); + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/race/RaceController.ts b/apps/api/src/domain/race/RaceController.ts index daa917492..ba568b23d 100644 --- a/apps/api/src/domain/race/RaceController.ts +++ b/apps/api/src/domain/race/RaceController.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query, Inject } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from '../auth/Public'; import { RaceService } from './RaceService'; import { AllRacesPageDTO } from './dtos/AllRacesPageDTO'; import { RaceStatsDTO } from './dtos/RaceStatsDTO'; @@ -24,6 +25,7 @@ import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO'; export class RaceController { constructor(@Inject(RaceService) private readonly raceService: RaceService) {} + @Public() @Get('all') @ApiOperation({ summary: 'Get all races' }) @ApiResponse({ status: 200, description: 'List of all races', type: AllRacesPageDTO }) @@ -32,6 +34,7 @@ export class RaceController { return presenter.viewModel; } + @Public() @Get('total-races') @ApiOperation({ summary: 'Get the total number of races' }) @ApiResponse({ status: 200, description: 'Total number of races', type: RaceStatsDTO }) @@ -40,6 +43,7 @@ export class RaceController { return presenter.viewModel; } + @Public() @Get('page-data') @ApiOperation({ summary: 'Get races page data' }) @ApiQuery({ name: 'leagueId', description: 'League ID' }) @@ -49,6 +53,7 @@ export class RaceController { return presenter.viewModel; } + @Public() @Get('all/page-data') @ApiOperation({ summary: 'Get all races page data' }) @ApiResponse({ status: 200, description: 'All races page data', type: AllRacesPageDTO }) @@ -57,6 +62,7 @@ export class RaceController { return presenter.viewModel; } + @Public() @Get(':raceId') @ApiOperation({ summary: 'Get race detail' }) @ApiParam({ name: 'raceId', description: 'Race ID' }) @@ -64,12 +70,14 @@ export class RaceController { @ApiResponse({ status: 200, description: 'Race detail', type: RaceDetailDTO }) async getRaceDetail( @Param('raceId') raceId: string, - @Query('driverId') driverId: string, + @Query('driverId') driverId?: string, ): Promise { - const presenter = await this.raceService.getRaceDetail({ raceId, driverId }); + const params = driverId ? { raceId, driverId } : { raceId }; + const presenter = await this.raceService.getRaceDetail(params); return await presenter.viewModel; } + @Public() @Get(':raceId/results') @ApiOperation({ summary: 'Get race results detail' }) @ApiParam({ name: 'raceId', description: 'Race ID' }) @@ -79,6 +87,7 @@ export class RaceController { return await presenter.viewModel; } + @Public() @Get(':raceId/sof') @ApiOperation({ summary: 'Get race with strength of field' }) @ApiParam({ name: 'raceId', description: 'Race ID' }) @@ -88,6 +97,7 @@ export class RaceController { return presenter.viewModel; } + @Public() @Get(':raceId/protests') @ApiOperation({ summary: 'Get race protests' }) @ApiParam({ name: 'raceId', description: 'Race ID' }) @@ -97,6 +107,7 @@ export class RaceController { return presenter.viewModel; } + @Public() @Get('reference/penalty-types') @ApiOperation({ summary: 'Get allowed penalty types and semantics' }) @ApiResponse({ status: 200, description: 'Penalty types reference', type: PenaltyTypesReferenceDTO }) @@ -104,6 +115,7 @@ export class RaceController { return this.raceService.getPenaltyTypesReference(); } + @Public() @Get(':raceId/penalties') @ApiOperation({ summary: 'Get race penalties' }) @ApiParam({ name: 'raceId', description: 'Race ID' }) diff --git a/apps/api/src/domain/race/dtos/GetRaceDetailParamsDTO.ts b/apps/api/src/domain/race/dtos/GetRaceDetailParamsDTO.ts index 7f6a05814..2076d38fc 100644 --- a/apps/api/src/domain/race/dtos/GetRaceDetailParamsDTO.ts +++ b/apps/api/src/domain/race/dtos/GetRaceDetailParamsDTO.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; export class GetRaceDetailParamsDTO { @ApiProperty() @@ -7,8 +7,8 @@ export class GetRaceDetailParamsDTO { @IsNotEmpty() raceId!: string; - @ApiProperty() + @ApiPropertyOptional() @IsString() - @IsNotEmpty() - driverId!: string; + @IsOptional() + driverId?: string; } \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/SponsorController.test.ts b/apps/api/src/domain/sponsor/SponsorController.test.ts index 17d4b6c61..8be821d95 100644 --- a/apps/api/src/domain/sponsor/SponsorController.test.ts +++ b/apps/api/src/domain/sponsor/SponsorController.test.ts @@ -1,14 +1,23 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; import { vi } from 'vitest'; import { SponsorController } from './SponsorController'; import { SponsorService } from './SponsorService'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; describe('SponsorController', () => { let controller: SponsorController; let sponsorService: ReturnType>; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + const moduleBuilder = Test.createTestingModule({ controllers: [SponsorController], providers: [ { @@ -31,7 +40,15 @@ describe('SponsorController', () => { }, }, ], - }).compile(); + }) + .overrideGuard(AuthenticationGuard) + .useValue({ canActivate: vi.fn().mockResolvedValue(true) }) + .overrideGuard(AuthorizationGuard) + .useValue({ canActivate: vi.fn().mockResolvedValue(true) }) + .overrideGuard(FeatureAvailabilityGuard) + .useValue({ canActivate: vi.fn().mockResolvedValue(true) }); + + const module: TestingModule = await moduleBuilder.compile(); controller = module.get(SponsorController); sponsorService = vi.mocked(module.get(SponsorService)); @@ -309,4 +326,112 @@ describe('SponsorController', () => { expect(sponsorService.updateSponsorSettings).toHaveBeenCalledWith(sponsorId, input); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'sponsors.portal': 'enabled' }, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [SponsorController], + providers: [ + { + provide: SponsorService, + useValue: { + getEntitySponsorshipPricing: vi.fn(async () => ({ entityType: 'season', entityId: 's1', pricing: [] })), + getSponsors: vi.fn(async () => ({ sponsors: [] })), + }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows @Public() endpoint without a session', async () => { + await request(app.getHttpServer()).get('/sponsors/pricing').expect(200); + }); + + it('denies protected endpoint when not authenticated (401)', async () => { + await request(app.getHttpServer()).get('/sponsors').expect(401); + }); + + it('returns 403 when authenticated but missing required role', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']); + + await request(app.getHttpServer()).get('/sponsors').expect(403); + }); + + it('returns 404 when role is satisfied but capability is disabled', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']); + vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'sponsors.portal': 'disabled' }, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }); + + await request(app.getHttpServer()).get('/sponsors').expect(404); + }); + + it('allows access when role is satisfied and capability is enabled', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']); + vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: { 'sponsors.portal': 'enabled' }, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }); + + await request(app.getHttpServer()).get('/sponsors').expect(200); + }); + }); }); diff --git a/apps/api/src/domain/sponsor/SponsorController.ts b/apps/api/src/domain/sponsor/SponsorController.ts index 07301ec93..da7307b49 100644 --- a/apps/api/src/domain/sponsor/SponsorController.ts +++ b/apps/api/src/domain/sponsor/SponsorController.ts @@ -1,5 +1,9 @@ import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query, Inject } from '@nestjs/common'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; +import { Public } from '../auth/Public'; +import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser'; +import { RequireRoles } from '../auth/RequireRoles'; +import { RequireCapability } from '../policy/RequireCapability'; import { SponsorService } from './SponsorService'; import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO'; import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO'; @@ -31,6 +35,7 @@ import type { RejectSponsorshipRequestResult } from '@core/racing/application/us export class SponsorController { constructor(@Inject(SponsorService) private readonly sponsorService: SponsorService) {} + @Public() @Get('pricing') @ApiOperation({ summary: 'Get sponsorship pricing for an entity' }) @ApiResponse({ @@ -43,6 +48,9 @@ export class SponsorController { } @Get() + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') @ApiOperation({ summary: 'Get all sponsors' }) @ApiResponse({ status: 200, @@ -54,6 +62,9 @@ export class SponsorController { } @Post() + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'mutate') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create a new sponsor' }) @ApiResponse({ @@ -66,6 +77,9 @@ export class SponsorController { } @Get('dashboard/:sponsorId') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') @ApiOperation({ summary: 'Get sponsor dashboard metrics and sponsored leagues' }) @ApiResponse({ status: 200, @@ -82,6 +96,9 @@ export class SponsorController { } @Get(':sponsorId/sponsorships') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') @ApiOperation({ summary: 'Get all sponsorships for a given sponsor', }) @@ -99,19 +116,10 @@ export class SponsorController { } as GetSponsorSponsorshipsQueryParamsDTO); } - @Get(':sponsorId') - @ApiOperation({ summary: 'Get a sponsor by ID' }) - @ApiResponse({ - status: 200, - description: 'Sponsor data', - type: GetSponsorOutputDTO, - }) - @ApiResponse({ status: 404, description: 'Sponsor not found' }) - async getSponsor(@Param('sponsorId') sponsorId: string): Promise { - return await this.sponsorService.getSponsor(sponsorId); - } - @Get('requests') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') @ApiOperation({ summary: 'Get pending sponsorship requests' }) @ApiResponse({ status: 200, @@ -129,7 +137,25 @@ export class SponsorController { ); } + @Get(':sponsorId') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') + @ApiOperation({ summary: 'Get a sponsor by ID' }) + @ApiResponse({ + status: 200, + description: 'Sponsor data', + type: GetSponsorOutputDTO, + }) + @ApiResponse({ status: 404, description: 'Sponsor not found' }) + async getSponsor(@Param('sponsorId') sponsorId: string): Promise { + return await this.sponsorService.getSponsor(sponsorId); + } + @Post('requests/:requestId/accept') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'mutate') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Accept a sponsorship request' }) @ApiResponse({ status: 200, description: 'Sponsorship request accepted' }) @@ -146,6 +172,9 @@ export class SponsorController { } @Post('requests/:requestId/reject') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'mutate') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Reject a sponsorship request' }) @ApiResponse({ status: 200, description: 'Sponsorship request rejected' }) @@ -163,6 +192,9 @@ export class SponsorController { } @Get('billing/:sponsorId') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') @ApiOperation({ summary: 'Get sponsor billing information' }) @ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object }) async getSponsorBilling( @@ -176,6 +208,9 @@ export class SponsorController { } @Get('leagues/available') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') @ApiOperation({ summary: 'Get available leagues for sponsorship' }) @ApiResponse({ status: 200, @@ -188,6 +223,9 @@ export class SponsorController { } @Get('leagues/:leagueId/detail') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') @ApiOperation({ summary: 'Get detailed league information for sponsors' }) @ApiResponse({ status: 200, description: 'League detail data', type: Object }) async getLeagueDetail( @@ -202,6 +240,9 @@ export class SponsorController { } @Get('settings/:sponsorId') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'view') @ApiOperation({ summary: 'Get sponsor settings' }) @ApiResponse({ status: 200, description: 'Sponsor settings', type: Object }) async getSponsorSettings( @@ -216,6 +257,9 @@ export class SponsorController { } @Put('settings/:sponsorId') + @RequireAuthenticatedUser() + @RequireRoles('admin') + @RequireCapability('sponsors.portal', 'mutate') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Update sponsor settings' }) @ApiResponse({ status: 200, description: 'Settings updated successfully' }) diff --git a/apps/api/src/domain/sponsor/SponsorModule.ts b/apps/api/src/domain/sponsor/SponsorModule.ts index 884f821a9..aed8eb43e 100644 --- a/apps/api/src/domain/sponsor/SponsorModule.ts +++ b/apps/api/src/domain/sponsor/SponsorModule.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/AuthModule'; +import { PolicyModule } from '../policy/PolicyModule'; import { SponsorService } from './SponsorService'; import { SponsorController } from './SponsorController'; import { SponsorProviders } from './SponsorProviders'; @Module({ + imports: [AuthModule, PolicyModule], controllers: [SponsorController], providers: SponsorProviders, exports: [SponsorService], diff --git a/apps/api/src/domain/team/TeamController.test.ts b/apps/api/src/domain/team/TeamController.test.ts index 2bcd7d1fd..574164159 100644 --- a/apps/api/src/domain/team/TeamController.test.ts +++ b/apps/api/src/domain/team/TeamController.test.ts @@ -1,9 +1,18 @@ +import 'reflect-metadata'; + +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; import { vi } from 'vitest'; import { TeamController } from './TeamController'; import { TeamService } from './TeamService'; import { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO'; import { UpdateTeamInput } from './dtos/TeamDto'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import type { AuthorizationService } from '../auth/AuthorizationService'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; +import type { PolicyService, PolicySnapshot } from '../policy/PolicyService'; describe('TeamController', () => { let controller: TeamController; @@ -146,4 +155,75 @@ describe('TeamController', () => { expect(response).toEqual(result); }); }); + + describe('auth guards (HTTP)', () => { + let app: any; + + const sessionPort: { getCurrentSession: () => Promise } = { + getCurrentSession: vi.fn(async () => null), + }; + + const authorizationService: Pick = { + getRolesForUser: vi.fn(() => []), + }; + + const policyService: Pick = { + getSnapshot: vi.fn(async (): Promise => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + })), + }; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [TeamController], + providers: [ + { + provide: TeamService, + useValue: { + getAll: vi.fn(async () => ({ teams: [], totalCount: 0 })), + getJoinRequests: vi.fn(async () => ({ requests: [], pendingCount: 0, totalCount: 0 })), + }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + + const reflector = new Reflector(); + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + it('allows @Public() endpoint without a session', async () => { + await request(app.getHttpServer()).get('/teams/all').expect(200); + }); + + it('denies non-public endpoint by default when not authenticated (401)', async () => { + await request(app.getHttpServer()).get('/teams/t1/join-requests').expect(401); + }); + + it('allows non-public endpoint when authenticated via session port', async () => { + vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({ + token: 't', + user: { id: 'user-1' }, + }); + + await request(app.getHttpServer()).get('/teams/t1/join-requests').expect(200); + }); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/team/TeamController.ts b/apps/api/src/domain/team/TeamController.ts index 53edb1a84..1626410b6 100644 --- a/apps/api/src/domain/team/TeamController.ts +++ b/apps/api/src/domain/team/TeamController.ts @@ -1,5 +1,6 @@ import { Controller, Get, Post, Patch, Body, Req, Param, Inject } from '@nestjs/common'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; +import { Public } from '../auth/Public'; type RequestWithUser = Record & { user?: { @@ -23,6 +24,7 @@ import { GetTeamMembershipOutputDTO } from './dtos/GetTeamMembershipOutputDTO'; export class TeamController { constructor(@Inject(TeamService) private readonly teamService: TeamService) {} + @Public() @Get('all') @ApiOperation({ summary: 'Get all teams' }) @ApiResponse({ status: 200, description: 'List of all teams', type: GetAllTeamsOutputDTO }) @@ -30,6 +32,7 @@ export class TeamController { return await this.teamService.getAll(); } + @Public() @Get(':teamId') @ApiOperation({ summary: 'Get team details' }) @ApiResponse({ status: 200, description: 'Team details', type: GetTeamDetailsOutputDTO }) @@ -39,6 +42,7 @@ export class TeamController { return await this.teamService.getDetails(teamId, userId); } + @Public() @Get(':teamId/members') @ApiOperation({ summary: 'Get team members' }) @ApiResponse({ status: 200, description: 'Team members', type: GetTeamMembersOutputDTO }) @@ -69,6 +73,7 @@ export class TeamController { return await this.teamService.update(teamId, input, userId); } + @Public() @Get('driver/:driverId') @ApiOperation({ summary: 'Get driver\'s team' }) @ApiResponse({ status: 200, description: 'Driver\'s team', type: GetDriverTeamOutputDTO }) @@ -77,6 +82,7 @@ export class TeamController { return await this.teamService.getDriverTeam(driverId); } + @Public() @Get(':teamId/members/:driverId') @ApiOperation({ summary: 'Get team membership for a driver' }) @ApiResponse({ status: 200, description: 'Team membership', type: GetTeamMembershipOutputDTO }) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0d680a3ad..b331ec7d8 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -7,6 +7,9 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { writeFileSync } from 'fs'; import { join } from 'path'; import { AppModule } from './app.module'; +import { AuthenticationGuard } from './domain/auth/AuthenticationGuard'; +import { AuthorizationGuard } from './domain/auth/AuthorizationGuard'; +import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard'; async function bootstrap() { const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined); @@ -19,6 +22,12 @@ async function bootstrap() { }), ); + app.useGlobalGuards( + app.get(AuthenticationGuard), + app.get(AuthorizationGuard), + app.get(FeatureAvailabilityGuard), + ); + // Swagger/OpenAPI configuration const config = new DocumentBuilder() .setTitle('GridPilot API') diff --git a/apps/website/app/sponsor/dashboard/page.tsx b/apps/website/app/sponsor/dashboard/page.tsx index 5c2a39209..14231b5f6 100644 --- a/apps/website/app/sponsor/dashboard/page.tsx +++ b/apps/website/app/sponsor/dashboard/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { motion, useReducedMotion, AnimatePresence } from 'framer-motion'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -44,13 +45,42 @@ import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardVie export default function SponsorDashboardPage() { const shouldReduceMotion = useReducedMotion(); - const { sponsorService } = useServices(); + const { sponsorService, policyService } = useServices(); const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d'); const [loading, setLoading] = useState(true); const [data, setData] = useState(null); const [error, setError] = useState(null); + const { + data: policySnapshot, + isLoading: policyLoading, + isError: policyError, + } = useQuery({ + queryKey: ['policySnapshot'], + queryFn: () => policyService.getSnapshot(), + staleTime: 60_000, + gcTime: 5 * 60_000, + }); + + const sponsorPortalState = policySnapshot + ? policyService.getCapabilityState(policySnapshot, 'sponsors.portal') + : null; + useEffect(() => { + if (policyLoading) { + return; + } + + if (policyError || sponsorPortalState !== 'enabled') { + setError( + sponsorPortalState === 'coming_soon' + ? 'Sponsor portal is coming soon.' + : 'Sponsor portal is currently unavailable.', + ); + setLoading(false); + return; + } + const loadDashboard = async () => { try { const dashboardData = await sponsorService.getSponsorDashboard('demo-sponsor-1'); @@ -67,8 +97,8 @@ export default function SponsorDashboardPage() { } }; - loadDashboard(); - }, []); + void loadDashboard(); + }, [policyLoading, policyError, sponsorPortalState, sponsorService]); if (loading) { return ( diff --git a/apps/website/components/profile/UserPill.tsx b/apps/website/components/profile/UserPill.tsx index be92ed060..c40258e7d 100644 --- a/apps/website/components/profile/UserPill.tsx +++ b/apps/website/components/profile/UserPill.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import React, { useEffect, useMemo, useState } from 'react'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; +import { CapabilityGate } from '@/components/shared/CapabilityGate'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/DriverViewModel'; @@ -190,40 +191,54 @@ export default function UserPill() { {/* Menu Items */} -
- setIsMenuOpen(false)} - > - - Dashboard - - setIsMenuOpen(false)} - > - - My Sponsorships - - setIsMenuOpen(false)} - > - - Billing - - setIsMenuOpen(false)} - > - - Settings - -
+ + Sponsor portal is currently unavailable. + + } + comingSoon={ +
+ Sponsor portal is coming soon. +
+ } + > +
+ setIsMenuOpen(false)} + > + + Dashboard + + setIsMenuOpen(false)} + > + + My Sponsorships + + setIsMenuOpen(false)} + > + + Billing + + setIsMenuOpen(false)} + > + + Settings + +
+
{/* Footer */}
diff --git a/apps/website/components/shared/CapabilityGate.tsx b/apps/website/components/shared/CapabilityGate.tsx new file mode 100644 index 000000000..1199ae406 --- /dev/null +++ b/apps/website/components/shared/CapabilityGate.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { ReactNode } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useServices } from '@/lib/services/ServiceProvider'; + +type CapabilityGateProps = { + capabilityKey: string; + children: ReactNode; + fallback?: ReactNode; + comingSoon?: ReactNode; +}; + +export function CapabilityGate({ + capabilityKey, + children, + fallback = null, + comingSoon = null, +}: CapabilityGateProps) { + const { policyService } = useServices(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['policySnapshot'], + queryFn: () => policyService.getSnapshot(), + staleTime: 60_000, + gcTime: 5 * 60_000, + }); + + if (isLoading || isError || !data) { + return <>{fallback}; + } + + const state = policyService.getCapabilityState(data, capabilityKey); + + if (state === 'enabled') { + return <>{children}; + } + + if (state === 'coming_soon') { + return <>{comingSoon ?? fallback}; + } + + return <>{fallback}; +} \ No newline at end of file diff --git a/apps/website/lib/api/policy/PolicyApiClient.ts b/apps/website/lib/api/policy/PolicyApiClient.ts new file mode 100644 index 000000000..a206547b3 --- /dev/null +++ b/apps/website/lib/api/policy/PolicyApiClient.ts @@ -0,0 +1,28 @@ +import { BaseApiClient } from '../base/BaseApiClient'; +import type { ErrorReporter } from '../../interfaces/ErrorReporter'; +import type { Logger } from '../../interfaces/Logger'; + +export type OperationalMode = 'normal' | 'maintenance' | 'test'; +export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden'; + +export type PolicySnapshotDto = { + policyVersion: number; + operationalMode: OperationalMode; + maintenanceAllowlist: { + view: string[]; + mutate: string[]; + }; + capabilities: Record; + loadedFrom: 'env' | 'file' | 'defaults'; + loadedAtIso: string; +}; + +export class PolicyApiClient extends BaseApiClient { + constructor(baseUrl: string, errorReporter: ErrorReporter, logger: Logger) { + super(baseUrl, errorReporter, logger); + } + + getSnapshot(): Promise { + return this.get('/policy/snapshot'); + } +} \ No newline at end of file diff --git a/apps/website/lib/blockers/CapabilityBlocker.ts b/apps/website/lib/blockers/CapabilityBlocker.ts new file mode 100644 index 000000000..0b0e3c1bc --- /dev/null +++ b/apps/website/lib/blockers/CapabilityBlocker.ts @@ -0,0 +1,66 @@ +import { Blocker } from './Blocker'; +import type { PolicySnapshotDto } from '../api/policy/PolicyApiClient'; +import { PolicyService } from '../services/policy/PolicyService'; + +export type CapabilityBlockReason = 'loading' | 'enabled' | 'coming_soon' | 'disabled' | 'hidden'; + +export class CapabilityBlocker extends Blocker { + private snapshot: PolicySnapshotDto | null = null; + + constructor( + private readonly policyService: PolicyService, + private readonly capabilityKey: string, + ) { + super(); + } + + updateSnapshot(snapshot: PolicySnapshotDto | null): void { + this.snapshot = snapshot; + } + + canExecute(): boolean { + return this.getReason() === 'enabled'; + } + + getReason(): CapabilityBlockReason { + if (!this.snapshot) { + return 'loading'; + } + + return this.policyService.getCapabilityState(this.snapshot, this.capabilityKey); + } + + block(): void { + this.snapshot = { + ...(this.snapshot ?? { + policyVersion: 0, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date().toISOString(), + }), + capabilities: { + ...(this.snapshot?.capabilities ?? {}), + [this.capabilityKey]: 'disabled', + }, + }; + } + + release(): void { + this.snapshot = { + ...(this.snapshot ?? { + policyVersion: 0, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date().toISOString(), + }), + capabilities: { + ...(this.snapshot?.capabilities ?? {}), + [this.capabilityKey]: 'enabled', + }, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/blockers/index.ts b/apps/website/lib/blockers/index.ts index 57941612c..b57218c3f 100644 --- a/apps/website/lib/blockers/index.ts +++ b/apps/website/lib/blockers/index.ts @@ -1,3 +1,4 @@ export { Blocker } from './Blocker'; +export { CapabilityBlocker } from './CapabilityBlocker'; export { SubmitBlocker } from './SubmitBlocker'; export { ThrottleBlocker } from './ThrottleBlocker'; \ No newline at end of file diff --git a/apps/website/lib/services/ServiceFactory.ts b/apps/website/lib/services/ServiceFactory.ts index 5f18f0726..c358485e7 100644 --- a/apps/website/lib/services/ServiceFactory.ts +++ b/apps/website/lib/services/ServiceFactory.ts @@ -9,6 +9,7 @@ import { AuthApiClient } from '../api/auth/AuthApiClient'; import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient'; import { MediaApiClient } from '../api/media/MediaApiClient'; import { DashboardApiClient } from '../api/dashboard/DashboardApiClient'; +import { PolicyApiClient } from '../api/policy/PolicyApiClient'; import { ProtestsApiClient } from '../api/protests/ProtestsApiClient'; import { PenaltiesApiClient } from '../api/penalties/PenaltiesApiClient'; import { PenaltyService } from './penalties/PenaltyService'; @@ -42,6 +43,7 @@ import { MembershipFeeService } from './payments/MembershipFeeService'; import { AuthService } from './auth/AuthService'; import { SessionService } from './auth/SessionService'; import { ProtestService } from './protests/ProtestService'; +import { PolicyService } from './policy/PolicyService'; import { OnboardingService } from './onboarding/OnboardingService'; /** @@ -67,6 +69,7 @@ export class ServiceFactory { analytics: AnalyticsApiClient; media: MediaApiClient; dashboard: DashboardApiClient; + policy: PolicyApiClient; protests: ProtestsApiClient; penalties: PenaltiesApiClient; }; @@ -85,6 +88,7 @@ export class ServiceFactory { analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger), media: new MediaApiClient(baseUrl, this.errorReporter, this.logger), dashboard: new DashboardApiClient(baseUrl, this.errorReporter, this.logger), + policy: new PolicyApiClient(baseUrl, this.errorReporter, this.logger), protests: new ProtestsApiClient(baseUrl, this.errorReporter, this.logger), penalties: new PenaltiesApiClient(baseUrl, this.errorReporter, this.logger), }; @@ -237,12 +241,19 @@ export class ServiceFactory { } /** - * Create DashboardService instance + * Create PolicyService instance */ - createDashboardService(): DashboardService { - return new DashboardService(this.apiClients.dashboard); + createPolicyService(): PolicyService { + return new PolicyService(this.apiClients.policy); } + /** + * Create DashboardService instance + */ + createDashboardService(): DashboardService { + return new DashboardService(this.apiClients.dashboard); + } + /** * Create MediaService instance */ diff --git a/apps/website/lib/services/ServiceProvider.tsx b/apps/website/lib/services/ServiceProvider.tsx index 716103393..07295d7cc 100644 --- a/apps/website/lib/services/ServiceProvider.tsx +++ b/apps/website/lib/services/ServiceProvider.tsx @@ -31,6 +31,7 @@ import { SponsorshipService } from './sponsors/SponsorshipService'; import { TeamJoinService } from './teams/TeamJoinService'; import { TeamService } from './teams/TeamService'; import { OnboardingService } from './onboarding/OnboardingService'; +import { PolicyService } from './policy/PolicyService'; import { LandingService } from './landing/LandingService'; export interface Services { @@ -60,6 +61,7 @@ export interface Services { protestService: ProtestService; penaltyService: PenaltyService; onboardingService: OnboardingService; + policyService: PolicyService; landingService: LandingService; } @@ -109,6 +111,7 @@ export function ServiceProvider({ children }: ServiceProviderProps) { protestService: serviceFactory.createProtestService(), penaltyService: serviceFactory.createPenaltyService(), onboardingService: serviceFactory.createOnboardingService(), + policyService: serviceFactory.createPolicyService(), landingService: serviceFactory.createLandingService(), }; }, []); diff --git a/apps/website/lib/services/policy/PolicyService.ts b/apps/website/lib/services/policy/PolicyService.ts new file mode 100644 index 000000000..a2d1e7542 --- /dev/null +++ b/apps/website/lib/services/policy/PolicyService.ts @@ -0,0 +1,17 @@ +import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '../../api/policy/PolicyApiClient'; + +export class PolicyService { + constructor(private readonly apiClient: PolicyApiClient) {} + + getSnapshot(): Promise { + return this.apiClient.getSnapshot(); + } + + getCapabilityState(snapshot: PolicySnapshotDto, capabilityKey: string): FeatureState { + return snapshot.capabilities[capabilityKey] ?? 'hidden'; + } + + isCapabilityEnabled(snapshot: PolicySnapshotDto, capabilityKey: string): boolean { + return this.getCapabilityState(snapshot, capabilityKey) === 'enabled'; + } +} \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceDetailUseCase.ts b/core/racing/application/use-cases/GetRaceDetailUseCase.ts index bd19511e4..7fc43f82d 100644 --- a/core/racing/application/use-cases/GetRaceDetailUseCase.ts +++ b/core/racing/application/use-cases/GetRaceDetailUseCase.ts @@ -14,7 +14,7 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit export type GetRaceDetailInput = { raceId: string; - driverId: string; + driverId?: string; }; // Backwards-compatible alias for older callers @@ -60,7 +60,7 @@ export class GetRaceDetailUseCase { const [league, registrations, membership] = await Promise.all([ this.leagueRepository.findById(race.leagueId), this.raceRegistrationRepository.findByRaceId(race.id), - this.leagueMembershipRepository.getMembership(race.leagueId, driverId), + driverId ? this.leagueMembershipRepository.getMembership(race.leagueId, driverId) : Promise.resolve(null), ]); const drivers = await Promise.all( @@ -69,14 +69,20 @@ export class GetRaceDetailUseCase { const validDrivers = drivers.filter((driver): driver is NonNullable => driver !== null); - const isUserRegistered = registrations.some(reg => reg.driverId.toString() === driverId); + const isUserRegistered = + typeof driverId === 'string' && driverId.length > 0 + ? registrations.some(reg => reg.driverId.toString() === driverId) + : false; + const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date(); const canRegister = - !!membership && membership.status.toString() === 'active' && isUpcoming; + typeof driverId === 'string' && driverId.length > 0 + ? !!membership && membership.status.toString() === 'active' && isUpcoming + : false; let userResult: RaceResult | null = null; - if (race.status === 'completed') { + if (race.status === 'completed' && typeof driverId === 'string' && driverId.length > 0) { const results = await this.resultRepository.findByRaceId(race.id); userResult = results.find(r => r.driverId.toString() === driverId) ?? null; } diff --git a/docs/architecture/AUTHORIZATION.md b/docs/architecture/AUTHORIZATION.md new file mode 100644 index 000000000..fa4942c87 --- /dev/null +++ b/docs/architecture/AUTHORIZATION.md @@ -0,0 +1,256 @@ +# Authorization (Roles + Permissions) + +This document defines the **authorization concept** for GridPilot, based on a clear role taxonomy and a permission-first model that scales to: +- system/global admins +- league-scoped admins/stewards +- sponsor-scoped admins +- team-scoped admins +- future “super admin” tooling + +It complements (but does not replace) feature availability: +- Feature availability answers: “Is this capability enabled at all?” +- Authorization answers: “Is this actor allowed to do it?” + +Related: +- Feature gating concept: docs/architecture/FEATURE_AVAILABILITY.md + + +--- + +## 1) Terms + +### 1.1 Actor +The authenticated user performing a request. + +### 1.2 Resource Scope +A resource boundary that defines where a role applies: +- **system**: global platform scope +- **league**: role applies only inside a league +- **sponsor**: role applies only inside a sponsor account +- **team**: role applies only inside a team + +### 1.3 Permission +A normalized action on a capability, expressed as: +- `capabilityKey` +- `actionType` (`view` or `mutate`) + +Examples: +- `league.admin.members` + `mutate` +- `league.stewarding.protests` + `view` +- `sponsors.portal` + `view` + + +--- + +## 2) Role Taxonomy (Canonical) + +These are the roles you described, organized by scope. + +### 2.1 System Roles (global) +- `owner` + Highest authority. Intended for a tiny set of internal operators. +- `admin` + Platform admin. Can manage most platform features. + +### 2.2 League Roles (scoped to a leagueId) +- `league_owner` + Full control over that league. +- `league_admin` + Admin control over that league. +- `league_steward` + Stewarding workflow privileges (protests, penalties, reviews), plus any explicitly granted admin powers. + +### 2.3 Sponsor Roles (scoped to a sponsorId) +- `sponsor_owner` + Full control over that sponsor account. +- `sponsor_admin` + Admin control for sponsor account operations. + +### 2.4 Team Roles (scoped to a teamId) +- `team_owner` + Full control over that team. +- `team_admin` + Admin control for team operations. + +### 2.5 Default Role +- `user` + Every authenticated account has this implicitly. + +Notes: +- “Role” is an access label; it is not a separate identity type. Admins, drivers, team captains are still “users”. + + +--- + +## 3) Role Composition Rules + +Authorization is evaluated with **role composition**: + +1) **System roles** apply everywhere. +2) **Scoped roles** apply only when the request targets that scope. + +Examples: +- A user can be `league_admin` in League A and just `user` in League B. +- A system `admin` is allowed even without scoped roles (unless an endpoint explicitly requires scoped membership). + + +--- + +## 4) Permission-First Model (Recommended) + +Instead of scattering checks like “is admin?” across controllers/services, define: +- a small, stable set of permissions (capabilityKey + actionType) +- a role → permission mapping table +- membership resolvers that answer: “what scoped roles does this actor have for this resourceId?” + +### 4.1 Why permission-first +- Centralizes security logic +- Makes audit/review simpler +- Avoids “new endpoint forgot a check” +- Enables future super-admin tooling by manipulating roles/permissions cleanly + + +--- + +## 5) Default Access Policy (Protect All Endpoints) + +To properly “protect all endpoints”, the platform must move to: + +### 5.1 Deny-by-default +- Every API route requires an authenticated actor **unless explicitly marked public**. + +### 5.2 Explicit public routes +A route is public only when explicitly marked as such (conceptually “Public metadata”). + +This prevents “we forgot to add guards” from becoming a security issue. + +### 5.3 Actor identity must not be caller-controlled +Any endpoint that currently accepts identifiers like: +- `performerDriverId` +- `adminId` +- `stewardId` +must stop trusting those fields and derive the actor identity from the authenticated session. + + +--- + +## 6) 403 vs 404 (Non-Disclosure Rules) + +Use different status codes for different security goals: + +### 6.1 Forbidden (403) +Return **403** when: +- the resource exists +- the actor is authenticated +- the actor lacks permission + +This is the normal authorization failure. + +### 6.2 Not Found (404) for non-disclosure +Return **404** when: +- revealing the existence of the resource would leak sensitive information +- the route is explicitly designated “non-disclosing” + +Use this sparingly and intentionally. + +### 6.3 Feature availability interaction +Feature availability failures (disabled/hidden/coming soon) should behave as “not found” for public callers, while maintenance mode should return 503. See docs/architecture/FEATURE_AVAILABILITY.md. + + +--- + +## 7) Suggested Role → Permission Mapping (First Pass) + +This table is a starting point (refine as product scope increases). + +### 7.1 System +- `owner`: all permissions +- `admin`: platform-admin permissions (payments admin, sponsor portal admin, moderation) + +### 7.2 League +- `league_owner`: all league permissions for that league +- `league_admin`: league management permissions (members, config, seasons, schedule, wallet) +- `league_steward`: stewarding permissions (review protests, apply penalties), and optionally limited admin view permissions + +### 7.3 Sponsor +- `sponsor_owner`: all sponsor permissions for that sponsor +- `sponsor_admin`: sponsor operational permissions (view dashboard, manage sponsorship requests, manage sponsor settings) + +### 7.4 Team +- `team_owner`: all team permissions for that team +- `team_admin`: team management permissions (update team, manage roster, handle join requests) + + +--- + +## 8) Membership Resolvers (Clean Architecture Boundary) + +Authorization needs a clean boundary for “does actor have a scoped role for this resource?” + +Conceptually: +- League membership repository answers: actor’s role in leagueId +- Team membership repository answers: actor’s role in teamId +- Sponsor membership repository answers: actor’s role in sponsorId + +This keeps persistence details out of controllers and allows in-memory adapters for tests. + + +--- + +## 9) Example Endpoint Policies (Conceptual) + +### 9.1 Public read +- Public league standings page: + - Feature availability: `league.public` view (if you want to gate) + - Authorization: public route (no login) + +### 9.2 League admin mutation +- Remove a member from league: + - Requires login + - Requires league scope + - Requires `league.admin.members` mutate + - Returns 403 if not allowed; 404 only if non-disclosure is intended + +### 9.3 Stewarding review +- Review protest: + - Requires login + - Requires league scope derived from the protest’s race/league + - Requires `league.stewarding.protests` mutate + - Actor must be derived from session, not from request body + +### 9.4 Payments +- Payments endpoints: + - Requires login + - Likely requires system `admin` or `owner` + + +--- + +## 10) Data Flow (Conceptual) + +```mermaid +flowchart LR + Req[HTTP Request] --> AuthN[Authenticate actor] + AuthN --> Scope[Resolve resource scope] + Scope --> Roles[Load actor roles for scope] + Roles --> Perms[Evaluate required permissions] + Perms --> Allow{Allow} + Allow -->|Yes| Handler[Route handler] + Allow -->|No| Deny[Deny 401 or 403 or 404] +``` + +Rules: +- AuthN attaches actor identity to the request. +- Scope resolution loads resource context (leagueId, teamId, sponsorId) from route params or from looked-up entities. +- Required permissions must be declared at the boundary (controller/route metadata). +- Deny-by-default means anything not marked public requires an actor. + + +--- + +## 11) What This Enables Later + +- A super-admin UI can manage: + - global roles (owner/admin) + - scoped roles (league_owner/admin/steward, sponsor_owner/admin, team_owner/admin) +- Feature availability remains a separate control plane (maintenance mode, coming soon, kill switches), documented in docs/architecture/FEATURE_AVAILABILITY.md. \ No newline at end of file diff --git a/docs/architecture/FEATURE_AVAILABILITY.md b/docs/architecture/FEATURE_AVAILABILITY.md new file mode 100644 index 000000000..caeb1247b --- /dev/null +++ b/docs/architecture/FEATURE_AVAILABILITY.md @@ -0,0 +1,315 @@ +# Feature Availability (Modes + Feature Flags) + +This document defines a clean, consistent system for enabling/disabling functionality across: +- API endpoints +- Website links/navigation +- Website components + +It is designed to support: +- test mode +- maintenance mode +- disabling features due to risk/issues +- coming soon features +- future super admin flag management + +It is aligned with the hard separation of responsibilities in `Blockers & Guards`: +- Frontend uses Blockers (UX best-effort) +- Backend uses Guards (authoritative enforcement) + +See: docs/architecture/BLOCKER_GUARDS.md + +--- + +## 1) Core Principle + +Availability is decided once, then applied in multiple places. + +- Backend Guards enforce availability for correctness and security. +- Frontend Blockers reflect availability for UX, but must never be relied on for enforcement. + +If it must be enforced, it is a Guard. +If it only improves UX, it is a Blocker. + +--- + +## 2) Definitions (Canonical Vocabulary) + +### 2.1 Operational Mode (system-level) +A small, global state representing operational posture. + +Recommended enum: +- normal +- maintenance +- test + +Operational Mode is: +- authoritative in backend +- typically environment-scoped +- required for rapid response (maintenance must be runtime-changeable) + +### 2.2 Feature State (capability-level) +A per-feature state machine (not a boolean). + +Recommended enum: +- enabled +- disabled +- coming_soon +- hidden + +Semantics: +- enabled: feature is available and advertised +- disabled: feature exists but must not be used (safety kill switch) +- coming_soon: may be visible in UI as teaser, but actions are blocked +- hidden: not visible/advertised; actions are blocked (safest default) + +### 2.3 Capability +A named unit of functionality (stable key) used consistently across API + website. + +Examples: +- races.create +- payments.checkout +- sponsor.portal +- stewarding.protests + +A capability key is a contract. + +### 2.4 Action Type +Availability decisions vary by the type of action: +- view: read-only operations (pages, GET endpoints) +- mutate: state-changing operations (POST/PUT/PATCH/DELETE) + +--- + +## 3) Policy Model (What Exists) + +### 3.1 FeatureAvailabilityPolicy (single evaluation model) +One evaluation function produces a decision. + +Inputs: +- environment (dev/test/prod) +- operationalMode (normal/maintenance/test) +- capabilityKey (string) +- actionType (view/mutate) +- actorContext (anonymous/authenticated; roles later) + +Outputs: +- allow: boolean +- publicReason: one of maintenance | disabled | coming_soon | hidden | not_configured +- uxHint: optional { messageKey, redirectPath, showTeaser } + +The same decision model is reused by: +- API Guard enforcement +- Website navigation visibility +- Website component rendering/disablement + +### 3.2 Precedence (where values come from) +To avoid “mystery behavior”, use strict precedence: + +1. runtime overrides (highest priority) +2. build-time environment configuration +3. code defaults (lowest priority, should be safe: hidden/disabled) + +Rationale: +- runtime overrides enable emergency response without rebuild +- env config enables environment-specific defaults +- code defaults keep behavior deterministic if config is missing + +--- + +## 4) Evaluation Rules (Deterministic, Explicit) + +### 4.1 Maintenance mode rules +Maintenance must be able to block the platform fast and consistently. + +Default behavior: +- mutate actions: denied unless explicitly allowlisted +- view actions: allowed only for a small allowlist (status page, login, health, static public routes) + +This creates a safe “fail closed” posture. + +Optional refinement: +- define a maintenance allowlist for critical reads (e.g., dashboards for operators) + +### 4.2 Test mode rules +Test mode should primarily exist in non-prod, and should be explicit in prod. + +Recommended behavior: +- In prod, test mode should not be enabled accidentally. +- In test environments, test mode may: + - enable test-only endpoints + - bypass external integrations (through adapters) + - relax rate limits + - expose test banners in UI (Blocker-level display) + +### 4.3 Feature state rules (per capability) +Given a capability state: + +- enabled: + - allow view + mutate (subject to auth/roles) + - visible in UI +- coming_soon: + - allow view of teaser pages/components + - deny mutate and deny sensitive reads + - visible in UI with Coming Soon affordances +- disabled: + - deny view + mutate + - hidden in nav by default +- hidden: + - deny view + mutate + - never visible in UI + +Note: +- “disabled” and “hidden” are both blocked; the difference is UI and information disclosure. + +### 4.4 Missing configuration +If a capability is not configured: +- treat as hidden (fail closed) +- optionally log a warning (server-side) + +--- + +## 5) Enforcement Mapping (Where Each Requirement Lives) + +This section is the “wiring contract” across layers. + +### 5.1 API endpoints (authoritative) +- Enforce via Backend Guards (NestJS CanActivate). +- Endpoints must declare the capability they require. + +Mapping to HTTP: +- maintenance: 503 Service Unavailable (preferred for global maintenance) +- disabled/hidden: 404 Not Found (avoid advertising unavailable capabilities) +- coming_soon: 404 Not Found publicly, or 409 Conflict internally if you want explicit semantics for trusted clients later + +Guideline: +- External clients should not get detailed feature availability information unless explicitly intended. + +### 5.2 Website links / navigation (UX) +- Enforce via Frontend Blockers. +- Hide links when state is disabled/hidden. +- For coming_soon, show link but route to teaser page or disable with explanation. + +Rules: +- Never assume hidden in UI equals enforced on server. +- UI should degrade gracefully (API may still block). + +### 5.3 Website components (UX) +- Use Blockers to: + - hide components for hidden/disabled + - show teaser content for coming_soon + - disable buttons or flows for coming_soon/disabled, with consistent messaging + +Recommendation: +- Provide a single reusable component (FeatureBlocker) that consumes policy decisions and renders: + - children when allowed + - teaser when coming_soon + - null or fallback when disabled/hidden + +--- + +## 6) Build-Time vs Runtime (Clean, Predictable) + +### 6.1 Build-time flags (require rebuild/redeploy) +What they are good for: +- preventing unfinished UI code from shipping in a bundle +- cutting entire routes/components from builds for deterministic releases + +Limitations: +- NEXT_PUBLIC_* values are compiled into the client bundle; changing them does not update clients without rebuild. + +Use build-time flags for: +- experimental UI +- “not yet shipped” components/routes +- simplifying deployments (pre-launch vs alpha style gating) + +### 6.2 Runtime flags (no rebuild) +What they are for: +- maintenance mode +- emergency disable for broken endpoints +- quickly hiding risky features + +Runtime flags must be available to: +- API Guards (always) +- Website SSR/middleware optionally +- Website client optionally (for UX only) + +Key tradeoff: +- runtime access introduces caching and latency concerns +- treat runtime policy reads as cached, fast, and resilient + +Recommended approach: +- API is authoritative source of runtime policy +- website can optionally consume a cached policy snapshot endpoint + +--- + +## 7) Storage and Distribution (Now + Future Super Admin) + +### 7.1 Now (no super admin UI) +Use a single “policy snapshot” stored in one place and read by the API, with caching. + +Options (in priority order): +1. Remote KV/DB-backed policy snapshot (preferred for true runtime changes) +2. Environment variable JSON (simpler, but changes require restart/redeploy) +3. Static config file in repo (requires rebuild/redeploy) + +### 7.2 Future (super admin UI) +Super admin becomes a writer to the same store. + +Non-negotiable: +- The storage schema must be stable and versioned. + +Recommended schema (conceptual): +- policyVersion +- operationalMode +- capabilities: map of capabilityKey -> featureState +- allowlists: maintenance view/mutate allowlists +- optional targeting rules later (by role/user) + +--- + +## 8) Data Flow (Conceptual) + +```mermaid +flowchart LR + UI[Website UI] --> FB[Frontend Blockers] + FB --> PC[Policy Client] + UI --> API[API Request] + API --> FG[Feature Guard] + FG --> AS[API Application Service] + AS --> UC[Core Use Case] + PC --> PS[Policy Snapshot] + FG --> PS +``` + +Interpretation: +- Website reads policy for UX (best-effort). +- API enforces policy (authoritative) before any application logic. + +--- + +## 9) Implementation Checklist (For Code Mode) + +Backend (apps/api): +- Define capability keys and feature states as shared types in a local module. +- Create FeaturePolicyService that resolves the current policy snapshot (cached). +- Add FeatureFlagGuard (or FeatureAvailabilityGuard) that: + - reads required capability metadata for an endpoint + - evaluates allow/deny with actionType + - maps denial to the chosen HTTP status codes + +Frontend (apps/website): +- Add a small PolicyClient that fetches policy snapshot from API (optional for phase 1). +- Add FeatureBlocker component for consistent UI behavior. +- Centralize navigation link definitions and filter them via policy. + +Ops/Config: +- Define how maintenance mode is toggled (KV/DB entry or config endpoint restricted to operators later). +- Ensure defaults are safe (fail closed). + +--- + +## 10) Non-Goals (Explicit) +- This system is not an authorization system. +- Roles/permissions are separate (but can be added as actorContext inputs later). +- Blockers never replace Guards. \ No newline at end of file