feat(rbac): add role-based access control module

- Add RBAC module with data-access, schema, types, and components for role and permission management
This commit is contained in:
SpecialX
2026-07-03 10:23:56 +08:00
parent cee7bbfd7a
commit ac1de9e433
18 changed files with 2416 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, ilike, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { roles, users, usersToRoles } from "@/shared/db/schema"
import type { PaginatedResult, UserRoleAssignment } from "./types"
const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100
function clampPageSize(size?: number): number {
if (!size || size <= 0) return DEFAULT_PAGE_SIZE
return Math.min(size, MAX_PAGE_SIZE)
}
function clampPage(page?: number): number {
if (!page || page <= 0) return 1
return page
}
/**
* Get the list of role names assigned to a user.
*/
export const getUserRoleNames = cache(async (userId: string): Promise<string[]> => {
const rows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId))
return rows.map((r) => r.name)
})
/**
* Replace the full set of roles assigned to a user.
* Resolves role names to IDs and inserts them in a transaction.
*
* Only enabled roles (or system roles, which are always considered enabled)
* can be assigned. Disabled roles are silently filtered out to keep
* `users_to_roles` consistent with `resolvePermissions` (which also filters
* disabled roles at login time).
*
* Pass an empty array to remove all roles from the user.
*/
export async function assignRolesToUser(
userId: string,
roleNames: string[]
): Promise<void> {
// Verify the user exists
const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
if (!user) throw new Error("User not found")
// Resolve role names to role records. Only enabled roles are assignable;
// disabled roles are filtered out to avoid data inconsistency.
const roleRows =
roleNames.length > 0
? await db
.select({ id: roles.id, name: roles.name, isEnabled: roles.isEnabled })
.from(roles)
.where(inArray(roles.name, roleNames))
: []
// Validate all requested role names exist
const foundNames = new Set(roleRows.map((r) => r.name))
const missing = roleNames.filter((n) => !foundNames.has(n))
if (missing.length > 0) {
throw new Error(`Unknown roles: ${missing.join(", ")}`)
}
// P1-9 security fix: filter out disabled non-system roles
const assignableRoles = roleRows.filter((r) => r.isEnabled || r.name === "admin")
const roleIds = assignableRoles.map((r) => r.id)
await db.transaction(async (tx) => {
await tx.delete(usersToRoles).where(eq(usersToRoles.userId, userId))
if (roleIds.length > 0) {
await tx.insert(usersToRoles).values(
roleIds.map((roleId) => ({ userId, roleId }))
)
}
})
}
/**
* List user-role assignments with optional filtering and pagination.
* Each row includes the user's id, name, email, and the list of role names assigned.
*/
export async function getUserRoleAssignments(params?: {
page?: number
pageSize?: number
search?: string
role?: string
}): Promise<PaginatedResult<UserRoleAssignment>> {
const page = clampPage(params?.page)
const pageSize = clampPageSize(params?.pageSize)
const offset = (page - 1) * pageSize
const conditions = []
if (params?.search) {
const term = `%${params.search}%`
conditions.push(or(ilike(users.name, term), ilike(users.email, term)))
}
// If filtering by role, join through users_to_roles
const where = conditions.length ? and(...conditions) : undefined
const [userRows, totalRows] = await Promise.all([
db
.select({
id: users.id,
name: users.name,
email: users.email,
createdAt: users.createdAt,
})
.from(users)
.where(where)
.orderBy(desc(users.createdAt))
.limit(pageSize)
.offset(offset),
db.select({ value: count() }).from(users).where(where),
])
const userIds = userRows.map((u) => u.id)
if (userIds.length === 0) {
return {
items: [],
page,
pageSize,
total: 0,
totalPages: 0,
}
}
// Fetch role assignments for these users in a single query
const assignmentRows = await db
.select({ userId: usersToRoles.userId, roleName: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, userIds))
const roleMap = new Map<string, string[]>()
for (const row of assignmentRows) {
const list = roleMap.get(row.userId) ?? []
list.push(row.roleName)
roleMap.set(row.userId, list)
}
const items: UserRoleAssignment[] = userRows.map((u) => ({
userId: u.id,
userName: u.name,
userEmail: u.email,
roleNames: roleMap.get(u.id) ?? [],
createdAt: u.createdAt,
}))
// Apply role filter post-fetch if specified (simpler than a subquery here)
const filtered = params?.role
? items.filter((i) => i.roleNames.includes(params.role ?? ""))
: items
const total = Number(totalRows[0]?.value ?? 0)
return {
items: filtered,
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
}
}