Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions TECHNICAL_DEBT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ This document tracks known technical debt items that should be addressed in futu
- The `session` table currently exposes raw `ipAddress` and `userAgent` fields indefinitely.
- There is no automated cleanup or anonymization of this Personally Identifiable Information (PII).

**Recommended Solution**:
**Recommended Solution (Time-bounded retention model chosen)**:

- Update the schema with `anonymizeIp` and `anonymizeUserAgent` helpers to hash/truncate data before insert.
- Create a `background` (or `jobs`) module in `apps/api` using `@nestjs/schedule`.
- Implement `runPIICleanup` to run daily, finding sessions older than 30 days and anonymizing their PII, emitting audit logs.
- Keep raw IP/user-agent on insert for security auditing.
- Remove or rename the legacy `anonymizeIp` and `anonymizeUserAgent` pre-insert helpers if they exist.
- Implement `runPIICleanup` inside the `background`/`jobs` module using `@nestjs/schedule` to run daily.
- This job will find sessions older than 30 days and anonymize their PII (nullify or hash), emitting audit logs.
Comment on lines +23 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Specify one irreversible cleanup strategy.

nullify or hash is too ambiguous for a compliance-sensitive path. Those options have different privacy properties, and a hash can still leave the data linkable. Please document a single approved transformation and explicitly forbid raw IP/user-agent from being copied into the audit logs produced by this job.

Suggested doc change
-- This job will find sessions older than 30 days and anonymize their PII (nullify or hash), emitting audit logs.
+- This job will find sessions older than 30 days and apply a single approved irreversible cleanup strategy to their PII.
+- Prefer nulling the raw `ipAddress` and `userAgent` fields unless a reviewed retention requirement explicitly requires a non-reversible derived value.
+- Audit logs must record counts/record IDs/timestamps only and must not persist raw `ipAddress` or `userAgent`.

Comment on lines +25 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid documenting this as an in-process cron without a singleton guarantee.

Putting runPIICleanup behind @nestjs/schedule in the app module will run it once per replica in a scaled deployment. That creates duplicate cleanup passes and duplicate audit events unless you also define leader election or a distributed lock.

