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:
parent
3fe8d24179
commit
722113a359
10 changed files with 6556 additions and 1 deletions
6290
package-lock.json
generated
Normal file
6290
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
33
src/auth/ability.factory.ts
Normal file
33
src/auth/ability.factory.ts
Normal 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
9
src/auth/auth.module.ts
Normal 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
39
src/auth/tenant.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
11
src/events/events.module.ts
Normal file
11
src/events/events.module.ts
Normal 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 {}
|
||||
53
src/events/outbox.dispatcher.ts
Normal file
53
src/events/outbox.dispatcher.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/events/outbox.service.ts
Normal file
40
src/events/outbox.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue