feat(api): master-login stub + WorkspacePrismaPool (Frente E)
- Prisma 7: prisma.config.ts com datasource.url (API correta); schema gerado em CJS - WorkspacePrismaPool: LRU cache (max 10) de PrismaClient por workspace (ADR 0006) PrismaPg adapter + pg.Pool por workspace; getOrCreate/health/onModuleDestroy - JwtAuthGuard: global APP_GUARD, jose HS256, popula CLS com workspace_id/userId/prisma @Public() decorator marca ping/health/dev-auth como rotas abertas - DevAuthController: POST /auth/dev/token — emite JWT dev (404 em produção) - AuthTokenResponseSchema + DevTokenRequestSchema em @sar/api-interface - WorkspacePoolHealthIndicator: health/ready reporta amostra LRU top-3 (nunca O(N)) - .npmrc: hoist @prisma/client-runtime-utils (requerido pelo Prisma 7 isolated mode) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
12
apps/api/src/app/auth/auth.module.ts
Normal file
12
apps/api/src/app/auth/auth.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { DevAuthController } from './dev-auth.controller';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceModule],
|
||||
controllers: [DevAuthController],
|
||||
providers: [JwtAuthGuard],
|
||||
exports: [JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
46
apps/api/src/app/auth/dev-auth.controller.ts
Normal file
46
apps/api/src/app/auth/dev-auth.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, NotFoundException, Post } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SignJWT } from 'jose';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { DevTokenRequestSchema, type AuthTokenResponse } from '@sar/api-interface';
|
||||
import type { Env } from '../config/env.schema';
|
||||
import { Public } from './public.decorator';
|
||||
|
||||
class DevTokenRequestDto extends createZodDto(DevTokenRequestSchema) {}
|
||||
|
||||
// Dev-only stub — emite JWT HS256 para smoke tests locais.
|
||||
// CODING-RULES PGD-SEC-002: retorna 404 em produção.
|
||||
// CODING-RULES PGD-AUTHZ-002: workspace_id vem do body aqui APENAS porque
|
||||
// este endpoint É o gerador do token — nenhum outro handler pode fazer isso.
|
||||
|
||||
@Public()
|
||||
@Controller({ path: 'auth/dev' })
|
||||
export class DevAuthController {
|
||||
private readonly secret: Uint8Array;
|
||||
private readonly expiresIn: number;
|
||||
private readonly isProd: boolean;
|
||||
|
||||
constructor(config: ConfigService<Env, true>) {
|
||||
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
||||
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
||||
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async token(@Body() dto: DevTokenRequestDto): Promise<AuthTokenResponse> {
|
||||
if (this.isProd) throw new NotFoundException();
|
||||
|
||||
const accessToken = await new SignJWT({
|
||||
workspace_id: dto.workspaceId,
|
||||
role: dto.role,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(dto.userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${this.expiresIn}s`)
|
||||
.sign(this.secret);
|
||||
|
||||
return { accessToken, tokenType: 'Bearer', expiresIn: this.expiresIn };
|
||||
}
|
||||
}
|
||||
79
apps/api/src/app/auth/jwt-auth.guard.ts
Normal file
79
apps/api/src/app/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { jwtVerify } from 'jose';
|
||||
import type { Request } from 'express';
|
||||
import type { Env } from '../config/env.schema';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service';
|
||||
import type { JwtPayload } from './jwt.types';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
// Guard global (APP_GUARD). Valida Bearer HS256 e atualiza CLS com workspace real.
|
||||
// CODING-RULES PGD-AUTHZ-002: workspaceId sempre do JWT, nunca de body/param.
|
||||
// Ordem NestJS: middleware CLS (workspace default) → este guard (workspace real).
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
private readonly secret: Uint8Array;
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
private readonly pool: WorkspacePrismaPool,
|
||||
config: ConfigService<Env, true>,
|
||||
) {
|
||||
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (this.isPublic(context)) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractBearer(req);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('token ausente');
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify<JwtPayload>(token, this.secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
||||
|
||||
// Sobrescreve CLS com workspace real do JWT (corre depois do middleware).
|
||||
const workspaceId = payload.workspace_id;
|
||||
this.cls.set('workspaceId', workspaceId);
|
||||
this.cls.set('userId', payload.sub);
|
||||
|
||||
const dbUrl =
|
||||
process.env['DATABASE_URL'] ??
|
||||
`postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_${workspaceId}`;
|
||||
this.cls.set('prisma', this.pool.getOrCreate(workspaceId, dbUrl));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('token inválido ou expirado');
|
||||
}
|
||||
}
|
||||
|
||||
private isPublic(ctx: ExecutionContext): boolean {
|
||||
return (
|
||||
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
ctx.getHandler(),
|
||||
ctx.getClass(),
|
||||
]) === true
|
||||
);
|
||||
}
|
||||
|
||||
private extractBearer(req: Request): string | undefined {
|
||||
const auth = req.headers['authorization'];
|
||||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
17
apps/api/src/app/auth/jwt.types.ts
Normal file
17
apps/api/src/app/auth/jwt.types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Claims do JWT emitido pelo master-login. Fonte da verdade para req.user.
|
||||
// CODING-RULES PGD-AUTHZ-002: workspace_id vem sempre do token, nunca do body.
|
||||
|
||||
export type JwtRole = 'rep' | 'supervisor' | 'manager' | 'admin';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string; // userId
|
||||
workspace_id: string;
|
||||
role: JwtRole;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// Tipo auxiliar para requests autenticados — evita global namespace augmentation.
|
||||
export interface AuthenticatedRequest {
|
||||
user: JwtPayload;
|
||||
}
|
||||
6
apps/api/src/app/auth/public.decorator.ts
Normal file
6
apps/api/src/app/auth/public.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
/** Marca um controller ou handler como público — JwtAuthGuard não exige token. */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
Reference in New Issue
Block a user