Suggested doc change
-- Implement `runPIICleanup` inside the `background`/`jobs` module using `@nestjs/schedule` to run daily.
+- Implement `runPIICleanup` as a singleton scheduled task.
+- If `@nestjs/schedule` is used, guard execution with leader election or a distributed lock; otherwise run it from a dedicated worker/queue so only one instance performs the daily cleanup.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Implement `runPIICleanup` inside the `background`/`jobs` module using `@nestjs/schedule` to run daily.
- This job will find sessions older than 30 days and anonymize their PII (nullify or hash), emitting audit logs.
- Implement `runPIICleanup` as a singleton scheduled task.
- If `@nestjs/schedule` is used, guard execution with leader election or a distributed lock; otherwise run it from a dedicated worker/queue so only one instance performs the daily cleanup.
- This job will find sessions older than 30 days and anonymize their PII (nullify or hash), emitting audit logs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TECHNICAL_DEBT.md` around lines 25 - 26, The doc currently recommends
implementing runPIICleanup with `@nestjs/schedule` which will execute in each
replica and produce duplicate cleanup/audit events; update the guidance in the
background/jobs section to explicitly warn against using an in-process cron
without a singleton guarantee and list safe alternatives: runPIICleanup only
from a single dedicated worker process, schedule it via an external orchestrator
(e.g., Kubernetes CronJob), or protect the in-process job with a distributed
lock/leader election (e.g., Redis Redlock, Consul/etcd leader election) and
provide a short note to ensure audit events are emitted exactly once per cleanup
pass.

- Add a Drizzle migration to backfill and anonymize existing old sessions.

### 2. Permission Caching Architecture
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/db/database-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ vi.mock('@nexiom/identity/utils/rbac-seeding', () => ({
seedSystemRbac: rbacMocks.seedSystemRbac,
}));

vi.mock('../constants', () => constantMocks);
vi.mock('../constants.js', () => constantMocks);

describe('DatabaseManager', () => {
let manager: DatabaseManager;
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/db/reset-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import * as bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import * as dotEnv from 'dotenv';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ALL_PERMISSIONS, isSystemPermission } from '../constants.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Load .env from apps/api root
dotEnv.config({ path: path.resolve(__dirname, '../../.env') });

Expand Down
17 changes: 6 additions & 11 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,9 @@ async function bootstrap() {
);
}

// Top-level await is not available in CommonJS.
const start = async () => {
try {
await bootstrap();
} catch (err) {
Logger.error('Bootstrap failed', err);
process.exit(1);
}
};

void start();
try {
await bootstrap();
} catch (err) {
Logger.error('Bootstrap failed', err);
process.exit(1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OAuthCallbackController } from './callback.controller.js';
import { ProviderRegistryService } from '@nexiom/connectors';
import { Request, Response } from 'express';
import { vi, describe, it, expect, beforeEach, Mocked } from 'vitest';
import type { Request, Response } from 'express';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { Mocked } from 'vitest';
import { OauthStateService } from '../oauth-state.service.js';
import { ConfigService } from '@nestjs/config';

Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/modules/identity/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
Delete,
Logger,
} from '@nestjs/common';
import { USER_PROVIDER, TENANT_PROVIDER, User } from '@nexiom/identity';
import type { ITenantProvider, IUserProvider } from '@nexiom/identity';
import { USER_PROVIDER, TENANT_PROVIDER } from '@nexiom/identity';
import type { ITenantProvider, IUserProvider, User } from '@nexiom/identity';
import { InvitationsService } from '../invitations/invitations.service.js';
import { CreateUser } from './users.validation.js';
import { Request } from 'express';
Expand Down Expand Up @@ -132,7 +132,7 @@ export class UsersController {
id: inv.id, // Use invitation ID temporarily
email: inv.email,
name: '', // Name might not be known yet
role: inv.role,
role: inv.role ?? 'member',
status: 'pending', // Explicit status for UI (vs 'active')
emailVerified: false,
createdAt: inv.createdAt,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/trigger/trigger.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { WebhooksController } from './webhooks.controller.js';
import { DATABASE_CONNECTION } from '@nexiom/database';
import type { DrizzleDb } from '@nexiom/database';
import { DB_MANAGER } from '../dbmanager/dbmanager.module.js';
import { DatabaseManager } from '@nexiom/dbmanager';
import type { DatabaseManager } from '@nexiom/dbmanager';

/**
* Wires all trigger-related services.
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/scripts/admin-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import * as dotenv from 'dotenv';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Load Environment Variables (MUST BE FIRST)
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
dotenv.config({ path: path.resolve(__dirname, '../../.env') });

import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
import { drizzle } from 'drizzle-orm/node-postgres';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Client } from 'pg';
import * as schema from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
Expand Down
4 changes: 4 additions & 0 deletions packages/connectors/src/crypto/encryption.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export abstract class EncryptionService {
abstract decrypt(val: string): Promise<string>;
abstract encrypt(val: string): Promise<string>;
}
5 changes: 3 additions & 2 deletions packages/connectors/src/crypto/encryption.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TokenManagerService } from '../oauth/token-manager.service.js';
import { EncryptionService } from './encryption.interface.js';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

@Injectable()
export class AesEncryptionService {
export class AesEncryptionService extends EncryptionService {
private readonly logger = new Logger(AesEncryptionService.name);
private readonly algorithm = 'aes-256-gcm';
private readonly keyBuffer: Buffer;
Expand All @@ -14,6 +14,7 @@ export class AesEncryptionService {
* Requires ENCRYPTION_KEY to be provided and exactly 32 bytes (raw string)
*/
constructor(private readonly configService: ConfigService) {
super();
const key = this.configService.get<string>('ENCRYPTION_KEY');
if (!key) {
throw new Error('ENCRYPTION_KEY is missing from configuration');
Expand Down
1 change: 1 addition & 0 deletions packages/connectors/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from './oauth/token-manager.service.js';
export * from './oauth/provider-registry.js';

// Crypto utilities
export * from './crypto/encryption.interface.js';
export * from './crypto/encryption.service.js';

// Framework — Piece, Action, Trigger, Auth, Property definitions
Expand Down
6 changes: 1 addition & 5 deletions packages/connectors/src/oauth/token-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ import { eq } from 'drizzle-orm';
import { Redis } from 'ioredis';
import type { DrizzleDb } from '@nexiom/database';

// Abstract contracts — consumers must provide real implementations via DI
export abstract class EncryptionService {
abstract decrypt(val: string): Promise<string>;
abstract encrypt(val: string): Promise<string>;
}
import { EncryptionService } from '../crypto/encryption.interface.js';

export class OAuthRefreshError extends Error {
constructor(message: string, public status?: number) {
Expand Down
6 changes: 5 additions & 1 deletion packages/database/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema/identity": {
"types": "./dist/schema/identity.d.ts",
"import": "./dist/schema/identity.js",
"default": "./dist/schema/identity.js"
},
"./schema/tenant": {
"types": "./dist/schema/tenant.d.ts",
"import": "./dist/schema/tenant.js",
"default": "./dist/schema/tenant.js"
},
"./dist/schema/identity": {
"types": "./dist/schema/identity.d.ts",
"import": "./dist/schema/identity.js",
"default": "./dist/schema/identity.js"
}
Expand All @@ -38,4 +42,4 @@
"drizzle-kit": "^0.31.8",
"typescript": "^5.7.3"
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Minor: Missing trailing newline.

The file ends without a trailing newline. POSIX convention and most linters expect files to end with a newline character.

🔧 Add trailing newline
   }
-}
+}
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/database/package.json` at line 45, The file ends with a closing
brace '}' but lacks a trailing newline; open the package.json that ends with
that '}' and add a single newline character after it (ensuring the file ends
with '\n') so it conforms to POSIX/newline conventions and linters.

