refactor: simplify role management by removing unused role schema and related logic; update user role checks in context and procedures

This commit is contained in:
Mauricio Siu
2025-07-13 14:00:26 -06:00
parent cee426dcf5
commit d84099108a
10 changed files with 305 additions and 373 deletions
@@ -1,6 +1,6 @@
import { db } from "@/server/db";
import { invitation, member, organization, role } from "@/server/db/schema";
import { createDefaultRoles, IS_CLOUD } from "@dokploy/server/index";
import { invitation, member, organization } from "@/server/db/schema";
import { IS_CLOUD } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
@@ -38,18 +38,12 @@ export const organizationRouter = createTRPCRouter({
message: "Failed to create organization",
});
}
await createDefaultRoles(result.id);
const ownerRole = await db.query.role.findFirst({
where: and(eq(role.name, "owner"), eq(role.organizationId, result.id)),
});
await db.insert(member).values({
organizationId: result.id,
role: "owner",
createdAt: new Date(),
userId: ctx.user.id,
roleId: ownerRole?.roleId || "",
});
return result;
}),
+72 -83
View File
@@ -1,86 +1,75 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiFindOneRole,
createRoleSchema,
role,
updateRoleSchema,
} from "@/server/db/schema";
import { createRole, removeRoleById, updateRoleById } from "@dokploy/server";
import { defaultPermissions } from "@dokploy/server/lib/permissions";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
import { createTRPCRouter } from "@/server/api/trpc";
// import { createRole, removeRoleById, updateRoleById } from "@dokploy/server";
// import { defaultPermissions } from "@dokploy/server/lib/permissions";
export const roleRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
const roles = await db.query.role.findMany({
where: and(
eq(role.organizationId, ctx.session.activeOrganizationId),
eq(role.isSystem, false),
),
orderBy: [asc(role.createdAt)],
});
return roles;
}),
delete: protectedProcedure
.input(apiFindOneRole)
.mutation(async ({ input }) => {
try {
return removeRoleById(input.roleId);
} catch (error) {
const message =
error instanceof Error ? error.message : "Error input: Deleting role";
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
}),
create: protectedProcedure
.input(createRoleSchema)
.mutation(async ({ input, ctx }) => {
try {
return await createRole(
{
...input,
},
ctx.session.activeOrganizationId,
);
} catch (error) {
console.error(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Creating role",
cause: error,
});
}
}),
update: protectedProcedure
.input(updateRoleSchema)
.mutation(async ({ input }) => {
return await updateRoleById(input.roleId, input);
}),
getDefaultRoles: protectedProcedure.query(async ({ ctx }) => {
const roles = await db.query.role.findMany({
where: and(
eq(role.organizationId, ctx.session.activeOrganizationId),
eq(role.isSystem, true),
),
});
// add the description from the constants roles to the roles
const rolesWithDescription = defaultPermissions.map((role) => {
const roleInfo = roles.find((r) => r.name === role.name);
return {
...roleInfo,
...role,
};
});
const set = new Set(rolesWithDescription.flatMap((r) => r.permissions));
return {
roles: rolesWithDescription,
permissions: Array.from(set),
};
}),
// all: protectedProcedure.query(async ({ ctx }) => {
// const roles = await db.query.role.findMany({
// where: and(
// eq(role.organizationId, ctx.session.activeOrganizationId),
// eq(role.isSystem, false),
// ),
// orderBy: [asc(role.createdAt)],
// });
// return roles;
// }),
// delete: protectedProcedure
// .input(apiFindOneRole)
// .mutation(async ({ input }) => {
// try {
// return removeRoleById(input.roleId);
// } catch (error) {
// const message =
// error instanceof Error ? error.message : "Error input: Deleting role";
// throw new TRPCError({
// code: "BAD_REQUEST",
// message,
// });
// }
// }),
// create: protectedProcedure
// .input(createRoleSchema)
// .mutation(async ({ input, ctx }) => {
// try {
// return await createRole(
// {
// ...input,
// },
// ctx.session.activeOrganizationId,
// );
// } catch (error) {
// console.error(error);
// throw new TRPCError({
// code: "BAD_REQUEST",
// message: "Error input: Creating role",
// cause: error,
// });
// }
// }),
// update: protectedProcedure
// .input(updateRoleSchema)
// .mutation(async ({ input }) => {
// return await updateRoleById(input.roleId, input);
// }),
// getDefaultRoles: protectedProcedure.query(async ({ ctx }) => {
// const roles = await db.query.role.findMany({
// where: and(
// eq(role.organizationId, ctx.session.activeOrganizationId),
// eq(role.isSystem, true),
// ),
// });
// // add the description from the constants roles to the roles
// const rolesWithDescription = defaultPermissions.map((role) => {
// const roleInfo = roles.find((r) => r.name === role.name);
// return {
// ...roleInfo,
// ...role,
// };
// });
// const set = new Set(rolesWithDescription.flatMap((r) => r.permissions));
// return {
// roles: rolesWithDescription,
// permissions: Array.from(set),
// };
// }),
});
+3 -17
View File
@@ -30,17 +30,7 @@ import { ZodError } from "zod";
*/
interface CreateContextOptions {
user:
| (User & {
role: {
roleId: string;
name: string;
permissions: string[];
isSystem: boolean;
};
ownerId: string;
})
| null;
user: (User & { role: "member" | "admin" | "owner"; ownerId: string }) | null;
session:
| (Session & { activeOrganizationId: string; impersonatedBy?: string })
| null;
@@ -192,7 +182,7 @@ export const uploadProcedure = async (opts: any) => {
};
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user || ctx.user.role.name !== "owner") {
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
@@ -206,11 +196,7 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => {
});
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
if (
!ctx.session ||
!ctx.user ||
(ctx.user.role.name !== "owner" && ctx.user.role.name !== "admin")
) {
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
+7 -7
View File
@@ -10,7 +10,7 @@ import { nanoid } from "nanoid";
import { projects } from "./project";
import { server } from "./server";
import { users } from "./user";
import { role } from "./rbac";
// import { role } from "./rbac";
export const account = pgTable("account", {
id: text("id")
@@ -92,8 +92,8 @@ export const member = pgTable("member", {
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: text("role"),
roleId: text("roleId").references(() => role.roleId, { onDelete: "cascade" }),
role: text("role").$type<"owner" | "member" | "admin">(),
// roleId: text("roleId").references(() => role.roleId, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull(),
teamId: text("team_id"),
// Permissions
@@ -116,10 +116,10 @@ export const memberRelations = relations(member, ({ one }) => ({
fields: [member.userId],
references: [users.id],
}),
role: one(role, {
fields: [member.roleId],
references: [role.roleId],
}),
// role: one(role, {
// fields: [member.roleId],
// references: [role.roleId],
// }),
}));
export const invitation = pgTable("invitation", {
+1 -1
View File
@@ -30,7 +30,7 @@ export * from "./server";
export * from "./utils";
export * from "./preview-deployments";
export * from "./ai";
export * from "./rbac";
// export * from "./rbac";
export * from "./account";
export * from "./schedule";
export * from "./rollbacks";
+52 -52
View File
@@ -1,58 +1,58 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, unique } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { organization, member } from "./account";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
// import { relations } from "drizzle-orm";
// import { pgTable, text, timestamp, boolean, unique } from "drizzle-orm/pg-core";
// import { nanoid } from "nanoid";
// import { organization, member } from "./account";
// import { createInsertSchema } from "drizzle-zod";
// import { z } from "zod";
export const role = pgTable(
"member_role",
{
roleId: text("roleId")
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull().unique(),
description: text("description"),
canDelete: boolean("canDelete").notNull().default(true),
isSystem: boolean("is_system").default(false),
permissions: text("permissions").array(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
},
(table) => ({
roleName: unique("role_name_unique").on(table.name, table.organizationId),
}),
);
// export const role = pgTable(
// "member_role",
// {
// roleId: text("roleId")
// .primaryKey()
// .$defaultFn(() => nanoid()),
// name: text("name").notNull().unique(),
// description: text("description"),
// canDelete: boolean("canDelete").notNull().default(true),
// isSystem: boolean("is_system").default(false),
// permissions: text("permissions").array(),
// createdAt: timestamp("created_at").notNull().defaultNow(),
// updatedAt: timestamp("updated_at").notNull().defaultNow(),
// organizationId: text("organizationId")
// .notNull()
// .references(() => organization.id, { onDelete: "cascade" }),
// },
// (table) => ({
// roleName: unique("role_name_unique").on(table.name, table.organizationId),
// }),
// );
export const roleRelations = relations(role, ({ one, many }) => ({
organization: one(organization, {
fields: [role.organizationId],
references: [organization.id],
}),
members: many(member),
}));
// export const roleRelations = relations(role, ({ one, many }) => ({
// organization: one(organization, {
// fields: [role.organizationId],
// references: [organization.id],
// }),
// members: many(member),
// }));
export type Role = typeof role.$inferSelect;
// export type Role = typeof role.$inferSelect;
export const createRoleSchema = createInsertSchema(role)
.omit({
roleId: true,
createdAt: true,
updatedAt: true,
isSystem: true,
organizationId: true,
})
.extend({
permissions: z.array(z.string()),
});
// export const createRoleSchema = createInsertSchema(role)
// .omit({
// roleId: true,
// createdAt: true,
// updatedAt: true,
// isSystem: true,
// organizationId: true,
// })
// .extend({
// permissions: z.array(z.string()),
// });
export const updateRoleSchema = createRoleSchema.extend({
roleId: z.string().min(1),
});
// export const updateRoleSchema = createRoleSchema.extend({
// roleId: z.string().min(1),
// });
export const apiFindOneRole = z.object({
roleId: z.string().min(1),
});
// export const apiFindOneRole = z.object({
// roleId: z.string().min(1),
// });
+12 -96
View File
@@ -2,7 +2,7 @@ import type { IncomingMessage } from "node:http";
import * as bcrypt from "bcrypt";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { APIError } from "better-auth/api";
import { admin, apiKey, organization, twoFactor } from "better-auth/plugins";
import { and, desc, eq } from "drizzle-orm";
import { IS_CLOUD } from "../constants";
@@ -11,12 +11,7 @@ import * as schema from "../db/schema";
import { getUserByToken } from "../services/admin";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
import { createDefaultRoles } from "../services/role";
import {
findWebServer,
updateWebServer,
} from "@dokploy/server/services/web-server";
import type { Role } from "../db/schema/rbac";
import { findWebServer, updateWebServer } from "../services/web-server";
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
@@ -84,48 +79,6 @@ const { handler, api } = betterAuth({
});
},
},
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (ctx.path === "/organization/accept-invitation") {
const invitationId = ctx.body.invitationId;
if (invitationId) {
const user = await getUserByToken(invitationId);
if (!user) {
throw new APIError("BAD_REQUEST", {
message: "User not found",
});
}
const role = await db.query.role.findFirst({
where: and(
eq(schema.role.name, user.role || "member"),
eq(schema.role.organizationId, user.organizationId),
),
});
const userTemp = await db.query.users.findFirst({
where: eq(schema.users.email, user.email),
});
const member = await db.query.member.findFirst({
where: and(
eq(schema.member.userId, userTemp?.id || ""),
eq(schema.member.organizationId, user.organizationId),
),
});
await db
.update(schema.member)
.set({
roleId: role?.roleId || "",
})
.where(eq(schema.member.userId, member?.userId || ""))
.returning();
}
}
}),
},
databaseHooks: {
user: {
create: {
@@ -135,25 +88,14 @@ const { handler, api } = betterAuth({
context?.request?.headers?.get("x-dokploy-token");
if (xDokployToken) {
const user = await getUserByToken(xDokployToken);
if (!user) {
throw new APIError("BAD_REQUEST", {
message: "User not found",
});
}
} 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: and(eq(schema.member.roleId, ownerRole.roleId)),
where: eq(schema.member.role, "owner"),
});
if (isAdminPresent) {
throw new APIError("BAD_REQUEST", {
@@ -164,11 +106,8 @@ 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: and(eq(schema.member.roleId, ownerRole?.roleId || "")),
where: eq(schema.member.role, "owner"),
});
if (!IS_CLOUD) {
@@ -189,20 +128,11 @@ const { handler, api } = betterAuth({
.returning()
.then((res) => res[0]);
await createDefaultRoles(organization?.id || "");
const ownerRole = await tx.query.role.findFirst({
where: and(
eq(schema.role.name, "owner"),
eq(schema.role.organizationId, organization?.id || ""),
),
});
await tx.insert(schema.member).values({
userId: user.id,
organizationId: organization?.id || "",
role: "owner",
createdAt: new Date(),
roleId: ownerRole?.roleId || "",
});
});
}
@@ -216,7 +146,6 @@ const { handler, api } = betterAuth({
where: eq(schema.member.userId, session.userId),
orderBy: desc(schema.member.createdAt),
with: {
role: true,
organization: true,
},
});
@@ -225,7 +154,6 @@ const { handler, api } = betterAuth({
data: {
...session,
activeOrganizationId: member?.organization.id,
roleId: member?.roleId,
},
};
},
@@ -237,9 +165,9 @@ const { handler, api } = betterAuth({
updateAge: 60 * 60 * 24,
},
user: {
modelName: "users",
modelName: "users_temp",
additionalFields: {
roleId: {
role: {
type: "string",
// required: true,
input: false,
@@ -293,7 +221,6 @@ const { handler, api } = betterAuth({
export const auth = {
handler,
createApiKey: api.createApiKey,
createInvitation: api.createInvitation,
};
export const validateRequest = async (request: IncomingMessage) => {
@@ -348,7 +275,6 @@ export const validateRequest = async (request: IncomingMessage) => {
),
with: {
organization: true,
role: true,
},
});
@@ -381,7 +307,7 @@ export const validateRequest = async (request: IncomingMessage) => {
createdAt,
updatedAt,
twoFactorEnabled,
role: member?.role,
role: member?.role || "member",
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
},
};
@@ -410,7 +336,6 @@ export const validateRequest = async (request: IncomingMessage) => {
};
}
let role: Role | null = null;
if (session?.user) {
const member = await db.query.member.findFirst({
where: and(
@@ -421,26 +346,17 @@ export const validateRequest = async (request: IncomingMessage) => {
),
),
with: {
role: true,
organization: true,
},
});
role = member?.role || null;
session.user.role = member?.role || "member";
if (member) {
session.user.ownerId = member.organization.ownerId;
} else {
session.user.ownerId = session.user.id;
}
}
const mockSession = {
session: {
...session.session,
},
user: {
...session.user,
role,
ownerId: session.user.ownerId,
},
};
return mockSession;
return session;
};
+56
View File
@@ -1,3 +1,59 @@
import {
defaultStatements,
memberAc,
ownerAc,
adminAc,
} from "better-auth/plugins/organization/access";
import { createAccessControl } from "better-auth/plugins/access";
/**
* make sure to use `as const` so typescript can infer the type correctly
*/
const statement = {
...defaultStatements,
project: ["view", "create", "delete"],
service: ["view", "create", "delete"],
traefik_files: ["access"],
docker: ["access"],
api: ["access"],
schedules: ["access"],
git_providers: ["access"],
ssh_keys: ["access"],
} as const;
export const ac = createAccessControl(statement);
export const owner = ac.newRole({
...ownerAc.statements,
// inherit all the statements from the statements object
project: ["create", "view", "delete"],
service: ["create", "view", "delete"],
traefik_files: ["access"],
docker: ["access"],
api: ["access"],
schedules: ["access"],
git_providers: ["access"],
ssh_keys: ["access"],
});
export const admin = ac.newRole({
...adminAc.statements,
project: ["create", "view", "delete"],
service: ["create", "view", "delete"],
traefik_files: ["access"],
docker: ["access"],
api: ["access"],
schedules: ["access"],
git_providers: ["access"],
ssh_keys: ["access"],
});
export const member = ac.newRole({
...memberAc.statements,
project: ["create", "view", "delete"],
service: ["create", "view", "delete"],
});
export const PERMISSIONS = {
PROJECT: {
VIEW: {
+2 -11
View File
@@ -3,7 +3,6 @@ import {
invitation,
member,
organization,
role,
users,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
@@ -35,12 +34,8 @@ 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.roleId, ownerRole?.roleId || ""),
where: eq(member.role, "owner"),
});
if (!admin) {
@@ -50,12 +45,8 @@ 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.roleId, ownerRole?.roleId || ""),
where: eq(member.role, "owner"),
with: {
user: true,
},
+98 -98
View File
@@ -1,119 +1,119 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import {
type createRoleSchema,
member,
role,
type updateRoleSchema,
} from "../db/schema";
import type { z } from "zod";
import {
adminPermissions,
memberPermissions,
ownerPermissions,
} from "../lib/permissions";
// import { eq } from "drizzle-orm";
// import { db } from "../db";
// import {
// type createRoleSchema,
// member,
// 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>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const { ...other } = input;
const newRole = await tx
.insert(role)
.values({ ...other, organizationId })
.returning()
.then((res) => res[0]);
// export const createRole = async (
// input: z.infer<typeof createRoleSchema>,
// organizationId: string,
// ) => {
// await db.transaction(async (tx) => {
// const { ...other } = input;
// const newRole = await tx
// .insert(role)
// .values({ ...other, organizationId })
// .returning()
// .then((res) => res[0]);
if (!newRole) {
throw new Error("Failed to create role");
}
// if (!newRole) {
// throw new Error("Failed to create role");
// }
return role;
});
};
// return role;
// });
// };
const findRoleById = async (roleId: string) => {
const result = await db.query.role.findFirst({
where: eq(role.roleId, roleId),
});
// const findRoleById = async (roleId: string) => {
// const result = await db.query.role.findFirst({
// where: eq(role.roleId, roleId),
// });
if (!result) {
throw new Error("Role not found");
}
// if (!result) {
// throw new Error("Role not found");
// }
return result;
};
// return result;
// };
export const removeRoleById = async (roleId: string) => {
const currentRole = await findRoleById(roleId);
// export const removeRoleById = async (roleId: string) => {
// const currentRole = await findRoleById(roleId);
if (!currentRole) {
throw new Error("Role not found");
}
// if (!currentRole) {
// throw new Error("Role not found");
// }
if (currentRole.isSystem) {
throw new Error("Cannot delete system role");
}
// if (currentRole.isSystem) {
// throw new Error("Cannot delete system role");
// }
const members = await db.query.member.findMany({
where: eq(member.roleId, roleId),
});
// const members = await db.query.member.findMany({
// where: eq(member.roleId, roleId),
// });
if (members.length > 0) {
throw new Error("Cannot delete role with assigned members");
}
// if (members.length > 0) {
// throw new Error("Cannot delete role with assigned members");
// }
await db.delete(role).where(eq(role.roleId, roleId));
// await db.delete(role).where(eq(role.roleId, roleId));
return currentRole;
};
// return currentRole;
// };
export const updateRoleById = async (
roleId: string,
input: z.infer<typeof updateRoleSchema>,
) => {
const currentRole = await findRoleById(roleId);
// export const updateRoleById = async (
// roleId: string,
// input: z.infer<typeof updateRoleSchema>,
// ) => {
// const currentRole = await findRoleById(roleId);
if (!currentRole) {
throw new Error("Role not found");
}
// if (!currentRole) {
// throw new Error("Role not found");
// }
if (currentRole.isSystem) {
throw new Error("Cannot update system role");
}
// if (currentRole.isSystem) {
// throw new Error("Cannot update system role");
// }
await db.update(role).set(input).where(eq(role.roleId, roleId));
// await db.update(role).set(input).where(eq(role.roleId, roleId));
return currentRole;
};
// return currentRole;
// };
export const createDefaultRoles = async (organizationId: string) => {
await db.transaction(async (tx) => {
await tx.insert(role).values({
name: "owner",
description: "Owner of the organization with full access to all features",
organizationId,
isSystem: true,
permissions: ownerPermissions.map((permission) => permission.name),
});
// export const createDefaultRoles = async (organizationId: string) => {
// await db.transaction(async (tx) => {
// await tx.insert(role).values({
// name: "owner",
// description: "Owner of the organization with full access to all features",
// organizationId,
// isSystem: true,
// permissions: ownerPermissions.map((permission) => permission.name),
// });
await tx.insert(role).values({
name: "admin",
description:
"Administrator with access to manage projects, services and configurations",
organizationId,
isSystem: true,
permissions: adminPermissions.map((permission) => permission.name),
});
// await tx.insert(role).values({
// name: "admin",
// description:
// "Administrator with access to manage projects, services and configurations",
// organizationId,
// isSystem: true,
// permissions: adminPermissions.map((permission) => permission.name),
// });
await tx.insert(role).values({
name: "member",
description:
"Regular member with access to create projects and manage services",
organizationId,
isSystem: true,
permissions: memberPermissions.map((permission) => permission.name),
});
});
};
// await tx.insert(role).values({
// name: "member",
// description:
// "Regular member with access to create projects and manage services",
// organizationId,
// isSystem: true,
// permissions: memberPermissions.map((permission) => permission.name),
// });
// });
// };