Phase 3 Completed
This commit is contained in:
+13
-2
@@ -3,6 +3,11 @@ import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { IdentityModule } from './modules/identity/identity.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { AuthorizationModule } from './modules/authorization/authorization.module';
|
||||
import { AuditModule } from './modules/audit/audit.module';
|
||||
import { ApplicationModule } from './modules/application/application.module';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -10,8 +15,14 @@ import { AuthModule } from './modules/auth/auth.module';
|
||||
isGlobal: true,
|
||||
}),
|
||||
|
||||
IdentityModule,
|
||||
AuthModule,
|
||||
IdentityModule,
|
||||
AuthModule,
|
||||
HealthModule,
|
||||
// Authorization module provides permission checks and cache
|
||||
AuthorizationModule,
|
||||
AuditModule,
|
||||
ApplicationModule,
|
||||
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { EventEnvelope } from './event.interface';
|
||||
|
||||
type Handler = (event: EventEnvelope<any>) => Promise<void> | void;
|
||||
|
||||
@Injectable()
|
||||
export class EventBus {
|
||||
private handlers: Map<string, Handler[]> = new Map();
|
||||
private readonly logger = new Logger(EventBus.name);
|
||||
|
||||
publish(event: EventEnvelope<any>) {
|
||||
const handlers = this.handlers.get(event.type) || [];
|
||||
for (const h of handlers) {
|
||||
try {
|
||||
Promise.resolve(h(event)).catch((err) => this.logger.error('Event handler error', err));
|
||||
} catch (e) {
|
||||
this.logger.error('Event handler threw', e as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(eventType: string, handler: Handler) {
|
||||
const list = this.handlers.get(eventType) || [];
|
||||
list.push(handler);
|
||||
this.handlers.set(eventType, list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface EventEnvelope<T = any> {
|
||||
id: string;
|
||||
timestamp: string; // ISO
|
||||
type: string; // PascalCase event type
|
||||
payload: T;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma.service';
|
||||
import { ApplicationsController } from './presentation/controllers/applications.controller';
|
||||
import { PrismaApplicationRepository } from './infrastructure/repositories/prisma-application.repository';
|
||||
import { IApplication } from './domain/repositories/application.interface';
|
||||
import { GetApplicationsHandler } from './application/handlers/get-applications.handler';
|
||||
import { GetApplicationHandler } from './application/handlers/get-application.handler';
|
||||
import { CreateApplicationHandler } from './application/handlers/create-application.handler';
|
||||
import { UpdateApplicationHandler } from './application/handlers/update-application.handler';
|
||||
import { DeleteApplicationHandler } from './application/handlers/delete-application.handler';
|
||||
import { GetMeApplicationsHandler } from './application/handlers/get-me-applications.handler';
|
||||
import { ApplicationValidator } from './application/validators/application.validator';
|
||||
import { JwtAuthGuard } from '../../core/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUserGuard } from '../identity/presentation/guards/current-user.guard';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
PrismaService,
|
||||
PrismaApplicationRepository,
|
||||
GetApplicationsHandler,
|
||||
GetApplicationHandler,
|
||||
CreateApplicationHandler,
|
||||
UpdateApplicationHandler,
|
||||
DeleteApplicationHandler,
|
||||
GetMeApplicationsHandler,
|
||||
ApplicationValidator,
|
||||
JwtAuthGuard,
|
||||
CurrentUserGuard,
|
||||
{
|
||||
provide: IApplication,
|
||||
useClass: PrismaApplicationRepository,
|
||||
},
|
||||
],
|
||||
controllers: [ApplicationsController],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { IApplication } from '../../domain/repositories/application.interface';
|
||||
import { ApplicationValidator } from '../../application/validators/application.validator';
|
||||
import { ApplicationData } from '../../domain/entities/application.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CreateApplicationHandler {
|
||||
constructor(private readonly appRepo: IApplication, private readonly validator: ApplicationValidator) {}
|
||||
|
||||
async execute(payload: any) {
|
||||
await this.validator.validateCreate(payload);
|
||||
|
||||
const app = ApplicationData.create({
|
||||
code: payload.code,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
icon: payload.icon,
|
||||
url: payload.url,
|
||||
applicationsClaim: payload.applicationsClaim,
|
||||
displayOrder: payload.displayOrder,
|
||||
});
|
||||
|
||||
const created = await this.appRepo.create(app);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IApplication } from '../../domain/repositories/application.interface';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteApplicationHandler {
|
||||
constructor(private readonly appRepo: IApplication) {}
|
||||
|
||||
async execute(id: string) {
|
||||
// ensure exists
|
||||
await this.appRepo.findById(id);
|
||||
await this.appRepo.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IApplication } from '../../domain/repositories/application.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GetApplicationHandler {
|
||||
constructor(private readonly appRepo: IApplication) {}
|
||||
|
||||
async execute(id: string) {
|
||||
const app = await this.appRepo.findById(id);
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IApplication } from '../../domain/repositories/application.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GetApplicationsHandler {
|
||||
constructor(private readonly appRepo: IApplication) {}
|
||||
|
||||
async execute(query: { page?: number; limit?: number; search?: string }) {
|
||||
const res = await this.appRepo.find({ page: query.page, limit: query.limit, search: query.search || null });
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IApplication } from '../../domain/repositories/application.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GetMeApplicationsHandler {
|
||||
constructor(private readonly appRepo: IApplication) {}
|
||||
|
||||
async execute(applicationsClaimList: string[] | null | undefined) {
|
||||
if (!applicationsClaimList || applicationsClaimList.length === 0) return { data: [], total: 0 };
|
||||
|
||||
// Only return active applications whose applicationsClaim exists in provided list
|
||||
const res = await this.appRepo.find({ page: 1, limit: 1000, isActive: true, applicationsClaimIn: applicationsClaimList });
|
||||
// sort by displayOrder asc
|
||||
res.data.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IApplication } from '../../domain/repositories/application.interface';
|
||||
import { ApplicationValidator } from '../../application/validators/application.validator';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateApplicationHandler {
|
||||
constructor(private readonly appRepo: IApplication, private readonly validator: ApplicationValidator) {}
|
||||
|
||||
async execute(id: string, payload: any) {
|
||||
const existing = await this.appRepo.findById(id);
|
||||
if (!existing) throw new Error('Application not found');
|
||||
|
||||
await this.validator.validateUpdate(id, payload);
|
||||
|
||||
existing.update(payload);
|
||||
const updated = await this.appRepo.update(existing);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { IApplication } from '../../domain/repositories/application.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationValidator {
|
||||
constructor(private readonly appRepo: IApplication) {}
|
||||
|
||||
async validateCreate(payload: any) {
|
||||
if (!payload || !payload.code) throw new BadRequestException('code is required');
|
||||
if (!payload.name) throw new BadRequestException('name is required');
|
||||
if (!payload.applicationsClaim) throw new BadRequestException('applicationsClaim is required');
|
||||
|
||||
// url validation if provided
|
||||
if (payload.url) {
|
||||
try {
|
||||
// allow relative urls
|
||||
if (!payload.url.startsWith('/') && !payload.url.startsWith('http')) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
// new URL(payload.url) // avoid throwing for relative
|
||||
} catch (e) {
|
||||
throw new BadRequestException('invalid url');
|
||||
}
|
||||
}
|
||||
|
||||
// displayOrder
|
||||
if (payload.displayOrder !== undefined && typeof payload.displayOrder !== 'number') {
|
||||
throw new BadRequestException('invalid displayOrder');
|
||||
}
|
||||
|
||||
// unique code
|
||||
const byCode = await this.appRepo.findByCode(payload.code);
|
||||
if (byCode) throw new BadRequestException('duplicate code');
|
||||
|
||||
const byClaim = await this.appRepo.findByApplicationsClaim(payload.applicationsClaim);
|
||||
if (byClaim) throw new BadRequestException('duplicate applicationsClaim');
|
||||
}
|
||||
|
||||
async validateUpdate(id: string, payload: any) {
|
||||
if (!payload) return;
|
||||
|
||||
if (payload.url) {
|
||||
try {
|
||||
if (!payload.url.startsWith('/') && !payload.url.startsWith('http')) throw new Error('invalid');
|
||||
} catch (e) {
|
||||
throw new BadRequestException('invalid url');
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.displayOrder !== undefined && typeof payload.displayOrder !== 'number') {
|
||||
throw new BadRequestException('invalid displayOrder');
|
||||
}
|
||||
|
||||
if (payload.code) {
|
||||
const existing = await this.appRepo.findByCode(payload.code);
|
||||
if (existing && existing.id !== id) throw new BadRequestException('duplicate code');
|
||||
}
|
||||
|
||||
if (payload.applicationsClaim) {
|
||||
const existing = await this.appRepo.findByApplicationsClaim(payload.applicationsClaim);
|
||||
if (existing && existing.id !== id) throw new BadRequestException('duplicate applicationsClaim');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export class ApplicationData {
|
||||
private constructor(
|
||||
public readonly id: string,
|
||||
public code: string,
|
||||
public name: string,
|
||||
public description: string | null,
|
||||
public icon: string | null,
|
||||
public url: string | null,
|
||||
public applicationsClaim: string,
|
||||
public isActive: boolean,
|
||||
public displayOrder: number,
|
||||
public createdAt: Date | null,
|
||||
public updatedAt: Date | null,
|
||||
) {}
|
||||
|
||||
static create(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
url?: string | null;
|
||||
applicationsClaim: string;
|
||||
displayOrder?: number;
|
||||
}) {
|
||||
return new ApplicationData(
|
||||
crypto.randomUUID(),
|
||||
data.code,
|
||||
data.name,
|
||||
data.description ?? null,
|
||||
data.icon ?? null,
|
||||
data.url ?? null,
|
||||
data.applicationsClaim,
|
||||
true,
|
||||
data.displayOrder ?? 0,
|
||||
new Date(),
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
static restore(props: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
url?: string | null;
|
||||
applicationsClaim: string;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
createdAt?: Date | null;
|
||||
updatedAt?: Date | null;
|
||||
}) {
|
||||
return new ApplicationData(
|
||||
props.id,
|
||||
props.code,
|
||||
props.name,
|
||||
props.description ?? null,
|
||||
props.icon ?? null,
|
||||
props.url ?? null,
|
||||
props.applicationsClaim,
|
||||
props.isActive !== undefined ? props.isActive : true,
|
||||
props.displayOrder ?? 0,
|
||||
props.createdAt ?? null,
|
||||
props.updatedAt ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
update(data: {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
url?: string | null;
|
||||
applicationsClaim?: string;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}) {
|
||||
if (data.code !== undefined) this.code = data.code;
|
||||
if (data.name !== undefined) this.name = data.name;
|
||||
if (data.description !== undefined) this.description = data.description;
|
||||
if (data.icon !== undefined) this.icon = data.icon;
|
||||
if (data.url !== undefined) this.url = data.url;
|
||||
if (data.applicationsClaim !== undefined) this.applicationsClaim = data.applicationsClaim;
|
||||
if (data.isActive !== undefined) this.isActive = data.isActive;
|
||||
if (data.displayOrder !== undefined) this.displayOrder = data.displayOrder;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
toResponse() {
|
||||
return {
|
||||
id: this.id,
|
||||
code: this.code,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
icon: this.icon,
|
||||
url: this.url,
|
||||
displayOrder: this.displayOrder,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApplicationData } from '../entities/application.entity';
|
||||
|
||||
export abstract class IApplication {
|
||||
abstract find(params: { page?: number; limit?: number; search?: string | null; isActive?: boolean | null; applicationsClaimIn?: string[] | null }): Promise<{ data: ApplicationData[]; total: number }>;
|
||||
abstract findById(id: string): Promise<ApplicationData>;
|
||||
abstract findByCode(code: string): Promise<ApplicationData | null>;
|
||||
abstract findByApplicationsClaim(claim: string): Promise<ApplicationData | null>;
|
||||
|
||||
abstract create(app: ApplicationData): Promise<ApplicationData>;
|
||||
abstract update(app: ApplicationData): Promise<ApplicationData>;
|
||||
abstract delete(appId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApplicationData } from '../../domain/entities/application.entity';
|
||||
|
||||
export class PrismaApplicationMapper {
|
||||
static toDomain(model: any): ApplicationData {
|
||||
return ApplicationData.restore({
|
||||
id: model.id,
|
||||
code: model.code,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
icon: model.icon,
|
||||
url: model.url,
|
||||
applicationsClaim: model.applicationsClaim,
|
||||
isActive: model.isActive,
|
||||
displayOrder: model.displayOrder,
|
||||
createdAt: model.createdAt,
|
||||
updatedAt: model.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../../shared/prisma.service';
|
||||
import { IApplication } from '../../domain/repositories/application.interface';
|
||||
import { PrismaApplicationMapper } from '../mappers/prisma-application.mapper';
|
||||
import { ApplicationData } from '../../domain/entities/application.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaApplicationRepository implements IApplication {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async find(params: { page?: number; limit?: number; search?: string | null; isActive?: boolean | null; applicationsClaimIn?: string[] | null }) {
|
||||
const page = params.page && params.page > 0 ? params.page : 1;
|
||||
const limit = params.limit && params.limit > 0 ? params.limit : 25;
|
||||
|
||||
const where: any = {};
|
||||
if (params.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: params.search, mode: 'insensitive' } },
|
||||
{ code: { contains: params.search, mode: 'insensitive' } },
|
||||
{ description: { contains: params.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (params.isActive !== undefined && params.isActive !== null) {
|
||||
where.isActive = params.isActive;
|
||||
}
|
||||
|
||||
if (params.applicationsClaimIn && params.applicationsClaimIn.length > 0) {
|
||||
where.applicationsClaim = { in: params.applicationsClaimIn };
|
||||
}
|
||||
|
||||
const [total, items] = await Promise.all([
|
||||
this.prisma.application.count({ where }),
|
||||
this.prisma.application.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { displayOrder: 'asc', createdAt: 'asc' } }),
|
||||
]);
|
||||
|
||||
return { data: items.map(i => PrismaApplicationMapper.toDomain(i)), total };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const row = await this.prisma.application.findUnique({ where: { id } });
|
||||
if (!row) throw new Error('Application not found');
|
||||
return PrismaApplicationMapper.toDomain(row);
|
||||
}
|
||||
|
||||
async findByCode(code: string) {
|
||||
const row = await this.prisma.application.findUnique({ where: { code } });
|
||||
if (!row) return null;
|
||||
return PrismaApplicationMapper.toDomain(row);
|
||||
}
|
||||
|
||||
async findByApplicationsClaim(claim: string) {
|
||||
const row = await this.prisma.application.findUnique({ where: { applicationsClaim: claim } });
|
||||
if (!row) return null;
|
||||
return PrismaApplicationMapper.toDomain(row);
|
||||
}
|
||||
|
||||
async create(app: ApplicationData) {
|
||||
const created = await this.prisma.application.create({ data: {
|
||||
id: app.id,
|
||||
code: app.code,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
icon: app.icon,
|
||||
url: app.url,
|
||||
applicationsClaim: app.applicationsClaim,
|
||||
isActive: app.isActive,
|
||||
displayOrder: app.displayOrder,
|
||||
} });
|
||||
|
||||
return PrismaApplicationMapper.toDomain(created);
|
||||
}
|
||||
|
||||
async update(app: ApplicationData) {
|
||||
const updated = await this.prisma.application.update({ where: { id: app.id }, data: {
|
||||
code: app.code,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
icon: app.icon,
|
||||
url: app.url,
|
||||
applicationsClaim: app.applicationsClaim,
|
||||
isActive: app.isActive,
|
||||
displayOrder: app.displayOrder,
|
||||
} });
|
||||
|
||||
return PrismaApplicationMapper.toDomain(updated);
|
||||
}
|
||||
|
||||
async delete(appId: string) {
|
||||
await this.prisma.application.delete({ where: { id: appId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Controller, Get, Param, UseGuards, Query, Patch, Delete, Post, Body, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../../../core/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUserGuard } from '../../../identity/presentation/guards/current-user.guard';
|
||||
import { GetApplicationsHandler } from '../../application/handlers/get-applications.handler';
|
||||
import { GetApplicationHandler } from '../../application/handlers/get-application.handler';
|
||||
import { CreateApplicationHandler } from '../../application/handlers/create-application.handler';
|
||||
import { UpdateApplicationHandler } from '../../application/handlers/update-application.handler';
|
||||
import { DeleteApplicationHandler } from '../../application/handlers/delete-application.handler';
|
||||
import { GetMeApplicationsHandler } from '../../application/handlers/get-me-applications.handler';
|
||||
|
||||
@ApiTags('Applications')
|
||||
@Controller()
|
||||
export class ApplicationsController {
|
||||
constructor(
|
||||
private readonly getApplicationsHandler: GetApplicationsHandler,
|
||||
private readonly getApplicationHandler: GetApplicationHandler,
|
||||
private readonly createApplicationHandler: CreateApplicationHandler,
|
||||
private readonly updateApplicationHandler: UpdateApplicationHandler,
|
||||
private readonly deleteApplicationHandler: DeleteApplicationHandler,
|
||||
private readonly getMeApplicationsHandler: GetMeApplicationsHandler,
|
||||
) {}
|
||||
|
||||
@Get('applications')
|
||||
@UseGuards(JwtAuthGuard, CurrentUserGuard)
|
||||
@ApiOperation({ summary: 'List applications' })
|
||||
async findAll(@Query() query: any) {
|
||||
const res = await this.getApplicationsHandler.execute({ page: query.page, limit: query.limit, search: query.search });
|
||||
return { success: true, data: res.data.map(a => a.toResponse ? a.toResponse() : a), meta: { total: res.total } };
|
||||
}
|
||||
|
||||
@Get('applications/:id')
|
||||
@UseGuards(JwtAuthGuard, CurrentUserGuard)
|
||||
@ApiOperation({ summary: 'Get application by id' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
const app = await this.getApplicationHandler.execute(id);
|
||||
return { success: true, data: app.toResponse(), meta: {} };
|
||||
}
|
||||
|
||||
@Post('applications')
|
||||
@UseGuards(JwtAuthGuard, CurrentUserGuard)
|
||||
@ApiOperation({ summary: 'Create application' })
|
||||
async create(@Body() body: any) {
|
||||
const created = await this.createApplicationHandler.execute(body);
|
||||
return { success: true, data: created.toResponse(), meta: {} };
|
||||
}
|
||||
|
||||
@Patch('applications/:id')
|
||||
@UseGuards(JwtAuthGuard, CurrentUserGuard)
|
||||
@ApiOperation({ summary: 'Update application' })
|
||||
async update(@Param('id') id: string, @Body() body: any) {
|
||||
const updated = await this.updateApplicationHandler.execute(id, body);
|
||||
return { success: true, data: updated.toResponse(), meta: {} };
|
||||
}
|
||||
|
||||
@Delete('applications/:id')
|
||||
@UseGuards(JwtAuthGuard, CurrentUserGuard)
|
||||
@ApiOperation({ summary: 'Delete application' })
|
||||
async remove(@Param('id') id: string) {
|
||||
await this.deleteApplicationHandler.execute(id);
|
||||
return { success: true, data: null, meta: {} };
|
||||
}
|
||||
|
||||
// Dashboard endpoint
|
||||
@Get('me/applications')
|
||||
@UseGuards(JwtAuthGuard, CurrentUserGuard)
|
||||
@ApiOperation({ summary: "Get current user's applications (filtered by Authentik claims)" })
|
||||
async me(@Req() req: any) {
|
||||
const ctx = req.raylabContext;
|
||||
const identity = ctx && ctx.identity ? ctx.identity : {};
|
||||
const applicationsClaimList = identity.applications || [];
|
||||
|
||||
const res = await this.getMeApplicationsHandler.execute(applicationsClaimList);
|
||||
return { success: true, data: res.data.map(a => a.toResponse ? a.toResponse() : a), meta: { total: res.total } };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventBus } from '../../core/event-bus/event-bus.service';
|
||||
import { EventEnvelope } from '../../core/event-bus/event.interface';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditEventHandler {
|
||||
private readonly logger = new Logger(AuditEventHandler.name);
|
||||
|
||||
constructor(private readonly events: EventBus, private readonly auditService: AuditService) {
|
||||
this.events.subscribe('RolesSynchronized', (e) => this.handleRolesSynchronized(e));
|
||||
this.events.subscribe('UserAuthenticated', (e) => this.handleUserAuthenticated(e));
|
||||
this.events.subscribe('RoleCreated', (e) => this.handleGeneric(e));
|
||||
this.events.subscribe('RoleUpdated', (e) => this.handleGeneric(e));
|
||||
this.events.subscribe('RoleDeleted', (e) => this.handleGeneric(e));
|
||||
this.events.subscribe('PermissionCreated', (e) => this.handleGeneric(e));
|
||||
this.events.subscribe('PermissionUpdated', (e) => this.handleGeneric(e));
|
||||
this.events.subscribe('PermissionDeleted', (e) => this.handleGeneric(e));
|
||||
this.events.subscribe('UserActivated', (e) => this.handleGeneric(e));
|
||||
this.events.subscribe('UserDeactivated', (e) => this.handleGeneric(e));
|
||||
}
|
||||
|
||||
async handleRolesSynchronized(event: EventEnvelope<any>) {
|
||||
try {
|
||||
await this.auditService.createFromEvent(event);
|
||||
} catch (e) {
|
||||
this.logger.error('Audit handler failed', e as any);
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserAuthenticated(event: EventEnvelope<any>) {
|
||||
try {
|
||||
await this.auditService.createFromEvent(event);
|
||||
} catch (e) {
|
||||
this.logger.error('Audit handler failed', e as any);
|
||||
}
|
||||
}
|
||||
|
||||
async handleGeneric(event: EventEnvelope<any>) {
|
||||
try {
|
||||
await this.auditService.createFromEvent(event);
|
||||
} catch (e) {
|
||||
this.logger.error('Audit handler failed', e as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditEventHandler } from './audit.event-handler';
|
||||
import { PrismaService } from '../../shared/prisma.service';
|
||||
import { EventBus } from '../../core/event-bus/event-bus.service';
|
||||
|
||||
@Module({
|
||||
providers: [AuditService, AuditEventHandler, PrismaService, EventBus],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma.service';
|
||||
import { EventEnvelope } from '../../core/event-bus/event.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async createFromEvent(event: EventEnvelope<any>) {
|
||||
try {
|
||||
// Map standard events to AuditLog entries
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: event.payload?.userId || null,
|
||||
action: event.type,
|
||||
resource: event.payload?.resource || null,
|
||||
resourceId: event.payload?.resourceId || null,
|
||||
details: event.payload,
|
||||
createdAt: new Date(event.timestamp),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to write audit log', e as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,20 @@ import { IAuthConfig } from '../identity/application/config/i-auth-config';
|
||||
import { EnvAuthConfig } from '../identity/application/config/env-auth-config';
|
||||
import { RedisPkceStore } from './pkce/redis-pkce.store';
|
||||
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { RoleSyncService } from './role-sync.service';
|
||||
import { RedisService } from '../../shared/redis.service';
|
||||
import { GroupHashService } from './group-hash.service';
|
||||
import { AuthenticationService } from './authentication.service';
|
||||
import { EventBus } from '../../core/event-bus/event-bus.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
AuthorizationModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (config: ConfigService) => ({
|
||||
@@ -40,7 +50,16 @@ import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
|
||||
// In-memory PKCE and Refresh stores (Redis removed)
|
||||
RedisPkceStore,
|
||||
InMemoryRefreshStore,
|
||||
|
||||
// OIDC & Role Sync
|
||||
OidcService,
|
||||
RoleSyncService,
|
||||
GroupHashService,
|
||||
AuthenticationService,
|
||||
EventBus,
|
||||
AuditService,
|
||||
RedisService,
|
||||
],
|
||||
exports: [AuthService],
|
||||
exports: [AuthService, OidcService, RoleSyncService, AuthenticationService, EventBus],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { PrismaService } from '../../shared/prisma.service';
|
||||
import { RoleSyncService } from './role-sync.service';
|
||||
import { AuthorizationService } from '../authorization/authorization.service';
|
||||
import { EventBus } from '../../core/event-bus/event-bus.service';
|
||||
import { RequestContext } from '../../shared/types/request-context';
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationService {
|
||||
private readonly logger = new Logger(AuthenticationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly oidc: OidcService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly roleSync: RoleSyncService,
|
||||
private readonly authorization: AuthorizationService,
|
||||
private readonly events: EventBus,
|
||||
) {}
|
||||
|
||||
async authenticate(bearerToken: string): Promise<RequestContext> {
|
||||
// Validate token (signature/iss/aud/exp)
|
||||
const claims = await this.oidc.verifyToken(bearerToken);
|
||||
|
||||
const sub = claims.sub;
|
||||
if (!sub) throw new Error('Invalid token: missing sub');
|
||||
|
||||
// Resolve identity
|
||||
const identity = { sub, email: claims.email, preferred_username: claims.preferred_username, raw: claims };
|
||||
|
||||
// Find or create user (materialize)
|
||||
let user = await (this.prisma as any).user.findUnique({ where: { authentikId: sub } });
|
||||
if (!user) {
|
||||
user = await (this.prisma as any).user.create({ data: { authentikId: sub, username: identity.preferred_username || identity.email || sub, email: identity.email || null } });
|
||||
}
|
||||
|
||||
// Synchronize roles
|
||||
const groups: string[] = Array.isArray(claims.groups) ? claims.groups : [];
|
||||
await this.roleSync.syncUserRolesFromAuthentik(user.id, groups);
|
||||
|
||||
// Load permissions
|
||||
const permissions = await this.authorization.getUserPermissions(user.id);
|
||||
|
||||
// Build request context
|
||||
const rolesRows = await (this.prisma as any).userRole.findMany({ where: { userId: user.id } });
|
||||
const roles = rolesRows.map((r: any) => r.roleId);
|
||||
|
||||
const ctx: RequestContext = { user, identity, roles, permissions };
|
||||
|
||||
// Publish domain event
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'UserAuthenticated', payload: { userId: user.id, identity } });
|
||||
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class GroupHashService {
|
||||
compute(groups: string[]): string {
|
||||
const sorted = (groups || []).slice().sort();
|
||||
const data = sorted.join(',');
|
||||
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Logger, Inject } from '@nestjs/common';
|
||||
import { OidcService } from '../oidc.service';
|
||||
import { AuthenticationService } from '../authentication.service';
|
||||
|
||||
@Injectable()
|
||||
export class OidcGuard implements CanActivate {
|
||||
private readonly logger = new Logger(OidcGuard.name);
|
||||
constructor(private readonly oidc: OidcService, private readonly authn: AuthenticationService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const auth = req.headers['authorization'] || req.headers['Authorization'];
|
||||
if (!auth || typeof auth !== 'string' || !auth.startsWith('Bearer ')) throw new UnauthorizedException('Missing bearer token');
|
||||
const token = auth.substring(7).trim();
|
||||
|
||||
try {
|
||||
const ctx = await this.authn.authenticate(token);
|
||||
// attach context to request under a structured key
|
||||
req.raylabContext = ctx;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.debug('Authentication failed', (e as any).message);
|
||||
throw new UnauthorizedException('Invalid token or authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private jwksUri: string | null = null;
|
||||
private issuer: string;
|
||||
private audience: string | string[] | undefined;
|
||||
private logger = new Logger(OidcService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.issuer = this.config.get<string>('AUTHENTIK_ISSUER') || '';
|
||||
this.audience = this.config.get<string>('AUTHENTIK_AUDIENCE') || undefined;
|
||||
const jwksUri = this.config.get<string>('AUTHENTIK_JWKS_URI');
|
||||
if (jwksUri) this.jwksUri = jwksUri;
|
||||
else if (this.issuer) this.jwksUri = `${this.issuer.replace(/\/+$/, '')}/.well-known/jwks.json`;
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
if (!this.jwksUri) throw new Error('JWKS not configured');
|
||||
|
||||
try {
|
||||
// dynamic import to avoid ESM loading issues in test environment
|
||||
const jose = await import('jose');
|
||||
const jwks = jose.createRemoteJWKSet(new URL(this.jwksUri));
|
||||
const { payload } = await jose.jwtVerify(token, jwks, {
|
||||
issuer: this.issuer || undefined,
|
||||
audience: this.audience,
|
||||
} as any);
|
||||
|
||||
return payload as Record<string, any>;
|
||||
} catch (e) {
|
||||
this.logger.debug('Token verification failed', (e as Error).message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma.service';
|
||||
import { PermissionCache } from '../authorization/cache/permission-cache.interface';
|
||||
import { EventBus } from '../../core/event-bus/event-bus.service';
|
||||
import { GroupHashService } from './group-hash.service';
|
||||
import { PERMISSION_CACHE } from '../authorization/authorization.service';
|
||||
|
||||
@Injectable()
|
||||
export class RoleSyncService {
|
||||
private readonly logger = new Logger(RoleSyncService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly groupHash: GroupHashService,
|
||||
@Inject(PERMISSION_CACHE) private readonly permissionCache: PermissionCache,
|
||||
private readonly events: EventBus,
|
||||
) {}
|
||||
|
||||
computeGroupHash(groups: string[]): string {
|
||||
return this.groupHash.compute(groups || []);
|
||||
}
|
||||
|
||||
async mapGroupsToRoleIds(groups: string[]): Promise<string[]> {
|
||||
if (!groups || groups.length === 0) return [];
|
||||
const mappings = await (this.prisma as any).authGroupRoleMapping.findMany({ where: { authGroup: { in: groups } } });
|
||||
const roleIds = mappings.map((m: any) => m.roleId);
|
||||
return Array.from(new Set(roleIds));
|
||||
}
|
||||
|
||||
async syncUserRolesFromAuthentik(userId: string, groups: string[]) {
|
||||
const groupHash = this.computeGroupHash(groups || []);
|
||||
|
||||
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
if (user.lastGroupHash === groupHash) {
|
||||
this.logger.debug('Group hash unchanged, skipping sync');
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
const roleIds = await this.mapGroupsToRoleIds(groups || []);
|
||||
|
||||
const previousRoleRows = await (this.prisma as any).userRole.findMany({ where: { userId, source: 'AUTHENTIK' }, select: { roleId: true } });
|
||||
const previousRoles = previousRoleRows.map((r: any) => r.roleId);
|
||||
|
||||
await (this.prisma as any).$transaction(async (tx: any) => {
|
||||
await tx.userRole.deleteMany({ where: { userId: userId, source: 'AUTHENTIK' } });
|
||||
|
||||
if (roleIds.length > 0) {
|
||||
const createData = roleIds.map((rid: string) => ({ userId, roleId: rid, source: 'AUTHENTIK' }));
|
||||
await tx.userRole.createMany({ data: createData, skipDuplicates: true } as any);
|
||||
}
|
||||
|
||||
await tx.user.update({ where: { id: userId }, data: { lastGroupHash: groupHash } });
|
||||
});
|
||||
|
||||
// Invalidate permission cache through abstraction
|
||||
try {
|
||||
await this.permissionCache.invalidate(userId);
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to invalidate permission cache', e as any);
|
||||
}
|
||||
|
||||
// Publish event for audit and other subscribers
|
||||
this.events.publish({
|
||||
id: require('crypto').randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'RolesSynchronized',
|
||||
payload: { userId, groups, assignedRoleIds: roleIds, previousRoles },
|
||||
});
|
||||
|
||||
return { skipped: false, assignedRoleIds: roleIds };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthorizationService, PERMISSION_CACHE } from './authorization.service';
|
||||
import { PrismaService } from '../../shared/prisma.service';
|
||||
import { RedisService } from '../../shared/redis.service';
|
||||
import { RedisPermissionCache } from './cache/redis-permission-cache.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
AuthorizationService,
|
||||
PrismaService,
|
||||
RedisService,
|
||||
{ provide: PERMISSION_CACHE, useClass: RedisPermissionCache },
|
||||
],
|
||||
exports: [AuthorizationService, PERMISSION_CACHE],
|
||||
})
|
||||
export class AuthorizationModule {}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma.service';
|
||||
import { PermissionCache } from './cache/permission-cache.interface';
|
||||
|
||||
export const PERMISSION_CACHE = 'PERMISSION_CACHE';
|
||||
|
||||
@Injectable()
|
||||
export class AuthorizationService {
|
||||
private readonly logger = new Logger(AuthorizationService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService, @Inject(PERMISSION_CACHE) private readonly cache: PermissionCache) {}
|
||||
|
||||
async getUserPermissions(userId: string): Promise<string[]> {
|
||||
try {
|
||||
const cached = await this.cache.get(userId);
|
||||
if (cached) return cached;
|
||||
} catch (e) {
|
||||
this.logger.debug('PermissionCache get failed', e as any);
|
||||
}
|
||||
|
||||
const rows = await this.prisma.$queryRaw`
|
||||
SELECT p.code as code
|
||||
FROM "UserRole" ur
|
||||
JOIN "RolePermission" rp ON rp.role_id = ur.role_id
|
||||
JOIN "Permission" p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = ${userId}`;
|
||||
|
||||
const perms = Array.isArray(rows) ? rows.map((r: any) => r.code) : [];
|
||||
|
||||
try {
|
||||
await this.cache.set(userId, perms);
|
||||
} catch (e) {
|
||||
this.logger.debug('PermissionCache set failed', e as any);
|
||||
}
|
||||
|
||||
return perms;
|
||||
}
|
||||
|
||||
async hasPermission(userId: string, permissionCode: string): Promise<boolean> {
|
||||
const perms = await this.getUserPermissions(userId);
|
||||
return perms.includes(permissionCode);
|
||||
}
|
||||
|
||||
async invalidateUserPermissions(userId: string) {
|
||||
try {
|
||||
await this.cache.invalidate(userId);
|
||||
} catch (e) {
|
||||
this.logger.debug('PermissionCache invalidate failed', e as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface PermissionCache {
|
||||
get(userId: string): Promise<string[] | null>;
|
||||
set(userId: string, permissions: string[], ttlSeconds?: number): Promise<void>;
|
||||
invalidate(userId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PermissionCache } from './permission-cache.interface';
|
||||
import { RedisService } from '../../../shared/redis.service';
|
||||
|
||||
@Injectable()
|
||||
export class RedisPermissionCache implements PermissionCache {
|
||||
private readonly TTL = 60 * 5;
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
private key(userId: string) {
|
||||
const { CacheKeys } = require('../../../shared/cache-keys');
|
||||
return CacheKeys.permission(userId);
|
||||
}
|
||||
|
||||
async get(userId: string): Promise<string[] | null> {
|
||||
const data = await this.redis.get(this.key(userId));
|
||||
if (!data) return null;
|
||||
try {
|
||||
return JSON.parse(data) as string[];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(userId: string, permissions: string[], ttlSeconds?: number): Promise<void> {
|
||||
await this.redis.set(this.key(userId), JSON.stringify(permissions), ttlSeconds ?? this.TTL);
|
||||
}
|
||||
|
||||
async invalidate(userId: string): Promise<void> {
|
||||
await this.redis.del(this.key(userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { AuthorizationService } from '../authorization.service';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private readonly authz: AuthorizationService, private readonly permission: string) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.raylab?.user;
|
||||
if (!user) throw new ForbiddenException('Missing user');
|
||||
|
||||
const allowed = await this.authz.hasPermission(user.id, this.permission);
|
||||
if (!allowed) throw new ForbiddenException('Forbidden');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Factory to create guard instances with permission string (used in decorators)
|
||||
export const createPermissionGuard = (permission: string) => {
|
||||
@Injectable()
|
||||
class _Guard extends PermissionGuard {
|
||||
constructor(authz: AuthorizationService) {
|
||||
super(authz, permission);
|
||||
}
|
||||
}
|
||||
return _Guard;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Controller('api/health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
async get() {
|
||||
return this.healthService.getHealth();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
exports: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
async getHealth() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'raylab-core',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import { IPermission } from '../../../domain/repositories/permission.interface';
|
||||
import { PermissionData } from '../../../domain/entities/permission.entity';
|
||||
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
export class CreatePermissionHandler {
|
||||
constructor(private readonly permissionRepository: IPermission) {}
|
||||
constructor(private readonly permissionRepository: IPermission, private readonly events: EventBus) {}
|
||||
|
||||
async execute(dto: any) {
|
||||
if (!dto.name || !dto.code) throw new ConflictException('Missing required fields');
|
||||
|
||||
const perm = PermissionData.restore({
|
||||
id: crypto.randomUUID(),
|
||||
code: dto.code || dto.name,
|
||||
name: dto.name,
|
||||
displayName: dto.displayName || dto.name,
|
||||
description: dto.description || '',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
return this.permissionRepository.create(perm);
|
||||
const created = await this.permissionRepository.create(perm);
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionCreated', payload: { permissionId: created.id } });
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { IPermission } from '../../../domain/repositories/permission.interface';
|
||||
import { IRole } from '../../../domain/repositories/role.interface';
|
||||
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||
|
||||
@Injectable()
|
||||
export class DeletePermissionHandler {
|
||||
constructor(private readonly permissionRepository: IPermission) {}
|
||||
constructor(private readonly permissionRepository: IPermission, private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||
|
||||
async execute(id: string) {
|
||||
const p = await this.permissionRepository.getById(id);
|
||||
if (!p) throw new NotFoundException('Permission not found.');
|
||||
|
||||
// find roles that reference this permission
|
||||
const rolesWithPerm = await (this.permissionRepository as any).findRoleIdsByPermission(id);
|
||||
|
||||
// delete permission
|
||||
await this.permissionRepository.delete(id);
|
||||
|
||||
// invalidate caches for users who have affected roles
|
||||
const userSet = new Set<string>();
|
||||
for (const rid of rolesWithPerm) {
|
||||
const uids = await this.roleRepository.getAssignedUserIds(rid);
|
||||
uids.forEach(u => userSet.add(u));
|
||||
}
|
||||
|
||||
for (const uid of Array.from(userSet)) await this.authorizationService.invalidateUserPermissions(uid);
|
||||
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionDeleted', payload: { permissionId: id, affectedRoles: rolesWithPerm } });
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { IPermission } from '../../../domain/repositories/permission.interface';
|
||||
import { IRole } from '../../../domain/repositories/role.interface';
|
||||
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||
|
||||
@Injectable()
|
||||
export class UpdatePermissionHandler {
|
||||
constructor(private readonly permissionRepository: IPermission) {}
|
||||
constructor(private readonly permissionRepository: IPermission, private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||
|
||||
async execute(id: string, dto: any) {
|
||||
const perm = await this.permissionRepository.getById(id);
|
||||
@@ -13,6 +16,21 @@ export class UpdatePermissionHandler {
|
||||
if (dto.description !== undefined) perm.changeDescription(dto.description);
|
||||
if (dto.code) perm.changeCode(dto.code);
|
||||
|
||||
return this.permissionRepository.update(perm);
|
||||
const updated = await this.permissionRepository.update(perm);
|
||||
|
||||
// invalidate caches for users who belong to roles that reference this permission
|
||||
const roleIds = await (this.permissionRepository as any).findRoleIdsByPermission(id);
|
||||
const userSet = new Set<string>();
|
||||
|
||||
for (const rid of roleIds) {
|
||||
const uids = await this.roleRepository.getAssignedUserIds(rid);
|
||||
uids.forEach(u => userSet.add(u));
|
||||
}
|
||||
|
||||
for (const uid of Array.from(userSet)) await this.authorizationService.invalidateUserPermissions(uid);
|
||||
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionUpdated', payload: { permissionId: id } });
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, Inject } from '@nestjs/common';
|
||||
import { IRole } from '../../../domain/repositories/role.interface';
|
||||
import { IPermission } from '../../../domain/repositories/permission.interface';
|
||||
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||
|
||||
@Injectable()
|
||||
export class RoleAssignPermissionHandler {
|
||||
constructor(
|
||||
private readonly roleRepository: IRole,
|
||||
private readonly permissionRepository: IPermission,
|
||||
private readonly authorizationService: AuthorizationService,
|
||||
private readonly events: EventBus,
|
||||
) {}
|
||||
|
||||
async execute(roleId: string, permissionId: string) {
|
||||
@@ -18,6 +22,17 @@ export class RoleAssignPermissionHandler {
|
||||
|
||||
role.assignPermission(perm);
|
||||
|
||||
return this.roleRepository.update(role);
|
||||
const updated = await this.roleRepository.update(role);
|
||||
|
||||
// Invalidate permissions cache for users who have this role
|
||||
const userIds = await this.roleRepository.getAssignedUserIds(roleId);
|
||||
for (const uid of userIds) {
|
||||
await this.authorizationService.invalidateUserPermissions(uid);
|
||||
}
|
||||
|
||||
// Publish role.updated event
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId, permissionId } });
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import { IRole } from '../../../domain/repositories/role.interface';
|
||||
import { RoleData } from '../../../domain/entities/role.entity';
|
||||
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
export class CreateRoleHandler {
|
||||
constructor(private readonly roleRepository: IRole) {}
|
||||
constructor(private readonly roleRepository: IRole, private readonly events: EventBus) {}
|
||||
|
||||
async execute(dto: any) {
|
||||
// check uniqueness by code
|
||||
// simple check
|
||||
// basic validation
|
||||
if (!dto.code || !dto.name) throw new ConflictException('Missing required fields');
|
||||
|
||||
try {
|
||||
// attempt to create; repository may enforce uniqueness
|
||||
const role = RoleData.restore({
|
||||
id: crypto.randomUUID(),
|
||||
code: dto.code,
|
||||
@@ -22,7 +25,11 @@ export class CreateRoleHandler {
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
return this.roleRepository.create(role);
|
||||
const created = await this.roleRepository.create(role);
|
||||
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleCreated', payload: { roleId: created.id } });
|
||||
|
||||
return created;
|
||||
} catch (e) {
|
||||
throw new ConflictException('Role creation failed.');
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { IRole } from '../../../domain/repositories/role.interface';
|
||||
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteRoleHandler {
|
||||
constructor(private readonly roleRepository: IRole) {}
|
||||
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||
|
||||
async execute(id: string) {
|
||||
const role = await this.roleRepository.findById(id);
|
||||
if (!role) throw new NotFoundException('Role not found.');
|
||||
|
||||
// get affected users before delete
|
||||
const userIds = await this.roleRepository.getAssignedUserIds(id);
|
||||
|
||||
await this.roleRepository.delete(id);
|
||||
|
||||
// invalidate caches
|
||||
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
|
||||
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleDeleted', payload: { roleId: id } });
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { IRole } from '../../../domain/repositories/role.interface';
|
||||
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||
|
||||
@Injectable()
|
||||
export class RoleRemovePermissionHandler {
|
||||
constructor(private readonly roleRepository: IRole) {}
|
||||
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||
|
||||
async execute(roleId: string, permissionId: string) {
|
||||
const role = await this.roleRepository.findById(roleId);
|
||||
@@ -11,6 +13,15 @@ export class RoleRemovePermissionHandler {
|
||||
|
||||
role.removePermission(permissionId);
|
||||
|
||||
return this.roleRepository.update(role);
|
||||
const updated = await this.roleRepository.update(role);
|
||||
|
||||
// Invalidate caches for users with the role
|
||||
const userIds = await this.roleRepository.getAssignedUserIds(roleId);
|
||||
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
|
||||
|
||||
// Publish event
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId, removedPermissionId: permissionId } });
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { IRole } from '../../../domain/repositories/role.interface';
|
||||
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateRoleHandler {
|
||||
constructor(private readonly roleRepository: IRole) {}
|
||||
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||
|
||||
async execute(id: string, dto: any) {
|
||||
const role = await this.roleRepository.findById(id);
|
||||
if (!role) throw new NotFoundException('Role not found.');
|
||||
|
||||
// validation
|
||||
if (dto.name && dto.name.length < 2) throw new BadRequestException('Name too short');
|
||||
|
||||
if (dto.name) role.changeName(dto.name);
|
||||
if (dto.description !== undefined) role.changeDescription(dto.description);
|
||||
if (dto.isDefault !== undefined) role.setDefault(!!dto.isDefault);
|
||||
|
||||
return this.roleRepository.update(role);
|
||||
const updated = await this.roleRepository.update(role);
|
||||
|
||||
// invalidate caches for users with this role
|
||||
const userIds = await this.roleRepository.getAssignedUserIds(id);
|
||||
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
|
||||
|
||||
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId: id } });
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { IUser } from '../../../domain/repositories/user.interface';
|
||||
import { UpdateUserDto } from '../../../presentation/dto/update-user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateUserHandler {
|
||||
constructor(private readonly userRepository: IUser) {}
|
||||
|
||||
async execute(id: string, dto: UpdateUserDto) {
|
||||
// Password management is not allowed in RayLab Core
|
||||
if ((dto as any).password) throw new BadRequestException('Password management is not allowed.');
|
||||
|
||||
const user = await this.userRepository.getById(id);
|
||||
if (!user) throw new NotFoundException('User not found.');
|
||||
|
||||
// Only allow updating profile fields: name, email, metadata, storage settings
|
||||
if (dto.name !== undefined) user.changeUsername(dto.name);
|
||||
if (dto.email !== undefined) user.changeEmail(dto.email);
|
||||
// metadata field not handled at domain level yet
|
||||
if ((dto as any).storageQuota !== undefined) user.setStorageQuota((dto as any).storageQuota);
|
||||
if ((dto as any).storageUsed !== undefined) user.setStorageUsed((dto as any).storageUsed);
|
||||
|
||||
return this.userRepository.update(user);
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,7 @@ export abstract class IPermission {
|
||||
abstract create(permission: PermissionData): Promise<PermissionData>;
|
||||
abstract update(permission: PermissionData): Promise<PermissionData>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
|
||||
// Returns role ids that reference this permission
|
||||
abstract findRoleIdsByPermission(permissionId: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -9,4 +9,7 @@ export abstract class IRole {
|
||||
abstract create(role: RoleData): Promise<RoleData>;
|
||||
abstract update(role: RoleData): Promise<RoleData>;
|
||||
abstract delete(roleId: string): Promise<void>;
|
||||
|
||||
// Returns user ids assigned to a role (preserves layering)
|
||||
abstract getAssignedUserIds(roleId: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { IRole } from './domain/repositories/role.interface';
|
||||
import { IPermission } from './domain/repositories/permission.interface';
|
||||
import { IAuthConfig } from './application/config/i-auth-config';
|
||||
import { EnvAuthConfig } from './application/config/env-auth-config';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||
import { EventBus } from '../../core/event-bus/event-bus.service';
|
||||
|
||||
import { GetUsersHandler } from './application/handlers/user/get-users.handler';
|
||||
import { GetUserHandler } from './application/handlers/user/get-user.handler';
|
||||
@@ -28,10 +31,9 @@ import { EnableUserHandler } from './application/handlers/user/enable-user.handl
|
||||
import { DisableUserHandler } from './application/handlers/user/disable-user.handler';
|
||||
import { DeleteUserHandler } from './application/handlers/user/delete-user.handler';
|
||||
import { RestoreUserHandler } from './application/handlers/user/restore-user.handler';
|
||||
import { AssignRoleHandler } from './application/handlers/user/assign-role.handler';
|
||||
import { RemoveRoleHandler } from './application/handlers/user/remove-role.handler';
|
||||
import { AssignPermissionHandler } from './application/handlers/user/assign-permission.handler';
|
||||
import { RemovePermissionHandler } from './application/handlers/user/remove-permission.handler';
|
||||
import { UpdateUserHandler } from './application/handlers/user/update-user.handler';
|
||||
|
||||
|
||||
|
||||
import { GetRolesHandler } from './application/handlers/role/get-roles.handler';
|
||||
import { GetRoleHandler } from './application/handlers/role/get-role.handler';
|
||||
@@ -48,7 +50,9 @@ import { UpdatePermissionHandler } from './application/handlers/permission/updat
|
||||
import { DeletePermissionHandler } from './application/handlers/permission/delete-permission.handler';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, AuthorizationModule],
|
||||
providers: [
|
||||
|
||||
//#region User
|
||||
// CreateUserHandler has been removed: provisioning disabled; users must be created in Authentik.
|
||||
SyncIdentityHandler,
|
||||
@@ -59,11 +63,9 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
|
||||
EnableUserHandler,
|
||||
DisableUserHandler,
|
||||
DeleteUserHandler,
|
||||
RestoreUserHandler,
|
||||
AssignRoleHandler,
|
||||
RemoveRoleHandler,
|
||||
AssignPermissionHandler,
|
||||
RemovePermissionHandler,
|
||||
RestoreUserHandler,
|
||||
UpdateUserHandler,
|
||||
|
||||
//#endregion
|
||||
|
||||
// role & permission handlers
|
||||
@@ -75,21 +77,24 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
|
||||
RoleAssignPermissionHandler,
|
||||
RoleRemovePermissionHandler,
|
||||
|
||||
GetPermissionsHandler,
|
||||
GetPermissionsHandler,
|
||||
GetPermissionHandler,
|
||||
CreatePermissionHandler,
|
||||
UpdatePermissionHandler,
|
||||
DeletePermissionHandler,
|
||||
|
||||
JwtAuthGuard,
|
||||
|
||||
CurrentUserGuard,
|
||||
PermissionGuard,
|
||||
Reflector,
|
||||
PrismaService,
|
||||
PrismaService,
|
||||
PrismaUserRepository,
|
||||
PrismaRoleRepository,
|
||||
PrismaPermissionRepository,
|
||||
UserService,
|
||||
UserService,
|
||||
UpdateUserHandler,
|
||||
|
||||
{
|
||||
provide: IUser,
|
||||
useClass: PrismaUserRepository,
|
||||
@@ -106,8 +111,7 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
|
||||
provide: IAuthConfig,
|
||||
useClass: EnvAuthConfig,
|
||||
},
|
||||
],
|
||||
imports: [],
|
||||
],
|
||||
controllers: [UsersController, RolesController, PermissionsController],
|
||||
})
|
||||
export class IdentityModule {}
|
||||
|
||||
@@ -50,4 +50,10 @@ export class PrismaPermissionRepository implements IPermission {
|
||||
async delete(id: string) {
|
||||
await this.prisma.permission.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async findRoleIdsByPermission(permissionId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.rolePermission.findMany({ where: { permissionId }, select: { roleId: true } });
|
||||
return rows.map(r => r.roleId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,4 +106,10 @@ export class PrismaRoleRepository implements IRole {
|
||||
async delete(roleId: string) {
|
||||
await this.prisma.role.delete({ where: { id: roleId } });
|
||||
}
|
||||
|
||||
async getAssignedUserIds(roleId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.userRole.findMany({ where: { roleId }, select: { userId: true } });
|
||||
return rows.map(r => r.userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,8 @@ import { EnableUserHandler } from '../../application/handlers/user/enable-user.h
|
||||
import { DisableUserHandler } from '../../application/handlers/user/disable-user.handler';
|
||||
import { DeleteUserHandler } from '../../application/handlers/user/delete-user.handler';
|
||||
import { RestoreUserHandler } from '../../application/handlers/user/restore-user.handler';
|
||||
import { AssignRoleHandler } from '../../application/handlers/user/assign-role.handler';
|
||||
import { RemoveRoleHandler } from '../../application/handlers/user/remove-role.handler';
|
||||
import { AssignPermissionHandler } from '../../application/handlers/user/assign-permission.handler';
|
||||
import { RemovePermissionHandler } from '../../application/handlers/user/remove-permission.handler';
|
||||
import { UpdateUserHandler } from '../../application/handlers/user/update-user.handler';
|
||||
|
||||
|
||||
@ApiTags('Users')
|
||||
@Controller('users')
|
||||
@@ -31,10 +29,7 @@ export class UsersController {
|
||||
private readonly disableUserHandler: DisableUserHandler,
|
||||
private readonly deleteUserHandler: DeleteUserHandler,
|
||||
private readonly restoreUserHandler: RestoreUserHandler,
|
||||
private readonly assignRoleHandler: AssignRoleHandler,
|
||||
private readonly removeRoleHandler: RemoveRoleHandler,
|
||||
private readonly assignPermissionHandler: AssignPermissionHandler,
|
||||
private readonly removePermissionHandler: RemovePermissionHandler,
|
||||
private readonly updateUserHandler: UpdateUserHandler,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -82,6 +77,14 @@ export class UsersController {
|
||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Permissions(PermissionType.USER_UPDATE)
|
||||
@ApiOperation({ summary: 'Update user profile' })
|
||||
async update(@Param('id') id: string, @Body() body: any) {
|
||||
const updated = await this.updateUserHandler.execute(id, body);
|
||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Permissions(PermissionType.USER_DELETE)
|
||||
@ApiOperation({ summary: 'Soft delete user' })
|
||||
@@ -98,37 +101,5 @@ export class UsersController {
|
||||
return { success: true, data: (restored as any).toResponse ? (restored as any).toResponse() : restored, meta: {} };
|
||||
}
|
||||
|
||||
@Post(':id/roles')
|
||||
@Permissions(PermissionType.USER_UPDATE)
|
||||
@ApiOperation({ summary: 'Assign role to user' })
|
||||
async assignRole(@Param('id') id: string, @Body() body: any) {
|
||||
const roleId = body.roleId;
|
||||
const updated = await this.assignRoleHandler.execute(id, roleId);
|
||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
||||
}
|
||||
|
||||
@Delete(':id/roles/:roleId')
|
||||
@Permissions(PermissionType.USER_UPDATE)
|
||||
@ApiOperation({ summary: 'Remove role from user' })
|
||||
async removeRole(@Param('id') id: string, @Param('roleId') roleId: string) {
|
||||
const updated = await this.removeRoleHandler.execute(id, roleId);
|
||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
||||
}
|
||||
|
||||
@Post(':id/permissions')
|
||||
@Permissions(PermissionType.USER_UPDATE)
|
||||
@ApiOperation({ summary: 'Assign permission to user' })
|
||||
async assignPermission(@Param('id') id: string, @Body() body: any) {
|
||||
const permissionId = body.permissionId;
|
||||
const updated = await this.assignPermissionHandler.execute(id, permissionId);
|
||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
||||
}
|
||||
|
||||
@Delete(':id/permissions/:permissionId')
|
||||
@Permissions(PermissionType.USER_UPDATE)
|
||||
@ApiOperation({ summary: 'Remove permission from user' })
|
||||
async removePermission(@Param('id') id: string, @Param('permissionId') permissionId: string) {
|
||||
const updated = await this.removePermissionHandler.execute(id, permissionId);
|
||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ export class CurrentUserGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest() as any;
|
||||
|
||||
// Prefer RequestContext produced by AuthenticationService
|
||||
const ctx = request.raylabContext as any;
|
||||
if (ctx && ctx.user) {
|
||||
request.currentUser = ctx.user;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback to legacy identity if present
|
||||
const identity = request.identity as IdentityData | undefined;
|
||||
|
||||
if (!identity) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const CacheKeys = {
|
||||
permission: (userId: string) => `permissions:${userId}`,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import IORedis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||
// Keep client typed as any to avoid tight coupling to ioredis types in tests
|
||||
private client: any = null;
|
||||
private readonly logger = new Logger(RedisService.name);
|
||||
private lastLogAt = 0;
|
||||
private readonly LOG_THROTTLE_MS = 5000; // throttle repeated error logs
|
||||
|
||||
onModuleInit() {
|
||||
const enabled = (process.env.REDIS_ENABLED || 'true').toLowerCase() === 'true';
|
||||
if (!enabled) {
|
||||
this.logger.log('Redis disabled via REDIS_ENABLED=false');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
|
||||
// Use lazyConnect so application can start even if Redis is unavailable temporarily
|
||||
this.client = new IORedis(url, {
|
||||
lazyConnect: true,
|
||||
// limit retries to avoid infinite reconnect storms
|
||||
maxRetriesPerRequest: 5,
|
||||
// automatic reconnection strategy
|
||||
reconnectOnError: (err: any) => {
|
||||
return true;
|
||||
},
|
||||
enableOfflineQueue: true,
|
||||
// optional reconnect strategy
|
||||
retryStrategy: (times: number) => {
|
||||
// exponential backoff capped at 5s
|
||||
const delay = Math.min(50 * Math.pow(2, times), 5000);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
this.client.on('connect', () => this.logger.log('Connected to Redis'));
|
||||
this.client.on('ready', () => this.logger.log('Redis ready'));
|
||||
this.client.on('error', (err: any) => this.handleError(err));
|
||||
this.client.on('close', () => this.logger.warn('Redis connection closed'));
|
||||
this.client.on('reconnecting', () => this.logger.log('Redis reconnecting'));
|
||||
|
||||
// attempt to connect but do not throw if it fails
|
||||
this.client.connect().catch((err: any) => {
|
||||
this.handleError(err);
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(err: any) {
|
||||
const now = Date.now();
|
||||
if (now - this.lastLogAt > this.LOG_THROTTLE_MS) {
|
||||
this.logger.error('Redis error', err instanceof Error ? err.message : err);
|
||||
this.lastLogAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.client) {
|
||||
try {
|
||||
this.client.disconnect();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getClient(): any {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
private ensureClient() {
|
||||
// returns true if client is connected/usable
|
||||
return this.client && this.client.status && this.client.status !== 'end' && this.client.status !== 'close';
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
if (!this.ensureClient()) return null;
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
} catch (e) {
|
||||
this.handleError(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number) {
|
||||
if (!this.ensureClient()) return;
|
||||
try {
|
||||
if (ttlSeconds) {
|
||||
await this.client.set(key, value, 'EX', ttlSeconds);
|
||||
} else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
} catch (e) {
|
||||
this.handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string) {
|
||||
if (!this.ensureClient()) return;
|
||||
try {
|
||||
await this.client.del(key);
|
||||
} catch (e) {
|
||||
this.handleError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface RequestContext {
|
||||
user: any;
|
||||
identity: Record<string, any>;
|
||||
roles: string[]; // role ids
|
||||
permissions: string[]; // permission codes
|
||||
}
|
||||
Reference in New Issue
Block a user