24 changes: 19 additions & 5 deletions packages/identity/src/adapters/drizzle-user.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
it("create delegates to auth provider", async () => {
const db = mkDb();
const auth = mkAuth();
const adapter = new DrizzleUserAdapter(db, mkOptions(), auth);

Check warning on line 131 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

const user = await adapter.create({
email: "a@b.com",
Expand All @@ -140,7 +140,7 @@
it("update handles password via auth provider; updates fields; throws if missing user after update", async () => {
const db = mkDb();
const auth = mkAuth();
const adapter = new DrizzleUserAdapter(db, mkOptions(), auth);

Check warning on line 143 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

// password path
db.query.user.findFirst.mockResolvedValueOnce(mkUser({ id: "u1" }));
Expand All @@ -158,7 +158,7 @@
expect(res.name).toBe("A");

// missing setPassword support
const adapter2 = new DrizzleUserAdapter(db, mkOptions(), {

Check warning on line 161 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`
createUser: (input: CreateUserInput) => auth.createUser(input),
} as unknown as IAuthProvider);
await expect(
Expand All @@ -169,35 +169,44 @@
db.update.mockClear();
db.query.user.findFirst.mockResolvedValueOnce(mkUser({ id: "u1" }));
await adapter.update("u1", { password: "pw" } as UpdateUserInput);

expect(db.update).not.toHaveBeenCalled();
});

it("delete cascades and related tables in a transaction", async () => {
const db = mkDb();
const auth = mkAuth();
const adapter = new DrizzleUserAdapter(db, mkOptions(), auth);

Check warning on line 179 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

// Access the transaction mock to verify cascade behavior
const txCalls: string[] = [];
const txCalls: any[] = [];
db.transaction.mockImplementation((fn: (tx: MockTx) => unknown) => {
const tx = {
delete: vi.fn().mockImplementation(() => {
txCalls.push("delete");
delete: vi.fn().mockImplementation((table: any) => {
txCalls.push(table);
return { where: vi.fn().mockReturnThis() };
}),
} as unknown as MockTx;
return fn(tx);
});

await adapter.delete("u1");

expect(db.transaction).toHaveBeenCalled();
expect(txCalls.length).toBeGreaterThan(0);
expect(txCalls).toHaveLength(5);
expect(txCalls).toEqual([
schema.member,
schema.invitation,
schema.session,
schema.account,
schema.user,
]);
});

it("findById and findByEmail return mapped or null", async () => {
const db = mkDb();
const auth = mkAuth();
const adapter = new DrizzleUserAdapter(db, mkOptions(), auth);

Check warning on line 209 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

auth.findById = vi.fn().mockResolvedValue(mkUser({ id: "u1" }));
const byId = await adapter.findById("u1");
Expand All @@ -210,7 +219,7 @@
const authNoFind = mkAuth();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
delete (authNoFind as any).findById;
const adapterFallback = new DrizzleUserAdapter(db, mkOptions(), authNoFind);

Check warning on line 222 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

db.query.user.findFirst.mockResolvedValueOnce(mkUser({ id: "u2" }));
const byIdFallback = await adapterFallback.findById("u2");
Expand Down Expand Up @@ -240,7 +249,7 @@
it("deleteIfNotLastAdmin handles various scenarios", async () => {
const db = mkDb();
const auth = mkAuth();
const adapter = new DrizzleUserAdapter(db, mkOptions(), auth);

Check warning on line 252 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

const mockSelect = vi.fn();
const mockTx = {
Expand Down Expand Up @@ -367,21 +376,23 @@
it("forceVerifyEmail updates user", async () => {
const db = mkDb();
const auth = mkAuth();
const adapter = new DrizzleUserAdapter(db, mkOptions(), auth);

Check warning on line 379 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

await adapter.forceVerifyEmail("u1");

expect(db.update).toHaveBeenCalledWith(schema.user);

expect(db.set).toHaveBeenCalledWith(
expect.objectContaining({ emailVerified: true }),
);

expect(db.where).toHaveBeenCalled();
});

it("count returns total users with optional filtering", async () => {
const db = mkDb();
const auth = mkAuth();
const adapter = new DrizzleUserAdapter(db, mkOptions(), auth);

Check warning on line 395 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

// Mock count result
const mockCountResult = [{ count: 5 }];
Expand All @@ -393,19 +404,21 @@
// 1. Global count
const total = await adapter.count();
expect(total).toBe(5);

expect(db.innerJoin).not.toHaveBeenCalled();

// 2. Tenant count
db.innerJoin.mockClear();
const totalTenant = await adapter.count({ tenantId: "t1" });
expect(totalTenant).toBe(5);

expect(db.innerJoin).toHaveBeenCalled();
});

it("findAll builds search filters correctly", async () => {
const db = mkDb();
const auth = mkAuth();
const adapter = new DrizzleUserAdapter(db, mkOptions(), auth);

Check warning on line 421 in packages/identity/src/adapters/drizzle-user.adapter.spec.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test

Unsafe argument of type `any` assigned to a parameter of type `IdentityModuleOptions`

const dataChain = {
from: vi.fn().mockReturnThis(),
Expand All @@ -430,6 +443,7 @@
await adapter.findAll({ search: "test", limit: 10 });

expect(dataChain.where).toHaveBeenCalled();

expect(countChain.where).toHaveBeenCalled();
});

Expand Down Expand Up @@ -466,9 +480,9 @@
});

await adapter.findAll({ tenantId: "t1" });

expect(dataChain.innerJoin).toHaveBeenCalled();
expect(countChain.innerJoin).toHaveBeenCalled();
expect(dataChain.where).toHaveBeenCalled();
expect(countChain.where).toHaveBeenCalled();
});
Comment on lines 482 to 487

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assert tenant filtering on the count query too.

This spec verifies innerJoin() on countChain, but it never checks that the tenant predicate is applied there. If the count-side where() is omitted, the test still passes while pagination totals leak across tenants.

Suggested fix
     expect(dataChain.innerJoin).toHaveBeenCalled();
     expect(countChain.innerJoin).toHaveBeenCalled();
     expect(dataChain.where).toHaveBeenCalled();
+    expect(countChain.where).toHaveBeenCalled();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await adapter.findAll({ tenantId: "t1" });
expect(dataChain.innerJoin).toHaveBeenCalled();
expect(countChain.innerJoin).toHaveBeenCalled();
expect(dataChain.where).toHaveBeenCalled();
});
await adapter.findAll({ tenantId: "t1" });
expect(dataChain.innerJoin).toHaveBeenCalled();
expect(countChain.innerJoin).toHaveBeenCalled();
expect(dataChain.where).toHaveBeenCalled();
expect(countChain.where).toHaveBeenCalled();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/identity/src/adapters/drizzle-user.adapter.spec.ts` around lines 482
- 489, The test currently verifies that countChain.innerJoin was called but not
that the tenant filter was applied to the count query; update the spec that
calls adapter.findAll to also assert countChain.where was invoked with the same
tenant predicate used for dataChain (e.g., check countChain.where was called
and/or calledWith matching tenantId predicate), referencing the existing mocks
countChain and dataChain and the adapter.findAll invocation so the count-side
pagination total is validated for tenant scoping.

});
Loading