diff --git a/apps/web/src/components/dashboard-shell.tsx b/apps/web/src/components/dashboard-shell.tsx
index a0147e4..a3ddfee 100644
--- a/apps/web/src/components/dashboard-shell.tsx
+++ b/apps/web/src/components/dashboard-shell.tsx
@@ -15,6 +15,7 @@ import {
LayoutGrid,
MenuIcon,
Server,
+ Users,
Wrench,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -25,30 +26,42 @@ const dashboardLinks = [
label: "Overview",
description: "Health, alerts, and recent checks",
icon: LayoutGrid,
+ permission: "dashboard.read",
},
{
to: "/dashboard/servers",
label: "Servers",
description: "Inventory and host details",
icon: Server,
+ permission: "servers.read",
},
{
to: "/dashboard/services",
label: "Services",
description: "Processes and uptime targets",
icon: Wrench,
+ permission: "services.read",
},
{
to: "/dashboard/domains",
label: "Domains",
description: "DNS, SSL, and expiry status",
icon: Globe,
+ permission: "domains.read",
},
{
to: "/dashboard/notes",
label: "Notes",
description: "Runbooks and operational handover",
icon: BookText,
+ permission: "notes.read",
+ },
+ {
+ to: "/dashboard/users",
+ label: "Users",
+ description: "Roles and permission assignments",
+ icon: Users,
+ permission: "users.read",
},
] as const;
@@ -60,14 +73,18 @@ function isActive(pathname: string, to: string): boolean {
function NavItems({
pathname,
+ permissions,
onNavigate,
}: {
pathname: string;
+ permissions: string[];
onNavigate?: () => void;
}) {
+ const allowedPermissions = new Set(permissions);
+
return (
-
-
-
-
-
-
+ {allowedPermissions.has("servers.create") ? (
+
+
+
+ ) : null}
+ {allowedPermissions.has("notes.create") ? (
+
+
+
+ ) : null}
@@ -192,7 +217,7 @@ export function DashboardShell({
Navigation
-
+
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index af9cab6..7eeb976 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DashboardIndexRouteImport } from './routes/dashboard.index'
+import { Route as DashboardUsersRouteImport } from './routes/dashboard.users'
import { Route as DashboardServicesRouteImport } from './routes/dashboard.services'
import { Route as DashboardServersRouteImport } from './routes/dashboard.servers'
import { Route as DashboardNotesRouteImport } from './routes/dashboard.notes'
@@ -46,6 +47,11 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
path: '/',
getParentRoute: () => DashboardRoute,
} as any)
+const DashboardUsersRoute = DashboardUsersRouteImport.update({
+ id: '/users',
+ path: '/users',
+ getParentRoute: () => DashboardRoute,
+} as any)
const DashboardServicesRoute = DashboardServicesRouteImport.update({
id: '/services',
path: '/services',
@@ -118,6 +124,7 @@ export interface FileRoutesByFullPath {
'/dashboard/notes': typeof DashboardNotesRoute
'/dashboard/servers': typeof DashboardServersRoute
'/dashboard/services': typeof DashboardServicesRoute
+ '/dashboard/users': typeof DashboardUsersRoute
'/dashboard/': typeof DashboardIndexRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
@@ -135,6 +142,7 @@ export interface FileRoutesByTo {
'/dashboard/notes': typeof DashboardNotesRoute
'/dashboard/servers': typeof DashboardServersRoute
'/dashboard/services': typeof DashboardServicesRoute
+ '/dashboard/users': typeof DashboardUsersRoute
'/dashboard': typeof DashboardIndexRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
@@ -154,6 +162,7 @@ export interface FileRoutesById {
'/dashboard/notes': typeof DashboardNotesRoute
'/dashboard/servers': typeof DashboardServersRoute
'/dashboard/services': typeof DashboardServicesRoute
+ '/dashboard/users': typeof DashboardUsersRoute
'/dashboard/': typeof DashboardIndexRoute
'/dashboard/domains_/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains_/new': typeof DashboardDomainsNewRoute
@@ -174,6 +183,7 @@ export interface FileRouteTypes {
| '/dashboard/notes'
| '/dashboard/servers'
| '/dashboard/services'
+ | '/dashboard/users'
| '/dashboard/'
| '/dashboard/domains/$domainId'
| '/dashboard/domains/new'
@@ -191,6 +201,7 @@ export interface FileRouteTypes {
| '/dashboard/notes'
| '/dashboard/servers'
| '/dashboard/services'
+ | '/dashboard/users'
| '/dashboard'
| '/dashboard/domains/$domainId'
| '/dashboard/domains/new'
@@ -209,6 +220,7 @@ export interface FileRouteTypes {
| '/dashboard/notes'
| '/dashboard/servers'
| '/dashboard/services'
+ | '/dashboard/users'
| '/dashboard/'
| '/dashboard/domains_/$domainId'
| '/dashboard/domains_/new'
@@ -256,6 +268,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexRouteImport
parentRoute: typeof DashboardRoute
}
+ '/dashboard/users': {
+ id: '/dashboard/users'
+ path: '/users'
+ fullPath: '/dashboard/users'
+ preLoaderRoute: typeof DashboardUsersRouteImport
+ parentRoute: typeof DashboardRoute
+ }
'/dashboard/services': {
id: '/dashboard/services'
path: '/services'
@@ -348,6 +367,7 @@ interface DashboardRouteChildren {
DashboardNotesRoute: typeof DashboardNotesRoute
DashboardServersRoute: typeof DashboardServersRoute
DashboardServicesRoute: typeof DashboardServicesRoute
+ DashboardUsersRoute: typeof DashboardUsersRoute
DashboardIndexRoute: typeof DashboardIndexRoute
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
@@ -364,6 +384,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
DashboardNotesRoute: DashboardNotesRoute,
DashboardServersRoute: DashboardServersRoute,
DashboardServicesRoute: DashboardServicesRoute,
+ DashboardUsersRoute: DashboardUsersRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx
index ac05bd1..87f5073 100644
--- a/apps/web/src/routes/dashboard.tsx
+++ b/apps/web/src/routes/dashboard.tsx
@@ -1,7 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
+import { useQuery } from "@tanstack/react-query";
import { DashboardShell } from "@/components/dashboard-shell";
import { requireSession } from "@/lib/protected-route";
+import { trpc } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
@@ -10,8 +12,13 @@ export const Route = createFileRoute("/dashboard")({
function RouteComponent() {
const { session } = Route.useRouteContext();
+ const me = useQuery(trpc.users.me.queryOptions());
return (
-
+
);
}
diff --git a/apps/web/src/routes/dashboard.users.tsx b/apps/web/src/routes/dashboard.users.tsx
new file mode 100644
index 0000000..b643353
--- /dev/null
+++ b/apps/web/src/routes/dashboard.users.tsx
@@ -0,0 +1,149 @@
+import { Button } from "@minmon/ui/components/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@minmon/ui/components/card";
+import { Checkbox } from "@minmon/ui/components/checkbox";
+import { StatusBadge } from "@minmon/ui/components/status-badge";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { createFileRoute } from "@tanstack/react-router";
+import { toast } from "sonner";
+
+import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
+import { queryClient, trpc, trpcClient } from "@/utils/trpc";
+
+export const Route = createFileRoute("/dashboard/users")({
+ component: RouteComponent,
+});
+
+type RoleOption = {
+ id: string;
+ name: string;
+ description: string | null;
+};
+
+function RoleBadges({ roles }: { roles: { id: string; name: string }[] }) {
+ if (!roles.length) {
+ return No roles;
+ }
+
+ return (
+
+ {roles.map((role) => (
+
+ {role.name}
+
+ ))}
+
+ );
+}
+
+function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
+ const users = useQuery(trpc.users.list.queryOptions());
+ const updateRoles = useMutation({
+ mutationFn: async ({ userId, roleIds }: { userId: string; roleIds: string[] }) =>
+ trpcClient.users.updateRoles.mutate({ userId, roleIds }),
+ onSuccess: async () => {
+ toast.success("User roles updated");
+ await queryClient.invalidateQueries();
+ },
+ onError: (error) => toast.error(error.message),
+ });
+
+ if (users.isLoading) {
+ return ;
+ }
+
+ if (users.isError || !users.data) {
+ return ;
+ }
+
+ return (
+ (
+
+ {item.name}
+ {item.email}
+
+ ),
+ },
+ {
+ key: "roles",
+ header: "Current roles",
+ render: (item: (typeof users.data)[number]) => ,
+ },
+ {
+ key: "assign",
+ header: "Assign roles",
+ render: (item: (typeof users.data)[number]) => {
+ const assignedRoleIds = new Set(item.roles.map((role) => role.id));
+
+ return (
+
+ {roles.map((role) => {
+ const checked = assignedRoleIds.has(role.id);
+ const nextRoleIds = checked
+ ? item.roles.map((assignedRole) => assignedRole.id).filter((roleId) => roleId !== role.id)
+ : [...item.roles.map((assignedRole) => assignedRole.id), role.id];
+
+ return (
+
+ );
+ })}
+
+ );
+ },
+ },
+ ]}
+ items={users.data}
+ emptyTitle="No users found"
+ emptyDescription="Users appear here after they sign up or are seeded."
+ />
+ );
+}
+
+function RouteComponent() {
+ const roles = useQuery(trpc.users.roles.queryOptions());
+
+ if (roles.isLoading) {
+ return ;
+ }
+
+ if (roles.isError || !roles.data) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Role guide
+ Default RBAC roles seeded by the application.
+
+
+ {roles.data.map((role) => (
+
+
+ {role.name}
+
+
{role.description}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts
index d6a1081..5026dd5 100644
--- a/packages/api/src/context.ts
+++ b/packages/api/src/context.ts
@@ -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,
};
}
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index a287def..da992c4 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -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().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));
+}
diff --git a/packages/api/src/permissions.ts b/packages/api/src/permissions.ts
new file mode 100644
index 0000000..38b1f2f
--- /dev/null
+++ b/packages/api/src/permissions.ts
@@ -0,0 +1 @@
+export { permissions, roleDefinitions, type Permission, type RoleName } from "@minmon/db/rbac";
diff --git a/packages/api/src/routers/dashboard.ts b/packages/api/src/routers/dashboard.ts
index 205972d..da673a9 100644
--- a/packages/api/src/routers/dashboard.ts
+++ b/packages/api/src/routers/dashboard.ts
@@ -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,
diff --git a/packages/api/src/routers/domains.ts b/packages/api/src/routers/domains.ts
index 5f04711..9d0381c 100644
--- a/packages/api/src/routers/domains.ts
+++ b/packages/api/src/routers/domains.ts
@@ -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,
diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts
index c287721..3fd7cfe 100644
--- a/packages/api/src/routers/index.ts
+++ b/packages/api/src/routers/index.ts
@@ -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;
diff --git a/packages/api/src/routers/notes.ts b/packages/api/src/routers/notes.ts
index ccbd0a0..645a870 100644
--- a/packages/api/src/routers/notes.ts
+++ b/packages/api/src/routers/notes.ts
@@ -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,
diff --git a/packages/api/src/routers/schemas.ts b/packages/api/src/routers/schemas.ts
index a7256e8..16bcaef 100644
--- a/packages/api/src/routers/schemas.ts
+++ b/packages/api/src/routers/schemas.ts
@@ -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"),
+});
diff --git a/packages/api/src/routers/servers.ts b/packages/api/src/routers/servers.ts
index 2e96623..bed5574 100644
--- a/packages/api/src/routers/servers.ts
+++ b/packages/api/src/routers/servers.ts
@@ -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 };
diff --git a/packages/api/src/routers/services.ts b/packages/api/src/routers/services.ts
index 9e84963..4a4233d 100644
--- a/packages/api/src/routers/services.ts
+++ b/packages/api/src/routers/services.ts
@@ -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 };
diff --git a/packages/api/src/routers/users.ts b/packages/api/src/routers/users.ts
new file mode 100644
index 0000000..f0885f2
--- /dev/null
+++ b/packages/api/src/routers/users.ts
@@ -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);
+ }),
+});
diff --git a/packages/db/src/migrations/0001_add_rbac.sql b/packages/db/src/migrations/0001_add_rbac.sql
new file mode 100644
index 0000000..553996e
--- /dev/null
+++ b/packages/db/src/migrations/0001_add_rbac.sql
@@ -0,0 +1,40 @@
+CREATE TABLE IF NOT EXISTS "permission" (
+ "id" text PRIMARY KEY NOT NULL,
+ "name" text NOT NULL,
+ "description" text,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "permission_name_unique" UNIQUE("name")
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "role" (
+ "id" text PRIMARY KEY NOT NULL,
+ "name" text NOT NULL,
+ "description" text,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "role_name_unique" UNIQUE("name")
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "role_permission" (
+ "role_id" text NOT NULL,
+ "permission_id" text NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "role_permission_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id")
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "user_role" (
+ "user_id" text NOT NULL,
+ "role_id" text NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "user_role_user_id_role_id_pk" PRIMARY KEY("user_id","role_id")
+);
+--> statement-breakpoint
+ALTER TABLE "role_permission" ADD CONSTRAINT "role_permission_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "role_permission" ADD CONSTRAINT "role_permission_permission_id_permission_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "user_role" ADD CONSTRAINT "user_role_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "user_role" ADD CONSTRAINT "user_role_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "role_permission_role_id_idx" ON "role_permission" USING btree ("role_id");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "role_permission_permission_id_idx" ON "role_permission" USING btree ("permission_id");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_role_user_id_idx" ON "user_role" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_role_role_id_idx" ON "user_role" USING btree ("role_id");
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index f66548c..30f32c4 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1777458791180,
"tag": "0000_thick_molecule_man",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1782694800000,
+ "tag": "0001_add_rbac",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/db/src/rbac.ts b/packages/db/src/rbac.ts
new file mode 100644
index 0000000..f57f19f
--- /dev/null
+++ b/packages/db/src/rbac.ts
@@ -0,0 +1,47 @@
+export const permissions = [
+ "dashboard.read",
+ "servers.read",
+ "servers.create",
+ "servers.update",
+ "servers.delete",
+ "services.read",
+ "services.create",
+ "services.update",
+ "services.delete",
+ "domains.read",
+ "domains.create",
+ "domains.update",
+ "domains.delete",
+ "domains.check",
+ "notes.read",
+ "notes.create",
+ "notes.update",
+ "notes.delete",
+ "users.read",
+ "users.assignRoles",
+] as const;
+
+export type Permission = (typeof permissions)[number];
+
+export const roleDefinitions = [
+ {
+ id: "role_admin",
+ name: "Admin",
+ description: "Full access to monitoring data, users, roles, and permissions.",
+ permissions: [...permissions],
+ },
+ {
+ id: "role_editor",
+ name: "Editor",
+ description: "Can create and update monitoring resources, run checks, and read users.",
+ permissions: permissions.filter((permission) => permission !== "users.assignRoles"),
+ },
+ {
+ id: "role_viewer",
+ name: "Viewer",
+ description: "Read-only access to monitoring resources and users.",
+ permissions: permissions.filter((permission) => permission.endsWith(".read")),
+ },
+] as const;
+
+export type RoleName = (typeof roleDefinitions)[number]["name"];
diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts
index 413a524..cf4ab35 100644
--- a/packages/db/src/schema/auth.ts
+++ b/packages/db/src/schema/auth.ts
@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
-import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
+import { pgTable, text, timestamp, boolean, index, primaryKey } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
@@ -73,9 +73,68 @@ export const verification = pgTable(
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
+export const role = pgTable("role", {
+ id: text("id").primaryKey(),
+ name: text("name").notNull().unique(),
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+});
+
+export const permission = pgTable("permission", {
+ id: text("id").primaryKey(),
+ name: text("name").notNull().unique(),
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+});
+
+export const rolePermission = pgTable(
+ "role_permission",
+ {
+ roleId: text("role_id")
+ .notNull()
+ .references(() => role.id, { onDelete: "cascade" }),
+ permissionId: text("permission_id")
+ .notNull()
+ .references(() => permission.id, { onDelete: "cascade" }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ },
+ (table) => [
+ primaryKey({ columns: [table.roleId, table.permissionId] }),
+ index("role_permission_role_id_idx").on(table.roleId),
+ index("role_permission_permission_id_idx").on(table.permissionId),
+ ],
+);
+
+export const userRole = pgTable(
+ "user_role",
+ {
+ userId: text("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ roleId: text("role_id")
+ .notNull()
+ .references(() => role.id, { onDelete: "cascade" }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ },
+ (table) => [
+ primaryKey({ columns: [table.userId, table.roleId] }),
+ index("user_role_user_id_idx").on(table.userId),
+ index("user_role_role_id_idx").on(table.roleId),
+ ],
+);
+
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
+ roles: many(userRole),
}));
export const sessionRelations = relations(session, ({ one }) => ({
@@ -91,3 +150,34 @@ export const accountRelations = relations(account, ({ one }) => ({
references: [user.id],
}),
}));
+
+export const roleRelations = relations(role, ({ many }) => ({
+ permissions: many(rolePermission),
+ users: many(userRole),
+}));
+
+export const permissionRelations = relations(permission, ({ many }) => ({
+ roles: many(rolePermission),
+}));
+
+export const rolePermissionRelations = relations(rolePermission, ({ one }) => ({
+ role: one(role, {
+ fields: [rolePermission.roleId],
+ references: [role.id],
+ }),
+ permission: one(permission, {
+ fields: [rolePermission.permissionId],
+ references: [permission.id],
+ }),
+}));
+
+export const userRoleRelations = relations(userRole, ({ one }) => ({
+ user: one(user, {
+ fields: [userRole.userId],
+ references: [user.id],
+ }),
+ role: one(role, {
+ fields: [userRole.roleId],
+ references: [role.id],
+ }),
+}));
diff --git a/packages/db/src/seed-if-empty.ts b/packages/db/src/seed-if-empty.ts
index ee6306e..9a6112b 100644
--- a/packages/db/src/seed-if-empty.ts
+++ b/packages/db/src/seed-if-empty.ts
@@ -5,7 +5,7 @@ dotenv.config({
});
import { db } from "./index";
-import { seed } from "./seed";
+import { seed, seedRolesAndPermissions } from "./seed";
async function seedIfEmpty() {
const existingUser = await db.query.user.findFirst({
@@ -15,7 +15,8 @@ async function seedIfEmpty() {
});
if (existingUser) {
- console.info("Database already has users; skipping sample seed.");
+ await seedRolesAndPermissions(existingUser.id);
+ console.info("Database already has users; ensured default roles and permissions.");
return;
}
diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts
index 86e4063..0cc2606 100644
--- a/packages/db/src/seed.ts
+++ b/packages/db/src/seed.ts
@@ -1,12 +1,13 @@
import dotenv from "dotenv";
import { hashPassword } from "better-auth/crypto";
+import { roleDefinitions, permissions } from "./rbac";
dotenv.config({
path: new URL("../../../apps/server/.env", import.meta.url).pathname,
});
import { db } from "./index";
-import { account, user } from "./schema/auth";
+import { account, permission, role, rolePermission, user, userRole } from "./schema/auth";
import { domain, note, server, service } from "./schema/monitoring";
const now = new Date();
@@ -219,6 +220,46 @@ async function seedAuthAdmin() {
return adminUser.id;
}
+export async function seedRolesAndPermissions(adminUserId?: string) {
+ await db.insert(permission).values(
+ permissions.map((name) => ({
+ id: name,
+ name,
+ description: `Allows ${name.replace(".", " ")}`,
+ createdAt: now,
+ updatedAt: now,
+ })),
+ ).onConflictDoNothing();
+
+ await db.insert(role).values(
+ roleDefinitions.map((item) => ({
+ id: item.id,
+ name: item.name,
+ description: item.description,
+ createdAt: now,
+ updatedAt: now,
+ })),
+ ).onConflictDoNothing();
+
+ await db.insert(rolePermission).values(
+ roleDefinitions.flatMap((item) =>
+ item.permissions.map((permissionName) => ({
+ roleId: item.id,
+ permissionId: permissionName,
+ createdAt: now,
+ })),
+ ),
+ ).onConflictDoNothing();
+
+ if (adminUserId) {
+ await db.insert(userRole).values({
+ userId: adminUserId,
+ roleId: "role_admin",
+ createdAt: now,
+ }).onConflictDoNothing();
+ }
+}
+
async function seedMonitoringData() {
await db.delete(note);
await db.delete(domain);
@@ -262,7 +303,8 @@ async function seedMonitoringData() {
}
export async function seed() {
- await seedAuthAdmin();
+ const adminUserId = await seedAuthAdmin();
+ await seedRolesAndPermissions(adminUserId);
await seedMonitoringData();
}