feat: add role-based access control
This commit is contained in:
parent
c42b8e2904
commit
8ab9a9a497
21 changed files with 629 additions and 46 deletions
40
packages/db/src/migrations/0001_add_rbac.sql
Normal file
40
packages/db/src/migrations/0001_add_rbac.sql
Normal file
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
packages/db/src/rbac.ts
Normal file
47
packages/db/src/rbac.ts
Normal file
|
|
@ -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"];
|
||||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue