Skip to content

Commit

Permalink
feat: RBAC
Browse files Browse the repository at this point in the history
  • Loading branch information
potts99 committed Nov 13, 2024
1 parent c5d4964 commit 388d19e
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 31 deletions.
17 changes: 4 additions & 13 deletions apps/api/src/controllers/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,10 @@ export function dataRoutes(fastify: FastifyInstance) {
fastify.get(
"/api/v1/data/logs",
async (request: FastifyRequest, reply: FastifyReply) => {
const bearer = request.headers.authorization!.split(" ")[1];
const token = checkToken(bearer);

if (token) {
try {
const logs = await import("fs/promises").then((fs) =>
fs.readFile("logs.log", "utf-8")
);
reply.send({ logs: logs });
} catch (error) {
reply.code(500).send({ error: "Failed to read logs file" });
}
}
const logs = await import("fs/promises").then((fs) =>
fs.readFile("logs.log", "utf-8")
);
reply.send({ logs: logs });
}
);
}
33 changes: 20 additions & 13 deletions apps/api/src/controllers/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { track } from "../lib/hog";
import { requirePermission } from "../lib/roles";
import { checkSession } from "../lib/session";
import { prisma } from "../prisma";

export function webhookRoutes(fastify: FastifyInstance) {
// Create a new webhook
fastify.post(
"/api/v1/webhook/create",

{
preHandler: requirePermission(['webhook:create']),
},
async (request: FastifyRequest, reply: FastifyReply) => {
const user = await checkSession(request);
const { name, url, type, active, secret }: any = request.body;
await prisma.webhooks.create({
data: {
Expand All @@ -16,7 +21,7 @@ export function webhookRoutes(fastify: FastifyInstance) {
type,
active,
secret,
createdBy: "375f7799-5485-40ff-ba8f-0a28e0855ecf",
createdBy: user!.id,
},
});

Expand All @@ -36,7 +41,9 @@ export function webhookRoutes(fastify: FastifyInstance) {
// Get all webhooks
fastify.get(
"/api/v1/webhooks/all",

{
preHandler: requirePermission(['webhook:read']),
},
async (request: FastifyRequest, reply: FastifyReply) => {
const webhooks = await prisma.webhooks.findMany({});

Expand All @@ -45,20 +52,20 @@ export function webhookRoutes(fastify: FastifyInstance) {
);

// Delete a webhook

fastify.delete(
"/api/v1/admin/webhook/:id/delete",

{
preHandler: requirePermission(['webhook:delete']),
},
async (request: FastifyRequest, reply: FastifyReply) => {
const bearer = request.headers.authorization!.split(" ")[1];
const { id }: any = request.params;
await prisma.webhooks.delete({
where: {
id: id,
},
});
const { id }: any = request.params;
await prisma.webhooks.delete({
where: {
id: id,
},
});

reply.status(200).send({ success: true });
reply.status(200).send({ success: true });
}
);
}
126 changes: 126 additions & 0 deletions apps/api/src/lib/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { prisma, Role, User } from "../prisma";
import { checkSession } from "./session";
import { Permission } from "./types/permissions";

type UserWithRoles = User & {
roles: Role[];
};

export class InsufficientPermissionsError extends Error {
constructor(message: string = "Insufficient permissions") {
super(message);
this.name = "InsufficientPermissionsError";
}
}

/**
* Checks if a user has the required permissions through their roles
* @param user - The user with their roles loaded
* @param requiredPermissions - Single permission or array of permissions to check
* @param requireAll - If true, user must have ALL permissions. If false, only ONE permission is required
* @returns boolean
*/
export function hasPermission(
user: UserWithRoles,
requiredPermissions: Permission | Permission[],
requireAll: boolean = true
): boolean {
// Convert single permission to array for consistent handling
const permissions = Array.isArray(requiredPermissions)
? requiredPermissions
: [requiredPermissions];

// Combine all permissions from user's roles and default role
const userPermissions = new Set<Permission>();

// Add permissions from default role if it exists
const defaultRole = user.roles.find((role) => role.isDefault);
if (defaultRole) {
defaultRole.permissions.forEach((perm) => userPermissions.add(perm as Permission));
}

// Add permissions from additional roles
user.roles.forEach((role) => {
role.permissions.forEach((perm) => userPermissions.add(perm as Permission));
});

if (requireAll) {
// Check if user has ALL required permissions
return permissions.every((permission) => userPermissions.has(permission));
} else {
// Check if user has AT LEAST ONE of the required permissions
return permissions.some((permission) => userPermissions.has(permission));
}
}

/**
* Authorization middleware that checks for required permissions
* @param requiredPermissions - Single permission or array of permissions to check
* @param requireAll - If true, user must have ALL permissions. If false, only ONE permission is required
*/
export function requirePermission(
requiredPermissions: Permission | Permission[],
requireAll: boolean = true
) {
return async (req: any, res: any, next: any) => {
try {
const user = await checkSession(req);
const config = await prisma.config.findFirst();

if (!config?.roles_active) {
next();
}

const userWithRoles = user
? await prisma.user.findUnique({
where: { id: user.id },
include: {
roles: true,
},
})
: null;

if (!userWithRoles) {
throw new Error("User not authenticated");
}

if (!hasPermission(userWithRoles, requiredPermissions, requireAll)) {
throw new InsufficientPermissionsError();
}

next();
} catch (error) {
next(error);
}
};
}

// Usage examples:
/*
// Check single permission
if (hasPermission(user, 'issue::create')) {
// Allow create ticket
}
// Check multiple permissions (all required)
if (hasPermission(user, ['issue:update', 'issue:assign'])) {
// Allow ticket update and assignment
}
// Check multiple permissions (at least one required)
if (hasPermission(user, ['user:manage', 'role:manage'], false)) {
// Allow access if user has either permission
}
// Use as middleware
router.post('/tickets',
requirePermission('issue::create'),
ticketController.create
);
// Use as middleware with multiple permissions
router.put('/tickets/:id/assign',
requirePermission(['issue:update', 'issue:assign']),
ticketController.assign
);
*/
101 changes: 101 additions & 0 deletions apps/api/src/lib/types/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
export type IssuePermission =
| 'issue:create'
| 'issue:read'
| 'issue:write'
| 'issue:update'
| 'issue:delete'
| 'issue:assign'
| 'issue:transfer'
| 'issue:comment';

export type UserPermission =
| 'user:create'
| 'user:read'
| 'user:update'
| 'user:delete'
| 'user:manage';

export type RolePermission =
| 'role:create'
| 'role:read'
| 'role:update'
| 'role:delete'
| 'role:manage';

export type TeamPermission =
| 'team:create'
| 'team:read'
| 'team:update'
| 'team:delete'
| 'team:manage';

export type ClientPermission =
| 'client:create'
| 'client:read'
| 'client:update'
| 'client:delete'
| 'client:manage';

export type KnowledgeBasePermission =
| 'kb:create'
| 'kb:read'
| 'kb:update'
| 'kb:delete'
| 'kb:manage';

export type SystemPermission =
| 'settings:view'
| 'settings:manage'
| 'webhook:manage'
| 'integration:manage'
| 'email_template:manage';

export type TimeTrackingPermission =
| 'time_entry:create'
| 'time_entry:read'
| 'time_entry:update'
| 'time_entry:delete';

export type ViewPermission =
| 'docs:manage'
| 'admin:panel';

export type WebhookPermission =
| 'webhook:create'
| 'webhook:read'
| 'webhook:update'
| 'webhook:delete';

export type Permission =
| IssuePermission
| UserPermission
| RolePermission
| TeamPermission
| ClientPermission
| KnowledgeBasePermission
| SystemPermission
| TimeTrackingPermission
| ViewPermission
| WebhookPermission;

// Useful type for grouping permissions by category
export const PermissionCategories = {
ISSUE: 'Issue Management',
USER: 'User Management',
ROLE: 'Role Management',
TEAM: 'Team Management',
CLIENT: 'Client Management',
KNOWLEDGE_BASE: 'Knowledge Base',
SYSTEM: 'System Settings',
TIME_TRACKING: 'Time Tracking',
VIEW: 'Views',
WEBHOOK: 'Webhook Management',
} as const;

export type PermissionCategory = typeof PermissionCategories[keyof typeof PermissionCategories];

// Helper type for permission groups
export interface PermissionGroup {
category: PermissionCategory;
permissions: Permission[];
}
4 changes: 2 additions & 2 deletions apps/api/src/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { PrismaClient } from "@prisma/client";
import { Hook, Permission, PrismaClient, Role, User } from "@prisma/client";
export const prisma: PrismaClient = new PrismaClient();
export type Hook = "ticket_created" | "ticket_status_changed";
export { Hook, Permission, Role, User };
36 changes: 36 additions & 0 deletions apps/api/src/prisma/migrations/20241113193825_rbac/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- CreateEnum
CREATE TYPE "Permission" AS ENUM ('CREATE_TICKET', 'READ_TICKET', 'WRITE_TICKET', 'UPDATE_TICKET', 'DELETE_TICKET', 'ASSIGN_TICKET', 'TRANSFER_TICKET', 'COMMENT_TICKET', 'CREATE_USER', 'READ_USER', 'UPDATE_USER', 'DELETE_USER', 'MANAGE_USERS', 'CREATE_ROLE', 'READ_ROLE', 'UPDATE_ROLE', 'DELETE_ROLE', 'MANAGE_ROLES', 'CREATE_TEAM', 'READ_TEAM', 'UPDATE_TEAM', 'DELETE_TEAM', 'MANAGE_TEAMS', 'CREATE_CLIENT', 'READ_CLIENT', 'UPDATE_CLIENT', 'DELETE_CLIENT', 'MANAGE_CLIENTS', 'CREATE_KB', 'READ_KB', 'UPDATE_KB', 'DELETE_KB', 'MANAGE_KB', 'VIEW_REPORTS', 'MANAGE_SETTINGS', 'MANAGE_WEBHOOKS', 'MANAGE_INTEGRATIONS', 'MANAGE_EMAIL_TEMPLATES', 'CREATE_TIME_ENTRY', 'READ_TIME_ENTRY', 'UPDATE_TIME_ENTRY', 'DELETE_TIME_ENTRY', 'MANAGE_DOCS', 'ADMIN_PANEL');

-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"permissions" "Permission"[],
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "_RoleToUser" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);

-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");

-- CreateIndex
CREATE UNIQUE INDEX "_RoleToUser_AB_unique" ON "_RoleToUser"("A", "B");

-- CreateIndex
CREATE INDEX "_RoleToUser_B_index" ON "_RoleToUser"("B");

-- AddForeignKey
ALTER TABLE "_RoleToUser" ADD CONSTRAINT "_RoleToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "_RoleToUser" ADD CONSTRAINT "_RoleToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Config" ADD COLUMN "roles_active" BOOLEAN NOT NULL DEFAULT false;
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
Warnings:
- The `permissions` column on the `Role` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "Role" DROP COLUMN "permissions",
ADD COLUMN "permissions" JSONB[];

-- DropEnum
DROP TYPE "Permission";
Loading

0 comments on commit 388d19e

Please sign in to comment.