authentication authorization

This commit is contained in:
2025-12-26 15:32:22 +01:00
parent 68ae9da22a
commit 64377de548
54 changed files with 2833 additions and 95 deletions

View File

@@ -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<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
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);
});
});
});

View File

@@ -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<AuthSessionDTO> {

View File

@@ -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<null | { token: string; user: { id: string } }>;
};
describe('Auth guards (HTTP)', () => {
let app: any;
const sessionPort: SessionPort = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
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);
});
});

View File

@@ -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 {}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { AuthenticationGuard } from './AuthenticationGuard';
function createExecutionContext(request: Record<string, unknown>) {
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();
});
});

View File

@@ -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<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (request.user?.userId) {
return true;
}
const session = await this.sessionPort.getCurrentSession();
if (session?.user?.id) {
request.user = { userId: session.user.id };
}
return true;
}
}

View File

@@ -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);
});
});

View File

@@ -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<RequireRolesMetadata | undefined>(
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<AuthenticatedRequest>();
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;
}
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
export type SystemRole = string;
@Injectable()
export class AuthorizationService {
private cache:
| {
rolesByUserId: Readonly<Record<string, readonly SystemRole[]>>;
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<Record<string, readonly SystemRole[]>> {
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return {};
}
const record = parsed as Record<string, unknown>;
const normalized: Record<string, readonly SystemRole[]> = {};
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 {};
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}