feat: scaffold Identity Engine (CASL/tenant guard) and Event Fabric (outbox)

Adds tenants/memberships/consent_records tables, a CASL AbilityFactory
keyed on membership role, and a TenantGuard that derives tenant_id from
session only (never client-supplied), per blueprint 8.2/8.3/11.3.

Adds outbox_events + audit_log tables, an OutboxService for transactional
writes, and a Cron-based OutboxDispatcher that publishes pending events
to a BullMQ queue, per blueprint 9.1 (events before intelligence).
This commit is contained in:
valentinbvro 2026-07-21 02:46:35 +02:00
parent 3fe8d24179
commit 722113a359
10 changed files with 6556 additions and 1 deletions

6290
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -14,11 +14,13 @@
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@casl/ability": "^6.7.3",
"@nestjs/bullmq": "^11.0.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^4.1.1",
"@nestjs/swagger": "^8.0.0",
"@nestjs/throttler": "^6.2.0",
"@supabase/supabase-js": "^2.47.0",
@ -37,6 +39,7 @@
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@types/express": "^5.0.6",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.10",
"drizzle-kit": "^0.28.0",

View file

@ -1,14 +1,34 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { BullModule } from '@nestjs/bullmq';
import { LoggerModule } from 'nestjs-pino';
import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { EventsModule } from './events/events.module';
function parseRedisConnection(redisUrl: string | undefined) {
if (!redisUrl) {
return { host: 'localhost', port: 6379 };
}
const url = new URL(redisUrl);
return {
host: url.hostname,
port: Number(url.port || 6379),
password: url.password || undefined,
};
}
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
LoggerModule.forRoot(),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
ScheduleModule.forRoot(),
BullModule.forRoot({ connection: parseRedisConnection(process.env.REDIS_URL) }),
AuthModule,
EventsModule,
],
controllers: [HealthController],
})

View file

@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { AbilityBuilder, PureAbility, type AbilityClass } from '@casl/ability';
export type Action = 'manage' | 'create' | 'read' | 'update' | 'delete';
export type Subject = 'Organization' | 'Transaction' | 'Document' | 'Membership' | 'all';
export type AppAbility = PureAbility<[Action, Subject]>;
export type MembershipRole = 'owner' | 'admin' | 'member';
export interface MembershipContext {
tenantId: string;
role: MembershipRole;
}
/**
* Traduce rolul de membership (blueprint 8.3: memberships.role) intr-un set de
* permisiuni CASL. Regulile sunt intentionat minimale -- se extind per bounded
* context pe masura ce apar entitati noi (11.1 "roles si ABAC policies").
*/
@Injectable()
export class AbilityFactory {
createForMembership(membership: MembershipContext): AppAbility {
const { can, build } = new AbilityBuilder<AppAbility>(PureAbility as AbilityClass<AppAbility>);
if (membership.role === 'owner' || membership.role === 'admin') {
can('manage', 'all');
} else {
can('read', 'all');
can(['create', 'update'], ['Transaction', 'Document']);
}
return build();
}
}

9
src/auth/auth.module.ts Normal file
View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AbilityFactory } from './ability.factory';
import { TenantGuard } from './tenant.guard';
@Module({
providers: [AbilityFactory, TenantGuard],
exports: [AbilityFactory, TenantGuard],
})
export class AuthModule {}

39
src/auth/tenant.guard.ts Normal file
View file

@ -0,0 +1,39 @@
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import type { Request } from 'express';
export interface AuthenticatedSession {
userId: string;
tenantId: string;
role: 'owner' | 'admin' | 'member';
}
interface RequestWithSession extends Request {
session?: AuthenticatedSession;
}
/**
* Blueprint 11.3: tenant_id vine intotdeauna din sesiune, niciodata din
* body/query/params. Orice request fara sesiune valida, sau care incearca
* sa suprascrie tenant_id din client, este respins (deny-by-default).
*/
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<RequestWithSession>();
const session = request.session;
if (!session?.tenantId) {
throw new ForbiddenException('No active tenant session');
}
const clientSuppliedTenantId =
(request.body as Record<string, unknown> | undefined)?.tenantId ??
(request.query as Record<string, unknown> | undefined)?.tenantId;
if (clientSuppliedTenantId && clientSuppliedTenantId !== session.tenantId) {
throw new ForbiddenException('tenant_id must not be supplied by the client');
}
return true;
}
}

View file

@ -1,7 +1,64 @@
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
import { pgTable, pgEnum, uuid, text, timestamp, jsonb, integer } from 'drizzle-orm/pg-core';
export const organizations = pgTable('organizations', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// --- Identity Engine (blueprint sectiunea 11, 8.2, 8.3) ---
export const tenants = pgTable('tenants', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const membershipRole = pgEnum('membership_role', ['owner', 'admin', 'member']);
// user_id refera auth.users din Supabase Auth (8.1: "user identity si sessions" = Supabase Auth,
// nu duplicam identitatea aici, doar apartenenta la tenant + rol)
export const memberships = pgTable('memberships', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
userId: uuid('user_id').notNull(),
role: membershipRole('role').notNull().default('member'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const consentRecords = pgTable('consent_records', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
userId: uuid('user_id').notNull(),
purpose: text('purpose').notNull(),
grantedAt: timestamp('granted_at').defaultNow().notNull(),
revokedAt: timestamp('revoked_at'),
});
// --- Event Fabric / Outbox (blueprint sectiunea 9.1, principiul 2.1 "Events before intelligence") ---
export const outboxEvents = pgTable('outbox_events', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
eventType: text('event_type').notNull(),
eventVersion: integer('event_version').notNull().default(1),
subjectId: uuid('subject_id'),
payload: jsonb('payload').notNull(),
correlationId: uuid('correlation_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
processedAt: timestamp('processed_at'),
});
// --- Audit (blueprint sectiunea 31.4) ---
export const auditLog = pgTable('audit_log', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
actorId: uuid('actor_id'),
action: text('action').notNull(),
resource: text('resource').notNull(),
reason: text('reason'),
correlationId: uuid('correlation_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { OutboxService } from './outbox.service';
import { OutboxDispatcher, OUTBOX_QUEUE_NAME } from './outbox.dispatcher';
@Module({
imports: [BullModule.registerQueue({ name: OUTBOX_QUEUE_NAME })],
providers: [OutboxService, OutboxDispatcher],
exports: [OutboxService],
})
export class EventsModule {}

View file

@ -0,0 +1,53 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectQueue } from '@nestjs/bullmq';
import type { Queue } from 'bullmq';
import { and, eq, isNull, asc } from 'drizzle-orm';
import { db } from '../db/client';
import { outboxEvents } from '../db/schema';
const DISPATCH_BATCH_SIZE = 100;
export const OUTBOX_QUEUE_NAME = 'outbox-events';
/**
* Poller pentru outbox (blueprint 9.1). Ruleaza la interval fix, ia evenimentele
* neprocesate in ordine si le publica pe coada BullMQ; marcheaza processed_at
* doar dupa ce publicarea a reusit (at-least-once, consumatorii trebuie sa fie
* idempotenti pe event id).
*/
@Injectable()
export class OutboxDispatcher {
private readonly logger = new Logger(OutboxDispatcher.name);
constructor(@InjectQueue(OUTBOX_QUEUE_NAME) private readonly queue: Queue) {}
@Cron(CronExpression.EVERY_5_SECONDS)
async dispatchPending(): Promise<void> {
const pending = await db
.select()
.from(outboxEvents)
.where(and(isNull(outboxEvents.processedAt)))
.orderBy(asc(outboxEvents.createdAt))
.limit(DISPATCH_BATCH_SIZE);
if (pending.length === 0) {
return;
}
for (const event of pending) {
try {
await this.queue.add(event.eventType, event, {
jobId: event.id,
removeOnComplete: true,
removeOnFail: 1000,
});
await db
.update(outboxEvents)
.set({ processedAt: new Date() })
.where(and(eq(outboxEvents.id, event.id), isNull(outboxEvents.processedAt)));
} catch (error) {
this.logger.error(`Failed to dispatch outbox event ${event.id}`, error as Error);
}
}
}
}

View file

@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { db } from '../db/client';
import { outboxEvents } from '../db/schema';
import * as schema from '../db/schema';
export interface OutboxEventInput {
tenantId: string;
eventType: string;
subjectId?: string;
payload: Record<string, unknown>;
correlationId?: string;
eventVersion?: number;
}
type Transaction = Parameters<Parameters<NodePgDatabase<typeof schema>['transaction']>[0]>[0];
/**
* Blueprint 9.1 / principiul 2.1 "events before intelligence": orice scriere de
* domeniu care trebuie sa produca un eveniment scrie in aceeasi tranzactie in
* outbox_events. Publicarea efectiva (dispatch) se face separat, async, de catre
* OutboxDispatcher -- niciodata inline in request path.
*/
@Injectable()
export class OutboxService {
async withTransaction<T>(fn: (tx: Transaction) => Promise<T>): Promise<T> {
return db.transaction(fn);
}
async record(tx: Transaction, event: OutboxEventInput): Promise<void> {
await tx.insert(outboxEvents).values({
tenantId: event.tenantId,
eventType: event.eventType,
eventVersion: event.eventVersion ?? 1,
subjectId: event.subjectId,
payload: event.payload,
correlationId: event.correlationId,
});
}
}