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
263 changes: 263 additions & 0 deletions apps/api/src/db/reset-e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { Client } from 'pg';
import * as bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import * as dotenv from 'dotenv';
import * as path from 'node:path';

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

const main = async () => {
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is not defined in environment');
process.exit(1);
}

// Production safeguard
if (
process.env.NODE_ENV === 'production' ||
process.env.DATABASE_URL.includes('prod') ||
process.env.DATABASE_URL.includes('rds.amazonaws.com')
) {
if (process.env.FORCE_RESET !== 'true') {
console.error(
'FATAL: Attempting to run reset-e2e against production database!',
);
console.error('This operation would destroy all production data.');
console.error('If you absolutely must proceed, set FORCE_RESET=true');
process.exit(1);
}
console.warn(
'WARNING: FORCE_RESET=true detected, proceeding with production reset...',
);
}

console.log('Connecting to DB...');
const client = new Client({ connectionString: process.env.DATABASE_URL });

Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
await client.connect();
console.log('--- 1. Truncating Tables ---');
const tables = [
'role_permission',
'permission',
'member',
'invitation',
'account',
'session',
'verification',
'organization',
'user',
'role',
];

for (const table of tables) {
try {
await client.query(`TRUNCATE TABLE "${table}" CASCADE;`);
} catch (e) {
console.error(`Error truncating table "${table}":`, e);
console.log(`Skipped ${table} - check permissions or table existence`);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

console.log('--- 2. Seeding RBAC ---');
// Roles
await client.query(`
INSERT INTO "role" (id, name, "isSystem", description, "createdAt") VALUES
('owner', 'Owner', true, 'Full access', NOW()),
('admin', 'Admin', true, 'Manage users', NOW()),
('member', 'Member', true, 'Read only', NOW()),
('platform_admin', 'Platform Admin', true, 'System Root Access', NOW())
ON CONFLICT DO NOTHING;
`);

// Permissions
const perms = [
'users:read',
'users:create',
'users:update',
'users:delete',
'users:manage',
'tenants:read',
'tenants:create',
'tenants:update',
'tenants:delete',
'tenants:manage',
'dashboard:read',
'admin_dashboard:view', // Required for Redirect to /admin
'settings:manage',
'settings:read',
// System Admin Permissions
'system_users:read',
'system_users:manage',
'system_users:invite',
'system_tenants:read',
'system_tenants:manage',
];

for (const p of perms) {
const resource = p.split(':')[0];
const action = p.split(':')[1];
await client.query(
`
INSERT INTO "permission" (id, resource, action, "createdAt")
VALUES ($1, $2, $3, NOW())
ON CONFLICT DO NOTHING;
`,
[p, resource, action],
);
}

// Role Permissions
// Owner & Platform Admin: All
for (const p of perms) {
await client.query(
`INSERT INTO "role_permission" ("roleId", "permissionId") VALUES ('owner', $1) ON CONFLICT DO NOTHING`,
[p],
);
await client.query(
`INSERT INTO "role_permission" ("roleId", "permissionId") VALUES ('platform_admin', $1) ON CONFLICT DO NOTHING`,
[p],
);
}

// Member: Read
await client.query(
`INSERT INTO "role_permission" ("roleId", "permissionId") VALUES ('member', 'users:read') ON CONFLICT DO NOTHING`,
);
await client.query(
`INSERT INTO "role_permission" ("roleId", "permissionId") VALUES ('member', 'tenants:read') ON CONFLICT DO NOTHING`,
);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Admin: All non-system permissions
for (const p of perms) {
if (!p.startsWith('system_')) {
await client.query(
`INSERT INTO "role_permission" ("roleId", "permissionId") VALUES ('admin', $1) ON CONFLICT DO NOTHING`,
[p],
);
}
}

console.log('--- 3. Seeding User ---');
const userId = uuidv4();
const email = 'test.user+e2e@example.com';

await client.query(
`
INSERT INTO "user" (id, email, "emailVerified", name, "createdAt", "updatedAt")
VALUES ($1, $2, true, 'Test User', NOW(), NOW());
`,
[userId, email],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

console.log('--- 4. Seeding Account (Password) ---');
const passwordStart = 'password123';
const hash = await bcrypt.hash(passwordStart, 10);
const accountId = uuidv4();

await client.query(
`
INSERT INTO "account" (id, "userId", "accountId", "providerId", password, "createdAt", "updatedAt")
VALUES ($1, $2, $3, 'credential', $4, NOW(), NOW());
`,
[accountId, userId, email, hash],
);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
console.log('--- 5. Seeding Organizations ---');

// 5a. System Tenant (Nexiom)
const systemTenantId = '00000000-0000-0000-0000-000000000000';
await client.query(
`
INSERT INTO "organization" (id, name, status, "isSystem", "createdAt", "updatedAt")
VALUES ($1, 'Nexiom Platform', 'active', true, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
`,
[systemTenantId],
);

// 5b. Demo Customer Tenant
const demoTenantId = uuidv4();
await client.query(
`
INSERT INTO "organization" (id, name, status, "createdAt", "updatedAt")
VALUES ($1, 'Acme Corp', 'active', NOW(), NOW());
`,
[demoTenantId],
);

console.log('--- 6. Seeding Membership ---');

// 6a. Platform Admin Access (System Context)
const sysMemberId = uuidv4();
// Note: platform_admin role already seeded in step 2 (roles: owner, admin, member, platform_admin)

await client.query(
`
INSERT INTO "member" (id, "organizationId", "userId", "roleId", "createdAt")
VALUES ($1, $2, $3, 'platform_admin', NOW());
`,
[sysMemberId, systemTenantId, userId],
);

// 6b. Customer Tenant Access (Business Context)
// STRICT: One User One Org. Pramod is Platform Admin ONLY.
// We create a separate user for the customer tenant.
const customerUserId = uuidv4();
const customerEmail = 'customer@example.com';

await client.query(
`
INSERT INTO "user" (id, email, "emailVerified", name, "createdAt", "updatedAt")
VALUES ($1, $2, true, 'Acme Customer', NOW(), NOW());
`,
[customerUserId, customerEmail],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const customerHash = await bcrypt.hash('password123', 10);
await client.query(
`
INSERT INTO "account" (id, "userId", "accountId", "providerId", password, "createdAt", "updatedAt")
VALUES ($1, $2, $3, 'credential', $4, NOW(), NOW());
`,
[uuidv4(), customerUserId, customerEmail, customerHash],
);

const memberId = uuidv4();
await client.query(
`
INSERT INTO "member" (id, "organizationId", "userId", "roleId", "createdAt")
VALUES ($1, $2, $3, 'owner', NOW());
`,
[memberId, demoTenantId, customerUserId],
);

console.log('✅ SEED COMPLETE');
// Security: Do not log full credentials in CI/Production logs
const showCreds = process.env.SHOW_CREDENTIALS === 'true';
if (showCreds) {
console.log(
`[Platform Admin] ${email} / ${passwordStart} (Org: Nexiom Platform)`,
);
console.log(
`[Customer User] ${customerEmail} / password123 (Org: Acme Corp)`,
);
} else {
console.log(
`[Platform Admin] ${email} / ******** (Org: Nexiom Platform)`,
);
console.log(
`[Customer User] ${customerEmail} / ******** (Org: Acme Corp)`,
);
console.log('(Set SHOW_CREDENTIALS=true to view passwords)');
}
} catch (error) {
console.error('Seed failed:', error);
process.exit(1);
} finally {
await client.end();
}
};

void main();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions apps/api/src/modules/identity/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export class AuthService {
session: Session;
user: User & {
organizationId?: string;
organizationName?: string;
organizationSlug?: string;
hasTenant: boolean;
permissions: string[];
};
Expand All @@ -83,6 +85,12 @@ export class AuthService {
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const hasTenant = sortedTenants.length > 0;
const organizationId = hasTenant ? sortedTenants[0].id : undefined;
const organizationName = hasTenant
? (sortedTenants[0].name ?? undefined)
: undefined;
const organizationSlug = hasTenant
? (sortedTenants[0].slug ?? undefined)
: undefined;

// Fetch Permissions
// If we have an organization context, fetch permissions for that tenant
Expand Down Expand Up @@ -130,6 +138,8 @@ export class AuthService {
user: {
...user,
organizationId,
organizationName,
organizationSlug,
hasTenant,
permissions,
},
Expand Down
15 changes: 7 additions & 8 deletions apps/api/src/modules/identity/auth/permissions.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PermissionsGuard } from './permissions.guard';
import { Reflector } from '@nestjs/core';
import { ExecutionContext } from '@nestjs/common';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Test } from '@nestjs/testing';

describe('PermissionsGuard', () => {
Expand Down Expand Up @@ -53,7 +53,9 @@ describe('PermissionsGuard', () => {
{ resource: 'users', action: 'manage' },
]);
const user = { permissions: ['users:read'] };
expect(guard.canActivate(mockContext(user))).toBe(false);
expect(() => guard.canActivate(mockContext(user))).toThrow(
ForbiddenException,
);
});

it('should allow if user has wildcard', () => {
Expand All @@ -65,15 +67,10 @@ describe('PermissionsGuard', () => {
});

it('should allow if user matches one of multiple required permissions', () => {
// Verify guard allows user when any required permission matches

// But let's test Array behavior just in case.
vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
{ resource: 'users', action: 'read' },
{ resource: 'users', action: 'manage' },
]);
// Guard uses .some(). So if I have ONE of them, I pass.
// This implies "OR" logic.
const user = { permissions: ['users:read'] };
expect(guard.canActivate(mockContext(user))).toBe(true);
});
Expand All @@ -92,6 +89,8 @@ describe('PermissionsGuard', () => {
{ resource: 'users', action: 'manage' },
]);
const user = { permissions: undefined };
expect(guard.canActivate(mockContext(user))).toBe(false);
expect(() => guard.canActivate(mockContext(user))).toThrow(
ForbiddenException,
);
});
});
21 changes: 18 additions & 3 deletions apps/api/src/modules/identity/auth/permissions.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Injectable,
Logger,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
Expand All @@ -17,7 +18,7 @@ import { User } from '@nexiom/identity';
export class PermissionsGuard implements CanActivate {
private readonly logger = new Logger(PermissionsGuard.name);

constructor(private reflector: Reflector) {}
constructor(private readonly reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<
Expand All @@ -39,12 +40,26 @@ export class PermissionsGuard implements CanActivate {

if (!user.permissions) {
this.logger.warn(`User ${user.id} has no permissions loaded`);
return false;
throw new ForbiddenException('User has no permissions assigned');
}

return requiredPermissions.some((required) =>
const hasPerm = requiredPermissions.some((required) =>
this.hasPermission(user.permissions, required),
);

if (!hasPerm) {
const requiredStrings = requiredPermissions
.map((r) => `${r.resource}:${r.action}`)
.join(', ');

this.logger.warn(
`Missing required permissions: [${requiredStrings}]. User has: [${user.permissions.join(', ')}]`,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

throw new ForbiddenException('Missing required permissions');
}

return true;
}

private hasPermission(
Expand Down
Loading