Merge feat/keycloak into add_filters with manual conflict resolution.

Preserve Keycloak auth/RBAC contracts while retaining filter behavior and generator consistency for future regenerations.

Made-with: Cursor
This commit is contained in:
MaKarin
2026-03-24 13:52:20 +03:00
78 changed files with 2949 additions and 3880 deletions

View File

@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import { validateEnvironment } from './config/env.validation';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module';
@@ -7,7 +9,11 @@ import { EquipmentModule } from './modules/equipment/equipment.module';
import { RepairOrderModule } from './modules/repair-order/repair-order.module';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }),
imports: [ConfigModule.forRoot({
isGlobal: true,
validate: validateEnvironment,
}),
AuthModule,
PrismaModule,
HealthModule,
EquipmentTypeModule,

View File

@@ -0,0 +1,3 @@
export const IS_PUBLIC_KEY = 'isPublic';
export const ROLES_KEY = 'roles';

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
@Module({
providers: [
AuthService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,129 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { RuntimeEnvironment } from '../config/env.validation';
import {
AuthenticatedUser,
KeycloakJwtPayload,
} from './interfaces/authenticated-user.interface';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly issuerUrl: string;
private readonly audience: string;
private readonly explicitJwksUrl?: string;
private jwksResolverPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null =
null;
private jwksResolver: ReturnType<typeof createRemoteJWKSet> | null = null;
constructor(
private readonly configService: ConfigService<RuntimeEnvironment, true>,
) {
this.issuerUrl = this.configService.getOrThrow('KEYCLOAK_ISSUER_URL');
this.audience = this.configService.getOrThrow('KEYCLOAK_AUDIENCE');
this.explicitJwksUrl = this.configService.get('KEYCLOAK_JWKS_URL');
}
async verifyAccessToken(token: string): Promise<AuthenticatedUser> {
try {
const jwksResolver = await this.getJwksResolver();
const { payload } = await jwtVerify(token, jwksResolver, {
issuer: this.issuerUrl,
audience: this.audience,
});
return this.mapPayloadToUser(payload as KeycloakJwtPayload);
} catch (error) {
this.logger.warn(
`JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw new UnauthorizedException('Invalid or expired access token');
}
}
private mapPayloadToUser(payload: KeycloakJwtPayload): AuthenticatedUser {
if (!payload.sub) {
throw new UnauthorizedException('Token subject is missing');
}
const roles = Array.isArray(payload.realm_access?.roles)
? payload.realm_access.roles.filter(
(role): role is string => typeof role === 'string',
)
: [];
return {
sub: payload.sub,
username: payload.preferred_username,
name: payload.name,
email: payload.email,
roles,
claims: payload,
};
}
private async getJwksResolver() {
if (this.jwksResolver) {
return this.jwksResolver;
}
if (!this.jwksResolverPromise) {
this.jwksResolverPromise = this.createJwksResolver()
.then((resolver) => {
this.jwksResolver = resolver;
return resolver;
})
.finally(() => {
this.jwksResolverPromise = null;
});
}
return this.jwksResolverPromise;
}
private async createJwksResolver() {
const jwksUrl = await this.resolveJwksUrl();
this.logger.log(`Using JWKS URL: ${jwksUrl}`);
return createRemoteJWKSet(new URL(jwksUrl));
}
private async resolveJwksUrl(): Promise<string> {
if (this.explicitJwksUrl) {
return this.explicitJwksUrl;
}
const issuer = this.issuerUrl.replace(/\/+$/, '');
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
try {
const discoveryResponse = await fetch(discoveryUrl, {
headers: {
Accept: 'application/json',
},
});
if (discoveryResponse.ok) {
const discoveryDocument = (await discoveryResponse.json()) as {
jwks_uri?: string;
};
if (
typeof discoveryDocument.jwks_uri === 'string' &&
discoveryDocument.jwks_uri.trim().length > 0
) {
return discoveryDocument.jwks_uri;
}
}
} catch (error) {
this.logger.warn(
`OIDC discovery failed at ${discoveryUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
return `${issuer}/protocol/openid-connect/certs`;
}
}

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { IS_PUBLIC_KEY } from '../auth.constants';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import { ROLES_KEY } from '../auth.constants';
import { RealmRole } from '../roles/realm-role.enum';
export const Roles = (...roles: RealmRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,54 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../auth.constants';
import { AuthService } from '../auth.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly authService: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractBearerToken(request);
if (!token) {
throw new UnauthorizedException('Missing bearer token');
}
request.user = await this.authService.verifyAccessToken(token);
return true;
}
private extractBearerToken(request: Request): string | null {
const authorization = request.headers.authorization;
if (!authorization) {
return null;
}
const [scheme, token] = authorization.split(' ');
if (scheme?.toLowerCase() !== 'bearer' || !token) {
return null;
}
return token;
}
}

View File

@@ -0,0 +1,49 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { IS_PUBLIC_KEY, ROLES_KEY } from '../auth.constants';
import { RealmRole } from '../roles/realm-role.enum';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const requiredRoles =
this.reflector.getAllAndOverride<RealmRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]) ?? [];
if (requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const userRoles = request.user?.roles ?? [];
const hasRequiredRole = requiredRoles.some((role) =>
userRoles.includes(role),
);
if (!hasRequiredRole) {
throw new ForbiddenException('Access denied: insufficient role');
}
return true;
}
}

View File

@@ -0,0 +1,20 @@
import { JWTPayload } from 'jose';
export interface KeycloakJwtPayload extends JWTPayload {
preferred_username?: string;
name?: string;
email?: string;
realm_access?: {
roles?: string[];
};
}
export interface AuthenticatedUser {
sub: string;
username?: string;
name?: string;
email?: string;
roles: string[];
claims: KeycloakJwtPayload;
}

View File

@@ -0,0 +1,12 @@
import { AuthenticatedUser } from './authenticated-user.interface';
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
}
}
}
export {};

