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"
|
"db:migrate": "drizzle-kit migrate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@casl/ability": "^6.7.3",
|
||||||
"@nestjs/bullmq": "^11.0.0",
|
"@nestjs/bullmq": "^11.0.0",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/schedule": "^4.1.1",
|
||||||
"@nestjs/swagger": "^8.0.0",
|
"@nestjs/swagger": "^8.0.0",
|
||||||
"@nestjs/throttler": "^6.2.0",
|
"@nestjs/throttler": "^6.2.0",
|
||||||
"@supabase/supabase-js": "^2.47.0",
|
"@supabase/supabase-js": "^2.47.0",
|
||||||
|
|
@ -37,6 +39,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"drizzle-kit": "^0.28.0",
|
"drizzle-kit": "^0.28.0",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,34 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { HealthController } from './health/health.controller';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
LoggerModule.forRoot(),
|
LoggerModule.forRoot(),
|
||||||
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
BullModule.forRoot({ connection: parseRedisConnection(process.env.REDIS_URL) }),
|
||||||
|
AuthModule,
|
||||||
|
EventsModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
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', {
|
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(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
createdAt: timestamp('created_at').defaultNow().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