Phase 3 Completed

This commit is contained in:
Rayyan
2026-08-02 17:44:50 +07:00
parent 7ce0de4e91
commit ffc5ecd259
68 changed files with 3132 additions and 163 deletions
+13 -2
View File
@@ -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 {}
+28
View File
@@ -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);
}
}
+6
View File
@@ -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 } };
}
}
+46
View File
@@ -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);
}
}
}
+11
View File
@@ -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 {}
+28
View File
@@ -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);
}
}
}
+20 -1
View File
@@ -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;
}
}
+11
View File
@@ -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');
}
}
+26
View File
@@ -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');
}
}
}
+37
View File
@@ -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;
}
}
}
+74
View File
@@ -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;
};
+12
View File
@@ -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();
}
}
+10
View File
@@ -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 {}
+12
View File
@@ -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[]>;
}
+18 -14
View File
@@ -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) {
+3
View File
@@ -0,0 +1,3 @@
export const CacheKeys = {
permission: (userId: string) => `permissions:${userId}`,
};
+109
View File
@@ -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);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export interface RequestContext {
user: any;
identity: Record<string, any>;
roles: string[]; // role ids
permissions: string[]; // permission codes
}