feat: add role-based access control
This commit is contained in:
parent
c42b8e2904
commit
8ab9a9a497
21 changed files with 629 additions and 46 deletions
|
|
@ -1,17 +1,42 @@
|
|||
import { auth } from "@minmon/auth";
|
||||
import { db, permission, role, rolePermission, userRole } from "@minmon/db";
|
||||
import type { Context as ElysiaContext } from "elysia";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type CreateContextOptions = {
|
||||
context: ElysiaContext;
|
||||
};
|
||||
|
||||
async function getAuthorization(userId: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
permissionName: permission.name,
|
||||
})
|
||||
.from(userRole)
|
||||
.innerJoin(role, eq(role.id, userRole.roleId))
|
||||
.innerJoin(rolePermission, eq(rolePermission.roleId, role.id))
|
||||
.innerJoin(permission, eq(permission.id, rolePermission.permissionId))
|
||||
.where(eq(userRole.userId, userId));
|
||||
|
||||
return {
|
||||
roles: Array.from(new Map(rows.map((row) => [row.roleId, { id: row.roleId, name: row.roleName }])).values()),
|
||||
permissions: Array.from(new Set(rows.map((row) => row.permissionName))),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createContext({ context }: CreateContextOptions) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: context.request.headers,
|
||||
});
|
||||
|
||||
const authorization = session ? await getAuthorization(session.user.id) : { roles: [], permissions: [] };
|
||||
|
||||
return {
|
||||
auth: null,
|
||||
session,
|
||||
authorization,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Context } from "./context";
|
||||
import type { Permission } from "./permissions";
|
||||
|
||||
export const t = initTRPC.context<Context>().create();
|
||||
|
||||
|
|
@ -23,3 +24,33 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
export function requirePermission(permission: Permission) {
|
||||
return t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.session) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
cause: "No session",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.authorization.permissions.includes(permission)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Missing permission: ${permission}`,
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
session: ctx.session,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function permissionedProcedure(permission: Permission) {
|
||||
return t.procedure.use(requirePermission(permission));
|
||||
}
|
||||
|
|
|
|||
1
packages/api/src/permissions.ts
Normal file
1
packages/api/src/permissions.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { permissions, roleDefinitions, type Permission, type RoleName } from "@minmon/db/rbac";
|
||||
|
|
@ -2,10 +2,10 @@ import { db } from "@minmon/db";
|
|||
import { domain, note, server, service } from "@minmon/db";
|
||||
import { count, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { protectedProcedure, router } from "../index";
|
||||
import { permissionedProcedure, router } from "../index";
|
||||
|
||||
export const dashboardRouter = router({
|
||||
summary: protectedProcedure.query(async () => {
|
||||
summary: permissionedProcedure("dashboard.read").query(async () => {
|
||||
const [
|
||||
serverCount,
|
||||
serviceCount,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { domain, service } from "@minmon/db";
|
|||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
import { runAndPersistDomainCheck } from "../lib/domain-check";
|
||||
import { protectedProcedure, router } from "../index";
|
||||
import { permissionedProcedure, router } from "../index";
|
||||
import {
|
||||
byIdSchema,
|
||||
domainCreateSchema,
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from "./utils";
|
||||
|
||||
export const domainsRouter = router({
|
||||
list: protectedProcedure.input(domainListSchema).query(async ({ input }) => {
|
||||
list: permissionedProcedure("domains.read").input(domainListSchema).query(async ({ input }) => {
|
||||
if (!input?.serviceId) {
|
||||
return db.select().from(domain).orderBy(desc(domain.createdAt), desc(domain.id));
|
||||
}
|
||||
|
|
@ -33,11 +33,11 @@ export const domainsRouter = router({
|
|||
.orderBy(desc(domain.createdAt), desc(domain.id));
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||
byId: permissionedProcedure("domains.read").input(byIdSchema).query(async ({ input }) => {
|
||||
return requireDomain(input.id);
|
||||
}),
|
||||
|
||||
create: protectedProcedure.input(domainCreateSchema).mutation(async ({ input }) => {
|
||||
create: permissionedProcedure("domains.create").input(domainCreateSchema).mutation(async ({ input }) => {
|
||||
const { service: parentService, server: parentServer } = await requireServiceWithServer(input.serviceId);
|
||||
|
||||
const expectedServerIp = input.expectedServerIp ?? parentServer.primaryIpAddress;
|
||||
|
|
@ -56,7 +56,7 @@ export const domainsRouter = router({
|
|||
return created;
|
||||
}),
|
||||
|
||||
update: protectedProcedure.input(domainUpdateSchema).mutation(async ({ input }) => {
|
||||
update: permissionedProcedure("domains.update").input(domainUpdateSchema).mutation(async ({ input }) => {
|
||||
const { id, serviceId, ...changes } = input;
|
||||
const existing = await requireDomain(id);
|
||||
|
||||
|
|
@ -78,13 +78,13 @@ export const domainsRouter = router({
|
|||
return updated;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||
delete: permissionedProcedure("domains.delete").input(byIdSchema).mutation(async ({ input }) => {
|
||||
await requireDomain(input.id);
|
||||
await db.delete(domain).where(eq(domain.id, input.id));
|
||||
return { id: input.id };
|
||||
}),
|
||||
|
||||
check: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||
check: permissionedProcedure("domains.check").input(byIdSchema).mutation(async ({ input }) => {
|
||||
const existing = await requireDomain(input.id);
|
||||
|
||||
const result = await runAndPersistDomainCheck(db, {
|
||||
|
|
@ -99,7 +99,7 @@ export const domainsRouter = router({
|
|||
};
|
||||
}),
|
||||
|
||||
serviceOptions: protectedProcedure.query(async () => {
|
||||
serviceOptions: permissionedProcedure("services.read").query(async () => {
|
||||
return db
|
||||
.select({
|
||||
id: service.id,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { domainsRouter } from "./domains";
|
|||
import { notesRouter } from "./notes";
|
||||
import { serversRouter } from "./servers";
|
||||
import { servicesRouter } from "./services";
|
||||
import { usersRouter } from "./users";
|
||||
|
||||
export const appRouter = router({
|
||||
healthCheck: publicProcedure.query(() => {
|
||||
|
|
@ -15,5 +16,6 @@ export const appRouter = router({
|
|||
services: servicesRouter,
|
||||
domains: domainsRouter,
|
||||
notes: notesRouter,
|
||||
users: usersRouter,
|
||||
});
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { db } from "@minmon/db";
|
|||
import { note, server } from "@minmon/db";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
import { protectedProcedure, router } from "../index";
|
||||
import { permissionedProcedure, router } from "../index";
|
||||
import { byIdSchema, noteCreateSchema, noteListSchema, noteUpdateSchema } from "./schemas";
|
||||
import {
|
||||
createEntityId,
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "./utils";
|
||||
|
||||
export const notesRouter = router({
|
||||
list: protectedProcedure.input(noteListSchema).query(async ({ input }) => {
|
||||
list: permissionedProcedure("notes.read").input(noteListSchema).query(async ({ input }) => {
|
||||
if (!input?.serverId) {
|
||||
return db.select().from(note).orderBy(desc(note.createdAt), desc(note.id));
|
||||
}
|
||||
|
|
@ -26,11 +26,11 @@ export const notesRouter = router({
|
|||
.orderBy(desc(note.createdAt), desc(note.id));
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||
byId: permissionedProcedure("notes.read").input(byIdSchema).query(async ({ input }) => {
|
||||
return requireNote(input.id);
|
||||
}),
|
||||
|
||||
create: protectedProcedure.input(noteCreateSchema).mutation(async ({ input }) => {
|
||||
create: permissionedProcedure("notes.create").input(noteCreateSchema).mutation(async ({ input }) => {
|
||||
await requireServer(input.serverId);
|
||||
|
||||
const [created] = await db
|
||||
|
|
@ -44,7 +44,7 @@ export const notesRouter = router({
|
|||
return created;
|
||||
}),
|
||||
|
||||
update: protectedProcedure.input(noteUpdateSchema).mutation(async ({ input }) => {
|
||||
update: permissionedProcedure("notes.update").input(noteUpdateSchema).mutation(async ({ input }) => {
|
||||
const { id, serverId, ...changes } = input;
|
||||
await requireNote(id);
|
||||
|
||||
|
|
@ -61,13 +61,13 @@ export const notesRouter = router({
|
|||
return updated;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||
delete: permissionedProcedure("notes.delete").input(byIdSchema).mutation(async ({ input }) => {
|
||||
await requireNote(input.id);
|
||||
await db.delete(note).where(eq(note.id, input.id));
|
||||
return { id: input.id };
|
||||
}),
|
||||
|
||||
serverOptions: protectedProcedure.query(async () => {
|
||||
serverOptions: permissionedProcedure("servers.read").query(async () => {
|
||||
return db
|
||||
.select({
|
||||
id: server.id,
|
||||
|
|
|
|||
|
|
@ -105,3 +105,8 @@ export const noteCreateSchema = z.object({
|
|||
export const noteUpdateSchema = noteCreateSchema.partial().extend({
|
||||
id: entityIdSchema,
|
||||
});
|
||||
|
||||
export const updateUserRolesSchema = z.object({
|
||||
userId: entityIdSchema,
|
||||
roleIds: z.array(entityIdSchema).min(1, "Select at least one role"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ import { db } from "@minmon/db";
|
|||
import { note, server, service } from "@minmon/db";
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { protectedProcedure, router } from "../index";
|
||||
import { permissionedProcedure, router } from "../index";
|
||||
import { byIdSchema, serverCreateSchema, serverUpdateSchema } from "./schemas";
|
||||
import { createEntityId, requireServer, stripUndefined } from "./utils";
|
||||
|
||||
export const serversRouter = router({
|
||||
list: protectedProcedure.query(async () => {
|
||||
list: permissionedProcedure("servers.read").query(async () => {
|
||||
return db.select().from(server).orderBy(desc(server.createdAt), desc(server.id));
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||
byId: permissionedProcedure("servers.read").input(byIdSchema).query(async ({ input }) => {
|
||||
const item = await requireServer(input.id);
|
||||
|
||||
const [serviceCount, noteCount] = await Promise.all([
|
||||
|
|
@ -28,7 +28,7 @@ export const serversRouter = router({
|
|||
};
|
||||
}),
|
||||
|
||||
create: protectedProcedure.input(serverCreateSchema).mutation(async ({ input }) => {
|
||||
create: permissionedProcedure("servers.create").input(serverCreateSchema).mutation(async ({ input }) => {
|
||||
const [created] = await db
|
||||
.insert(server)
|
||||
.values({
|
||||
|
|
@ -40,7 +40,7 @@ export const serversRouter = router({
|
|||
return created;
|
||||
}),
|
||||
|
||||
update: protectedProcedure.input(serverUpdateSchema).mutation(async ({ input }) => {
|
||||
update: permissionedProcedure("servers.update").input(serverUpdateSchema).mutation(async ({ input }) => {
|
||||
const { id, ...changes } = input;
|
||||
await requireServer(id);
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ export const serversRouter = router({
|
|||
return updated;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||
delete: permissionedProcedure("servers.delete").input(byIdSchema).mutation(async ({ input }) => {
|
||||
await requireServer(input.id);
|
||||
await db.delete(server).where(eq(server.id, input.id));
|
||||
return { id: input.id };
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { db } from "@minmon/db";
|
|||
import { domain, service } from "@minmon/db";
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { protectedProcedure, router } from "../index";
|
||||
import { permissionedProcedure, router } from "../index";
|
||||
import {
|
||||
byIdSchema,
|
||||
serviceCreateSchema,
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "./utils";
|
||||
|
||||
export const servicesRouter = router({
|
||||
list: protectedProcedure.input(serviceListSchema).query(async ({ input }) => {
|
||||
list: permissionedProcedure("services.read").input(serviceListSchema).query(async ({ input }) => {
|
||||
if (!input?.serverId) {
|
||||
return db.select().from(service).orderBy(desc(service.createdAt), desc(service.id));
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ export const servicesRouter = router({
|
|||
.orderBy(desc(service.createdAt), desc(service.id));
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||
byId: permissionedProcedure("services.read").input(byIdSchema).query(async ({ input }) => {
|
||||
const item = await requireService(input.id);
|
||||
const [domainCount] = await db.select({ count: count() }).from(domain).where(eq(domain.serviceId, input.id));
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ export const servicesRouter = router({
|
|||
};
|
||||
}),
|
||||
|
||||
create: protectedProcedure.input(serviceCreateSchema).mutation(async ({ input }) => {
|
||||
create: permissionedProcedure("services.create").input(serviceCreateSchema).mutation(async ({ input }) => {
|
||||
await requireServer(input.serverId);
|
||||
|
||||
const [created] = await db
|
||||
|
|
@ -57,7 +57,7 @@ export const servicesRouter = router({
|
|||
return created;
|
||||
}),
|
||||
|
||||
update: protectedProcedure.input(serviceUpdateSchema).mutation(async ({ input }) => {
|
||||
update: permissionedProcedure("services.update").input(serviceUpdateSchema).mutation(async ({ input }) => {
|
||||
const { id, serverId, ...changes } = input;
|
||||
await requireService(id);
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ export const servicesRouter = router({
|
|||
return updated;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||
delete: permissionedProcedure("services.delete").input(byIdSchema).mutation(async ({ input }) => {
|
||||
await requireService(input.id);
|
||||
await db.delete(service).where(eq(service.id, input.id));
|
||||
return { id: input.id };
|
||||
|
|
|
|||
90
packages/api/src/routers/users.ts
Normal file
90
packages/api/src/routers/users.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { db, role, user, userRole } from "@minmon/db";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
|
||||
import { permissionedProcedure, protectedProcedure, router } from "../index";
|
||||
import { updateUserRolesSchema } from "./schemas";
|
||||
import { notFound } from "./utils";
|
||||
|
||||
async function getUsersWithRoles() {
|
||||
const [users, assignments] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
})
|
||||
.from(user)
|
||||
.orderBy(asc(user.name), asc(user.email)),
|
||||
db
|
||||
.select({
|
||||
userId: userRole.userId,
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
})
|
||||
.from(userRole)
|
||||
.innerJoin(role, eq(role.id, userRole.roleId))
|
||||
.orderBy(asc(role.name)),
|
||||
]);
|
||||
|
||||
return users.map((item) => ({
|
||||
...item,
|
||||
roles: assignments
|
||||
.filter((assignment) => assignment.userId === item.id)
|
||||
.map((assignment) => ({ id: assignment.roleId, name: assignment.roleName })),
|
||||
}));
|
||||
}
|
||||
|
||||
export const usersRouter = router({
|
||||
me: protectedProcedure.query(({ ctx }) => ({
|
||||
user: ctx.session.user,
|
||||
roles: ctx.authorization.roles,
|
||||
permissions: ctx.authorization.permissions,
|
||||
})),
|
||||
|
||||
list: permissionedProcedure("users.read").query(async () => getUsersWithRoles()),
|
||||
|
||||
roles: permissionedProcedure("users.read").query(async () =>
|
||||
db
|
||||
.select({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
})
|
||||
.from(role)
|
||||
.orderBy(asc(role.name)),
|
||||
),
|
||||
|
||||
updateRoles: permissionedProcedure("users.assignRoles")
|
||||
.input(updateUserRolesSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const [existingUser] = await db.select({ id: user.id }).from(user).where(eq(user.id, input.userId)).limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
notFound("User not found");
|
||||
}
|
||||
|
||||
const roles = await db.select({ id: role.id }).from(role);
|
||||
const validRoleIds = new Set(roles.map((item) => item.id));
|
||||
const uniqueRoleIds = Array.from(new Set(input.roleIds));
|
||||
|
||||
const invalidRoleId = uniqueRoleIds.find((roleId) => !validRoleIds.has(roleId));
|
||||
if (invalidRoleId) {
|
||||
notFound(`Role not found: ${invalidRoleId}`);
|
||||
}
|
||||
|
||||
await db.delete(userRole).where(eq(userRole.userId, input.userId));
|
||||
await db.insert(userRole).values(
|
||||
uniqueRoleIds.map((roleId) => ({
|
||||
userId: input.userId,
|
||||
roleId,
|
||||
})),
|
||||
);
|
||||
|
||||
const updated = await getUsersWithRoles();
|
||||
return updated.find((item) => item.id === input.userId);
|
||||
}),
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue