authentication authorization
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
164
apps/api/src/domain/auth/AuthGuards.http.test.ts
Normal file
164
apps/api/src/domain/auth/AuthGuards.http.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
|
||||
54
apps/api/src/domain/auth/AuthenticationGuard.test.ts
Normal file
54
apps/api/src/domain/auth/AuthenticationGuard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
30
apps/api/src/domain/auth/AuthenticationGuard.ts
Normal file
30
apps/api/src/domain/auth/AuthenticationGuard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
89
apps/api/src/domain/auth/AuthorizationGuard.test.ts
Normal file
89
apps/api/src/domain/auth/AuthorizationGuard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
67
apps/api/src/domain/auth/AuthorizationGuard.ts
Normal file
67
apps/api/src/domain/auth/AuthorizationGuard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
apps/api/src/domain/auth/AuthorizationService.ts
Normal file
76
apps/api/src/domain/auth/AuthorizationService.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
13
apps/api/src/domain/auth/Public.ts
Normal file
13
apps/api/src/domain/auth/Public.ts
Normal 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);
|
||||
}
|
||||
13
apps/api/src/domain/auth/RequireAuthenticatedUser.ts
Normal file
13
apps/api/src/domain/auth/RequireAuthenticatedUser.ts
Normal 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);
|
||||
}
|
||||
13
apps/api/src/domain/auth/RequireRoles.ts
Normal file
13
apps/api/src/domain/auth/RequireRoles.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user