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:
172
src/modules/rbac/data-access-assignments.ts
Normal file
172
src/modules/rbac/data-access-assignments.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user