feat(permissions): implement role-based access control and refactor user permissions

- Introduced a new Permissions component to manage role-based access across various components.
- Updated user role checks to utilize the new permissions structure, replacing direct role comparisons with permission checks.
- Refactored multiple components to enhance permission handling, ensuring only authorized users can access specific features.
- Removed deprecated add-permissions component and streamlined user permission management.
- Enhanced role management in the backend to support the new permissions schema, improving overall security and maintainability.
This commit is contained in:
Mauricio Siu
2025-07-13 01:52:08 -06:00
parent db221e5cc4
commit 30d45bf2e5
35 changed files with 435 additions and 728 deletions

View File

@@ -5,111 +5,6 @@ import { organization, member } from "./account";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
export const PERMISSIONS = {
PROJECT: {
VIEW: {
name: "project:view",
description: "View projects",
},
CREATE: {
name: "project:create",
description: "Create projects",
},
DELETE: {
name: "project:delete",
description: "Delete projects",
},
},
SERVICE: {
VIEW: {
name: "service:view",
description: "View services",
},
CREATE: {
name: "service:create",
description: "Create services",
},
DELETE: {
name: "service:delete",
description: "Delete services",
},
},
TRAEFIK: {
ACCESS: {
name: "traefik_files:access",
description: "Access traefik files",
},
},
DOCKER: {
VIEW: {
name: "docker:view",
description: "View docker",
},
},
API: {
ACCESS: {
name: "api:access",
description: "Access API",
},
},
SCHEDULES: {
ACCESS: {
name: "schedules:access",
description: "Access schedules",
},
},
} as const;
export const ownerPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
] as const;
export const adminPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.DOCKER.VIEW,
PERMISSIONS.API.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
] as const;
export const memberPermissions = [
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.TRAEFIK.ACCESS,
] as const;
export const defaultPermissions = [
{
name: "owner",
description: "Owner of the organization with full access to all features",
permissions: ownerPermissions,
},
{
name: "admin",
description:
"Administrator with access to manage projects, services and configurations",
permissions: adminPermissions,
},
{
name: "member",
description:
"Regular member with access to create projects and manage services",
permissions: memberPermissions,
},
] as const;
export const role = pgTable(
"member_role",
{

View File

@@ -140,8 +140,18 @@ const { handler, api } = betterAuth({
});
}
} else {
const ownerRole = await db.query.role.findFirst({
where: and(eq(schema.role.name, "owner")),
});
if (!ownerRole) {
throw new APIError("BAD_REQUEST", {
message: "Owner role not found",
});
}
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
where: and(eq(schema.member.roleId, ownerRole.roleId)),
});
if (isAdminPresent) {
throw new APIError("BAD_REQUEST", {
@@ -152,8 +162,11 @@ const { handler, api } = betterAuth({
}
},
after: async (user) => {
const ownerRole = await db.query.role.findFirst({
where: and(eq(schema.role.name, "owner")),
});
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
where: and(eq(schema.member.roleId, ownerRole?.roleId || "")),
});
if (!IS_CLOUD) {
@@ -186,7 +199,6 @@ const { handler, api } = betterAuth({
await tx.insert(schema.member).values({
userId: user.id,
organizationId: organization?.id || "",
role: "owner",
createdAt: new Date(),
roleId: ownerRole?.roleId || "",
});
@@ -202,14 +214,18 @@ const { handler, api } = betterAuth({
where: eq(schema.member.userId, session.userId),
orderBy: desc(schema.member.createdAt),
with: {
role: true,
organization: true,
},
});
console.log(member);
return {
data: {
...session,
activeOrganizationId: member?.organization.id,
roleId: member?.roleId,
},
};
},
@@ -223,7 +239,7 @@ const { handler, api } = betterAuth({
user: {
modelName: "users",
additionalFields: {
role: {
roleId: {
type: "string",
// required: true,
input: false,
@@ -331,6 +347,7 @@ export const validateRequest = async (request: IncomingMessage) => {
),
with: {
organization: true,
role: true,
},
});
@@ -363,7 +380,7 @@ export const validateRequest = async (request: IncomingMessage) => {
createdAt,
updatedAt,
twoFactorEnabled,
role: member?.role || "member",
role: member?.role,
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
},
};
@@ -392,6 +409,15 @@ export const validateRequest = async (request: IncomingMessage) => {
};
}
const mockSession = {
session: {
...session.session,
},
user: {
...session.user,
ownerId: session.user.ownerId,
},
};
if (session?.user) {
const member = await db.query.member.findFirst({
where: and(
@@ -402,17 +428,21 @@ export const validateRequest = async (request: IncomingMessage) => {
),
),
with: {
role: true,
organization: true,
},
});
session.user.role = member?.role || "member";
if (member?.role) {
mockSession.user.role = member.role;
}
if (member) {
session.user.ownerId = member.organization.ownerId;
mockSession.user.ownerId = member.organization.ownerId;
} else {
session.user.ownerId = session.user.id;
mockSession.user.ownerId = session.user.id;
}
}
return session;
return mockSession;
};

View File

@@ -0,0 +1,131 @@
export const PERMISSIONS = {
PROJECT: {
VIEW: {
name: "project:view",
description: "View projects",
},
CREATE: {
name: "project:create",
description: "Create projects",
},
DELETE: {
name: "project:delete",
description: "Delete projects",
},
},
SERVICE: {
VIEW: {
name: "service:view",
description: "View services",
},
CREATE: {
name: "service:create",
description: "Create services",
},
DELETE: {
name: "service:delete",
description: "Delete services",
},
},
TRAEFIK: {
ACCESS: {
name: "traefik_files:access",
description: "Access traefik files",
},
},
DOCKER: {
VIEW: {
name: "docker:view",
description: "View docker",
},
},
API: {
ACCESS: {
name: "api:access",
description: "Access API",
},
},
SCHEDULES: {
ACCESS: {
name: "schedules:access",
description: "Access schedules",
},
},
GIT_PROVIDERS: {
ACCESS: {
name: "git_providers:access",
description: "Access git providers",
},
},
SSH_KEYS: {
ACCESS: {
name: "ssh_keys:access",
description: "Access ssh keys",
},
},
} as const;
export const ownerPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
PERMISSIONS.GIT_PROVIDERS.ACCESS,
PERMISSIONS.SSH_KEYS.ACCESS,
] as const;
export const adminPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.DOCKER.VIEW,
PERMISSIONS.API.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
PERMISSIONS.GIT_PROVIDERS.ACCESS,
PERMISSIONS.SSH_KEYS.ACCESS,
] as const;
export const memberPermissions = [
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.TRAEFIK.ACCESS,
] as const;
export const defaultPermissions = [
{
name: "owner",
description: "Owner of the organization with full access to all features",
permissions: ownerPermissions,
},
{
name: "admin",
description:
"Administrator with access to manage projects, services and configurations",
permissions: adminPermissions,
},
{
name: "member",
description:
"Regular member with access to create projects and manage services",
permissions: memberPermissions,
},
] as const;
// Utility type to extract all permission names
type ExtractPermissionNames<T> = T extends { name: infer U }
? U
: T extends object
? {
[K in keyof T]: ExtractPermissionNames<T[K]>;
}[keyof T]
: never;
export type PermissionName = ExtractPermissionNames<typeof PERMISSIONS>;

View File

@@ -3,6 +3,7 @@ import {
invitation,
member,
organization,
role,
users,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
@@ -13,9 +14,6 @@ import { findWebServer } from "./web-server";
export const findUserById = async (userId: string) => {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
// with: {
// account: true,
// },
});
if (!user) {
throw new TRPCError({
@@ -37,8 +35,12 @@ export const findOrganizationById = async (organizationId: string) => {
};
export const isAdminPresent = async () => {
const ownerRole = await db.query.role.findFirst({
where: eq(role.name, "owner"),
});
const admin = await db.query.member.findFirst({
where: eq(member.role, "owner"),
where: eq(member.roleId, ownerRole?.roleId || ""),
});
if (!admin) {
@@ -48,8 +50,12 @@ export const isAdminPresent = async () => {
};
export const findOwner = async () => {
const ownerRole = await db.query.role.findFirst({
where: eq(role.name, "owner"),
});
const owner = await db.query.member.findFirst({
where: eq(member.role, "owner"),
where: eq(member.roleId, ownerRole?.roleId || ""),
with: {
user: true,
},

View File

@@ -1,15 +1,17 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import {
adminPermissions,
type createRoleSchema,
member,
memberPermissions,
ownerPermissions,
role,
type updateRoleSchema,
} from "../db/schema";
import type { z } from "zod";
import {
adminPermissions,
memberPermissions,
ownerPermissions,
} from "../lib/permissions";
export const createRole = async (
input: z.infer<typeof createRoleSchema>,

View File

@@ -3,6 +3,7 @@ import { apikey, member, users } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { auth } from "../lib/auth";
import { PERMISSIONS } from "../lib/permissions";
export type User = typeof users.$inferSelect;
@@ -44,13 +45,16 @@ export const canPerformCreationService = async (
projectId: string,
organizationId: string,
) => {
const { accessedProjects, canCreateServices } = await findMemberById(
const { accessedProjects, role } = await findMemberById(
userId,
organizationId,
);
const haveAccessToProject = accessedProjects.includes(projectId);
if (canCreateServices && haveAccessToProject) {
if (
role?.permissions?.includes(PERMISSIONS.SERVICE.CREATE.name) &&
haveAccessToProject
) {
return true;
}
@@ -77,13 +81,16 @@ export const canPeformDeleteService = async (
serviceId: string,
organizationId: string,
) => {
const { accessedServices, canDeleteServices } = await findMemberById(
const { accessedServices, role } = await findMemberById(
userId,
organizationId,
);
const haveAccessToService = accessedServices.includes(serviceId);
if (canDeleteServices && haveAccessToService) {
if (
role?.permissions?.includes(PERMISSIONS.SERVICE.DELETE.name) &&
haveAccessToService
) {
return true;
}
@@ -94,9 +101,9 @@ export const canPerformCreationProject = async (
userId: string,
organizationId: string,
) => {
const { canCreateProjects } = await findMemberById(userId, organizationId);
const { role } = await findMemberById(userId, organizationId);
if (canCreateProjects) {
if (role?.permissions?.includes(PERMISSIONS.PROJECT.CREATE.name)) {
return true;
}
@@ -107,9 +114,9 @@ export const canPerformDeleteProject = async (
userId: string,
organizationId: string,
) => {
const { canDeleteProjects } = await findMemberById(userId, organizationId);
const { role } = await findMemberById(userId, organizationId);
if (canDeleteProjects) {
if (role?.permissions?.includes(PERMISSIONS.PROJECT.DELETE.name)) {
return true;
}
@@ -135,11 +142,8 @@ export const canAccessToTraefikFiles = async (
userId: string,
organizationId: string,
) => {
const { canAccessToTraefikFiles } = await findMemberById(
userId,
organizationId,
);
return canAccessToTraefikFiles;
const { role } = await findMemberById(userId, organizationId);
return role?.permissions?.includes(PERMISSIONS.TRAEFIK.ACCESS.name);
};
export const checkServiceAccess = async (
@@ -183,7 +187,7 @@ export const checkServiceAccess = async (
};
export const checkProjectAccess = async (
authId: string,
userId: string,
action: "create" | "delete" | "access",
organizationId: string,
projectId?: string,
@@ -192,16 +196,16 @@ export const checkProjectAccess = async (
switch (action) {
case "access":
hasPermission = await canPerformAccessProject(
authId,
userId,
projectId as string,
organizationId,
);
break;
case "create":
hasPermission = await canPerformCreationProject(authId, organizationId);
hasPermission = await canPerformCreationProject(userId, organizationId);
break;
case "delete":
hasPermission = await canPerformDeleteProject(authId, organizationId);
hasPermission = await canPerformDeleteProject(userId, organizationId);
break;
default:
hasPermission = false;
@@ -225,6 +229,7 @@ export const findMemberById = async (
),
with: {
user: true,
role: true,
},
});