Skip to content
Open
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
45 changes: 45 additions & 0 deletions backend/auth/controller/rbac.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// backend/auth/controller/rbac.controller.ts
import { Controller, Post, Delete, Body, Param, Req, UseGuards } from '@nestjs/common';
import { RoleService } from '../rbac/role.service';
import { RequirePermission } from '../middleware/permissions.decorator';
import { PermissionGuard } from '../middleware/permission.guard';

@Controller('auth/rbac')
@UseGuards(PermissionGuard)
export class RbacController {
constructor(private readonly roleService: RoleService) {}

/**
* Delete a role (Edge case fallback handled inside service)
*/
@Delete('roles/:id')
@RequirePermission('settings:admin')
async removeRole(@Param('id') id: string) {
await this.roleService.deleteRole(id);
return { message: 'Role deleted successfully and assigned users fell back to Viewer.' };
}

/**
* Assign a role to a user (Escalation prevention checked inside service)
*/
@Post('users/assign')
@RequirePermission('settings:admin')
async assignRole(
@Req() req: any,
@Body() body: { userId: string; roleId: string }
) {
// req.user.id is populated by your authentication layer passport/JWT strategy
const actorId = req.user.id;

const assignment = await this.roleService.assignRoleToUser(
actorId,
body.userId,
body.roleId
);

return {
message: 'Role assigned successfully without escalation.',
data: assignment,
};
}
}
53 changes: 53 additions & 0 deletions backend/auth/middleware/permission.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// backend/auth/middleware/permission.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PrismaService } from 'src/prisma/prisma.service';
import { REQUIRE_PERMISSION_KEY } from './permissions.decorator';
import { hasPermission } from '../rbac/permission-matcher';

@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private prisma: PrismaService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. Scan metadata to see if the specific route endpoint is protected
const requiredPermission = this.reflector.get<string>(REQUIRE_PERMISSION_KEY, context.getHandler());
if (!requiredPermission) return true; // Public or generically authenticated route

const request = context.switchToHttp().getRequest();
const user = request.user; // Set up upstream by authentication JWT validation
if (!user) return false;

// 2. Fetch all permissions bound to the user's assigned roles
const userAssignments = await this.prisma.userRole.findMany({
where: { userId: user.id },
include: { role: { include: { permissions: true } } },
});

const userPermissions = userAssignments.flatMap(ua =>
ua.role.permissions.map(p => p.permission)
);

// 3. Evaluate matching rights
const isAllowed = hasPermission(userPermissions, requiredPermission);

// 4. Create an Audit Log track entry
await this.prisma.permissionAuditLog.create({
data: {
action: requiredPermission,
actorId: user.id,
resource: request.params.id || 'generic',
outcome: isAllowed ? 'ALLOW' : 'DENY',
},
});

if (!isAllowed) {
throw new ForbiddenException('Insufficient resource access permissions.');
}

return true;
}
}
11 changes: 11 additions & 0 deletions backend/auth/middleware/permissions.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// backend/auth/middleware/permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const REQUIRE_PERMISSION_KEY = 'permissions';

/**
* Attaches resource-level permission requirements to route handlers.
* @example @RequirePermission('subscription:cancel')
*/
export const RequirePermission = (permission: string) =>
SetMetadata(REQUIRE_PERMISSION_KEY, permission);
14 changes: 14 additions & 0 deletions backend/auth/rbac/permission-matcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// backend/auth/rbac/permission-matcher.ts

export function hasPermission(userPermissions: string[], requiredPermission: string): boolean {
const [reqResource, reqAction] = requiredPermission.split(':');

return userPermissions.some(perm => {
const [userResource, userAction] = perm.split(':');

const resourceMatch = userResource === '*' || userResource === reqResource;
const actionMatch = userAction === '*' || userAction === reqAction;

return resourceMatch && actionMatch;
});
}
8 changes: 8 additions & 0 deletions backend/auth/rbac/permission.registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// backend/auth/rbac/permission.registry.ts

