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

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", {

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";

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),
// });

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;
};

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: {

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,
},

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),
// });
// });
// };