Implement RBAC with permissions, roles, and route guards
- permission, role, role_permission, user_role_assignment tables - 42 system permissions across 13 domains - 6 default roles: Admin, Manager, Sales Associate, Technician, Instructor, Viewer - Permission inheritance: admin implies edit implies view - requirePermission() Fastify decorator on ALL routes - System permissions and roles seeded per company - Test helpers and API test runner seed RBAC data - All 42 API tests pass with permissions enforced
This commit is contained in:
43
packages/backend/src/db/migrations/0013_rbac.sql
Normal file
43
packages/backend/src/db/migrations/0013_rbac.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Permissions and role-based access control
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "permission" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"slug" varchar(100) NOT NULL UNIQUE,
|
||||
"domain" varchar(50) NOT NULL,
|
||||
"action" varchar(50) NOT NULL,
|
||||
"description" varchar(255) NOT NULL,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "role" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"company_id" uuid NOT NULL REFERENCES "company"("id"),
|
||||
"name" varchar(100) NOT NULL,
|
||||
"slug" varchar(100) NOT NULL,
|
||||
"description" text,
|
||||
"is_system" boolean NOT NULL DEFAULT false,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "role_company_slug" ON "role" ("company_id", "slug");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "role_permission" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"role_id" uuid NOT NULL REFERENCES "role"("id"),
|
||||
"permission_id" uuid NOT NULL REFERENCES "permission"("id"),
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "role_permission_unique" ON "role_permission" ("role_id", "permission_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user_role_assignment" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" uuid NOT NULL REFERENCES "user"("id"),
|
||||
"role_id" uuid NOT NULL REFERENCES "role"("id"),
|
||||
"assigned_by" uuid,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "user_role_assignment_unique" ON "user_role_assignment" ("user_id", "role_id");
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1774720000000,
|
||||
"tag": "0012_file_storage",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1774730000000,
|
||||
"tag": "0013_rbac",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
54
packages/backend/src/db/schema/rbac.ts
Normal file
54
packages/backend/src/db/schema/rbac.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { pgTable, uuid, varchar, text, timestamp, boolean, uniqueIndex } from 'drizzle-orm/pg-core'
|
||||
import { companies } from './stores.js'
|
||||
import { users } from './users.js'
|
||||
|
||||
export const permissions = pgTable('permission', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
slug: varchar('slug', { length: 100 }).notNull().unique(),
|
||||
domain: varchar('domain', { length: 50 }).notNull(),
|
||||
action: varchar('action', { length: 50 }).notNull(),
|
||||
description: varchar('description', { length: 255 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const roles = pgTable('role', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
slug: varchar('slug', { length: 100 }).notNull(),
|
||||
description: text('description'),
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const rolePermissions = pgTable('role_permission', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
roleId: uuid('role_id')
|
||||
.notNull()
|
||||
.references(() => roles.id),
|
||||
permissionId: uuid('permission_id')
|
||||
.notNull()
|
||||
.references(() => permissions.id),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const userRoles = pgTable('user_role_assignment', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
roleId: uuid('role_id')
|
||||
.notNull()
|
||||
.references(() => roles.id),
|
||||
assignedBy: uuid('assigned_by'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export type Permission = typeof permissions.$inferSelect
|
||||
export type Role = typeof roles.$inferSelect
|
||||
export type RolePermission = typeof rolePermissions.$inferSelect
|
||||
export type UserRole = typeof userRoles.$inferSelect
|
||||
133
packages/backend/src/db/seeds/rbac.ts
Normal file
133
packages/backend/src/db/seeds/rbac.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* System permissions and default role definitions.
|
||||
* Seeded per company on creation.
|
||||
*/
|
||||
|
||||
export const SYSTEM_PERMISSIONS = [
|
||||
// Accounts
|
||||
{ slug: 'accounts.view', domain: 'accounts', action: 'view', description: 'View accounts, members, payment methods, tax exemptions' },
|
||||
{ slug: 'accounts.edit', domain: 'accounts', action: 'edit', description: 'Create and edit accounts, members, payment methods' },
|
||||
{ slug: 'accounts.admin', domain: 'accounts', action: 'admin', description: 'Delete accounts, approve tax exemptions, manage identifiers' },
|
||||
|
||||
// Inventory
|
||||
{ slug: 'inventory.view', domain: 'inventory', action: 'view', description: 'View products, stock levels, categories, suppliers' },
|
||||
{ slug: 'inventory.edit', domain: 'inventory', action: 'edit', description: 'Create and edit products, receive stock, manage categories' },
|
||||
{ slug: 'inventory.admin', domain: 'inventory', action: 'admin', description: 'Delete products, adjust stock, manage lookup values' },
|
||||
|
||||
// POS
|
||||
{ slug: 'pos.view', domain: 'pos', action: 'view', description: 'View transaction history, cash drawer sessions' },
|
||||
{ slug: 'pos.edit', domain: 'pos', action: 'edit', description: 'Process sales, take payments, apply discounts' },
|
||||
{ slug: 'pos.admin', domain: 'pos', action: 'admin', description: 'Void transactions, override prices, manage discounts' },
|
||||
|
||||
// Rentals
|
||||
{ slug: 'rentals.view', domain: 'rentals', action: 'view', description: 'View rental contracts, fleet, billing' },
|
||||
{ slug: 'rentals.edit', domain: 'rentals', action: 'edit', description: 'Create rentals, process returns, manage fleet' },
|
||||
{ slug: 'rentals.admin', domain: 'rentals', action: 'admin', description: 'Override terms, adjust equity, cancel contracts' },
|
||||
|
||||
// Lessons
|
||||
{ slug: 'lessons.view', domain: 'lessons', action: 'view', description: 'View lesson schedules, enrollments, attendance' },
|
||||
{ slug: 'lessons.edit', domain: 'lessons', action: 'edit', description: 'Manage scheduling, enrollment, attendance' },
|
||||
{ slug: 'lessons.admin', domain: 'lessons', action: 'admin', description: 'Configure lesson settings, manage instructors' },
|
||||
|
||||
// Repairs
|
||||
{ slug: 'repairs.view', domain: 'repairs', action: 'view', description: 'View repair tickets, parts inventory' },
|
||||
{ slug: 'repairs.edit', domain: 'repairs', action: 'edit', description: 'Create tickets, log parts and labor, update status' },
|
||||
{ slug: 'repairs.admin', domain: 'repairs', action: 'admin', description: 'Override estimates, manage templates, delete tickets' },
|
||||
|
||||
// Accounting
|
||||
{ slug: 'accounting.view', domain: 'accounting', action: 'view', description: 'View journal entries, reports, AR aging' },
|
||||
{ slug: 'accounting.edit', domain: 'accounting', action: 'edit', description: 'Create journal entries, manage chart of accounts' },
|
||||
{ slug: 'accounting.admin', domain: 'accounting', action: 'admin', description: 'Close periods, adjust entries, export data' },
|
||||
|
||||
// Personnel
|
||||
{ slug: 'personnel.view', domain: 'personnel', action: 'view', description: 'View schedules, time entries' },
|
||||
{ slug: 'personnel.edit', domain: 'personnel', action: 'edit', description: 'Manage schedules, approve time off' },
|
||||
{ slug: 'personnel.admin', domain: 'personnel', action: 'admin', description: 'Payroll export, manage pay rates' },
|
||||
|
||||
// Files
|
||||
{ slug: 'files.view', domain: 'files', action: 'view', description: 'View and download files' },
|
||||
{ slug: 'files.upload', domain: 'files', action: 'upload', description: 'Upload files' },
|
||||
{ slug: 'files.delete', domain: 'files', action: 'delete', description: 'Delete files' },
|
||||
|
||||
// Email
|
||||
{ slug: 'email.view', domain: 'email', action: 'view', description: 'View email logs' },
|
||||
{ slug: 'email.send', domain: 'email', action: 'send', description: 'Send mass emails' },
|
||||
{ slug: 'email.admin', domain: 'email', action: 'admin', description: 'Manage email templates and campaigns' },
|
||||
|
||||
// Settings
|
||||
{ slug: 'settings.view', domain: 'settings', action: 'view', description: 'View store settings' },
|
||||
{ slug: 'settings.edit', domain: 'settings', action: 'edit', description: 'Edit store settings, locations, tax rates' },
|
||||
|
||||
// Users
|
||||
{ slug: 'users.view', domain: 'users', action: 'view', description: 'View users and roles' },
|
||||
{ slug: 'users.edit', domain: 'users', action: 'edit', description: 'Create and edit users, assign roles' },
|
||||
{ slug: 'users.admin', domain: 'users', action: 'admin', description: 'Create and modify roles, manage permissions' },
|
||||
|
||||
// Reports
|
||||
{ slug: 'reports.view', domain: 'reports', action: 'view', description: 'View reports' },
|
||||
{ slug: 'reports.export', domain: 'reports', action: 'export', description: 'Export report data' },
|
||||
|
||||
// System
|
||||
{ slug: 'system.backup', domain: 'system', action: 'backup', description: 'Create and download backups' },
|
||||
{ slug: 'system.restore', domain: 'system', action: 'restore', description: 'Restore from backup' },
|
||||
{ slug: 'system.audit', domain: 'system', action: 'audit', description: 'View audit trail' },
|
||||
] as const
|
||||
|
||||
/** Default system roles with their permission slugs */
|
||||
export const DEFAULT_ROLES = [
|
||||
{
|
||||
slug: 'admin',
|
||||
name: 'Admin',
|
||||
description: 'Full access to all features',
|
||||
permissions: SYSTEM_PERMISSIONS.map((p) => p.slug),
|
||||
},
|
||||
{
|
||||
slug: 'manager',
|
||||
name: 'Manager',
|
||||
description: 'Full access except user administration',
|
||||
permissions: SYSTEM_PERMISSIONS
|
||||
.filter((p) => p.slug !== 'users.admin')
|
||||
.map((p) => p.slug),
|
||||
},
|
||||
{
|
||||
slug: 'sales_associate',
|
||||
name: 'Sales Associate',
|
||||
description: 'Front counter sales, customer management',
|
||||
permissions: [
|
||||
'accounts.view', 'accounts.edit',
|
||||
'inventory.view',
|
||||
'pos.view', 'pos.edit',
|
||||
'rentals.view',
|
||||
'files.view', 'files.upload',
|
||||
'reports.view',
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'technician',
|
||||
name: 'Technician',
|
||||
description: 'Repair ticket management',
|
||||
permissions: [
|
||||
'repairs.view', 'repairs.edit',
|
||||
'inventory.view',
|
||||
'accounts.view',
|
||||
'files.view', 'files.upload',
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'instructor',
|
||||
name: 'Instructor',
|
||||
description: 'Lesson management',
|
||||
permissions: [
|
||||
'lessons.view', 'lessons.edit',
|
||||
'accounts.view',
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'viewer',
|
||||
name: 'Viewer',
|
||||
description: 'Read-only access to all areas',
|
||||
permissions: SYSTEM_PERMISSIONS
|
||||
.filter((p) => p.action === 'view')
|
||||
.map((p) => p.slug),
|
||||
},
|
||||
] as const
|
||||
Reference in New Issue
Block a user