export const PREDEFINED_ROLES = {
Admin: ['*:*'],
Billing: ['billing:*', 'invoice:*'],
Support: ['subscription:read', 'invoice:read'],
Viewer: ['*:read'],
};
65 changes: 65 additions & 0 deletions backend/auth/rbac/role.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// backend/auth/rbac/role.service.ts
import { Injectable, ConflictException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { hasPermission } from './permission-matcher';

@Injectable()
export class RoleService {
constructor(private prisma: PrismaService) {}

/**
* Edge Case: Dynamic Role Deletion with a Viewer fallback binding
*/
async deleteRole(roleId: string): Promise<void> {
const role = await this.prisma.role.findUnique({ where: { id: roleId } });
if (!role) throw new ConflictException('Target role configuration not found.');
if (!role.isCustom) throw new ConflictException('Cannot remove core system-defined baseline roles.');

// Find the predefined Viewer role instance for fallback assignments
const viewerRole = await this.prisma.role.findUnique({ where: { name: 'Viewer' } });
if (!viewerRole) throw new ConflictException('Fallback Viewer framework role missing.');

await this.prisma.$transaction(async (tx) => {
// Re-assign all users currently using this role down to Viewer
await tx.userRole.updateMany({
where: { roleId },
data: { roleId: viewerRole.id },
});

// Cascading drops permissions and the role entity itself
await tx.role.delete({ where: { id: roleId } });
});
}

/**
* Edge Case: Prevention of Privilege Escalation
*/
async assignRoleToUser(actorId: string, targetUserId: string, targetRoleId: string) {
// 1. Compile the active operational rights of the supervisor pushing the change
const actorAssignments = await this.prisma.userRole.findMany({
where: { userId: actorId },
include: { role: { include: { permissions: true } } },
});
const actorPermissions = actorAssignments.flatMap(ua => ua.role.permissions.map(p => p.permission));

// 2. Fetch the target rights included in the role being assigned
const targetRole = await this.prisma.role.findUnique({
where: { id: targetRoleId },
include: { permissions: true },
});
if (!targetRole) throw new ConflictException('Target assignment role does not exist.');

// 3. Prevent privilege escalation: An actor cannot grant permissions they do not possess
for (const item of targetRole.permissions) {
if (!hasPermission(actorPermissions, item.permission)) {
throw new ForbiddenException(
`Privilege escalation prevention: You lack the required authority to grant the '${item.permission}' permission.`
);
}
}

return this.prisma.userRole.create({
data: { userId: targetUserId, roleId: targetRoleId },
});
}
}
2 changes: 1 addition & 1 deletion db/migrations/002_materialized_views.sql
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ SELECT
FROM subscriptions s
WHERE s.status = 'active'
GROUP BY s.user_id
WITH DATA;
WITH DATA;

CREATE UNIQUE INDEX IF NOT EXISTS idx_asm_user_id
ON active_subscriptions_summary (user_id);
Expand Down
52 changes: 52 additions & 0 deletions db/migrations/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
datasource db {
provider = "postgresql"
}

generator client {
provider = "prisma-client-js"
}

// db/schema/schema.prisma

enum AuditOutcome {
ALLOW
DENY
}

model Role {
id String @id @default(uuid())
name String @unique // e.g., 'Admin', 'Billing', 'Support', 'Viewer', or Custom names
isCustom Boolean @default(true)
permissions RolePermission[]
userRoles UserRole[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model RolePermission {
id String @id @default(uuid())
roleId String
permission String // Format: "resource:action" (e.g., "subscription:create")
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)

@@unique([roleId, permission])
}

model UserRole {
id String @id @default(uuid())
userId String
roleId String
role Role @relation(fields: [roleId], references: [id])
createdAt DateTime @default(now())

@@unique([userId, roleId])
}

model PermissionAuditLog {
id String @id @default(uuid())
action String // e.g., "subscription:cancel"
actorId String // User ID pushing the request
resource String // Target Resource ID or 'generic'
outcome AuditOutcome
timestamp DateTime @default(now())
}
6 changes: 1 addition & 5 deletions developer-portal/types/developer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,7 @@ export interface Developer {

export interface OnboardingStatus {
step:
| 'registration'
| 'email_verification'
| 'profile_completion'
| 'sandbox_setup'
| 'completed';
'registration' | 'email_verification' | 'profile_completion' | 'sandbox_setup' | 'completed';
completedSteps: string[];
startedAt: Date;
completedAt?: Date;
Expand Down
Loading
Loading