View File

@@ -0,0 +1,6 @@
export enum RealmRole {
Admin = 'admin',
Editor = 'editor',
Viewer = 'viewer',
}

View File

@@ -0,0 +1,58 @@
export interface RuntimeEnvironment {
PORT: number;
DATABASE_URL: string;
CORS_ALLOWED_ORIGINS: string;
KEYCLOAK_ISSUER_URL: string;
KEYCLOAK_AUDIENCE: string;
KEYCLOAK_JWKS_URL?: string;
}
function getRequiredString(
config: Record<string, unknown>,
key: keyof RuntimeEnvironment,
): string {
const value = config[key];
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value.trim();
}
function getOptionalString(
config: Record<string, unknown>,
key: keyof RuntimeEnvironment,
): string | undefined {
const value = config[key];
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parsePort(value: unknown): number {
if (value === undefined || value === null || value === '') {
return 3000;
}
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Environment variable PORT must be an integer between 1 and 65535');
}
return port;
}
export function validateEnvironment(
config: Record<string, unknown>,
): RuntimeEnvironment {
return {
PORT: parsePort(config.PORT),
DATABASE_URL: getRequiredString(config, 'DATABASE_URL'),
CORS_ALLOWED_ORIGINS: getRequiredString(config, 'CORS_ALLOWED_ORIGINS'),
KEYCLOAK_ISSUER_URL: getRequiredString(config, 'KEYCLOAK_ISSUER_URL'),
KEYCLOAK_AUDIENCE: getRequiredString(config, 'KEYCLOAK_AUDIENCE'),
KEYCLOAK_JWKS_URL: getOptionalString(config, 'KEYCLOAK_JWKS_URL'),
};
}

View File

@@ -1,5 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/decorators/public.decorator';
@Public()
@Controller('health')
export class HealthController {
@Get()

View File

@@ -1,12 +1,35 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { RuntimeEnvironment } from './config/env.validation';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
ConfigService,
);
const allowedOrigins = configService
.getOrThrow('CORS_ALLOWED_ORIGINS')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.enableCors({
origin: true,
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error(`Origin ${origin} is not allowed by CORS`), false);
},
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'],
exposedHeaders: ['Content-Range'],
credentials: false,
});
await app.listen(process.env.PORT ?? 3000);
const port = configService.get('PORT', 3000);
await app.listen(port);
}
bootstrap();

View File

@@ -1,5 +1,7 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RealmRole } from '../../auth/roles/realm-role.enum';
import { EquipmentTypeService } from './equipment-type.service';
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
@@ -8,6 +10,7 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
export class EquipmentTypeController {
constructor(private readonly service: EquipmentTypeService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.service.findAll(query);
@@ -16,21 +19,25 @@ export class EquipmentTypeController {
return res.json(result.data);
}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':code')
findOne(@Param('code') id: string) {
return this.service.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
create(@Body() dto: CreateEquipmentTypeDto) {
return this.service.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':code')
update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
return this.service.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':code')
remove(@Param('code') id: string) {
return this.service.remove(id);

View File

@@ -22,6 +22,7 @@ export class EquipmentTypeService {
const take = end - start;
const skip = start;
const sortField = query._sort || 'code';
const prismaSortField = sortField === 'id' ? 'code' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {};
@@ -50,11 +51,11 @@ export class EquipmentTypeService {
}
const [data, total] = await Promise.all([
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.equipmentType.count({ where }),
]);
const mapped = data.map((r: any) => ({ id: r.code, ...serializeRecord(r) }));
const mapped = data.map((item: any) => ({ id: item.code, ...serializeRecord(item) }));
return { data: mapped, total };
}
@@ -73,9 +74,8 @@ export class EquipmentTypeService {
}
async update(id: string, dto: UpdateEquipmentTypeDto) {
const data: any = { ...(dto as any) };
delete data.id;
delete data.code;
const { id: _pk, code, ...rest } = (dto as any);
const data: any = { ...rest };

View File

@@ -1,5 +1,7 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RealmRole } from '../../auth/roles/realm-role.enum';
import { EquipmentService } from './equipment.service';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
@@ -8,6 +10,7 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto';
export class EquipmentController {
constructor(private readonly service: EquipmentService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.service.findAll(query);
@@ -16,21 +19,25 @@ export class EquipmentController {
return res.json(result.data);
}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
create(@Body() dto: CreateEquipmentDto) {
return this.service.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
return this.service.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(id);

View File

@@ -24,6 +24,7 @@ export class EquipmentService {
const take = end - start;
const skip = start;
const sortField = query._sort || 'inventoryNumber';
const prismaSortField = sortField === 'id' ? 'id' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {};
@@ -57,7 +58,7 @@ export class EquipmentService {
}
const [data, total] = await Promise.all([
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.equipment.count({ where }),
]);

View File

@@ -1,5 +1,7 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RealmRole } from '../../auth/roles/realm-role.enum';
import { RepairOrderService } from './repair-order.service';
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
@@ -8,6 +10,7 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
export class RepairOrderController {
constructor(private readonly service: RepairOrderService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.service.findAll(query);
@@ -16,21 +19,25 @@ export class RepairOrderController {
return res.json(result.data);
}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
create(@Body() dto: CreateRepairOrderDto) {
return this.service.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
return this.service.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(id);

View File

@@ -24,6 +24,7 @@ export class RepairOrderService {
const take = end - start;
const skip = start;
const sortField = query._sort || 'number';
const prismaSortField = sortField === 'id' ? 'id' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {};
@@ -55,7 +56,7 @@ export class RepairOrderService {
}
const [data, total] = await Promise.all([
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.repairOrder.count({ where }),
]);