User Management & Access

Admin user management with RBAC route guards and ABAC policies over the Better Auth admin plugin

The access feature adds user management to the dashboard and a reusable authorization layer to the backend. It builds on Better Auth's admin plugin (roles, ban, delete) and wraps it with:

  • RBAC — a role → permission map and a requirePermission() Hono middleware that can guard any route
  • ABAC — attribute-based policies that decide resource-level access (who may act on whom/what), evaluated with authorize()

Endpoints

All endpoints require a session with the admin role and are additionally checked against the ABAC policies.

MethodPathDescription
GET/api/admin/usersList users (?search=&page=&limit=)
PATCH/api/admin/users/:id/roleSet a user's role (user or admin)
POST/api/admin/users/:id/banBan a user (revokes their sessions)
POST/api/admin/users/:id/unbanLift a ban
DELETE/api/admin/users/:idDelete a user

The routes live in apps/api/src/features/access/routes.ts and are mounted into the Hono app through the managed routes marker in apps/api/src/app.ts.

File Overview

apps/api/src/features/access/Directory Structure
apps/api/src/features/access/
├── access.ts    # RBAC map + requirePermission() middleware + ABAC policies + recordAudit()
└── routes.ts    # Admin user-management endpoints

apps/web/
├── app/(dashboard)/dashboard/users/page.tsx   # Admin-gated page
└── components/access/users-manager.tsx        # Table, search, actions

packages/db/src/schema.ts
├── user table + role / banned / banReason / banExpires (user-fields marker)
└── audit_log table (tables marker)

RBAC: guarding routes

Roles and permissions live in access.ts. Guard any route with the middleware:

ts
import { requirePermission, type AccessEnv } from "./features/access/access"
export const myRoutes = new Hono<AccessEnv>().get(  "/api/reports",  requirePermission("reports:read"),  (c) => {    const session = c.get("session")    // ...  })

Add the permission to a role in ROLE_PERMISSIONS to grant access:

ts
export const ROLE_PERMISSIONS: Record<Role, readonly string[]> = {  user: [],  admin: ["users:list", "reports:read"],}

ABAC: attribute-based policies

Policies are plain functions over actor/target attributes. They run after RBAC and catch rules roles can't express (self-actions, owner checks, time windows):

ts
export const policies = {  "users:delete": ({ actor, target }) =>    actor.id !== target.id && target.role !== "admin",} satisfies Record<string, Policy>

Call authorize() in a route before mutating a resource:

ts
if (!authorize("users:delete", { actor: session.user, target })) {  return c.json({ error: "Forbidden" }, 403)}

Schema

The feature contributes four columns to the user table (via the managed user-fields marker):

ColumnTypeNotes
roletextDefaults to "user"
bannedbooleanDefaults to false
banReasontextOptional
banExpirestimestampOptional

Audit trail

Every successful admin mutation writes a row to the audit_log table (via the managed tables marker) using the recordAudit() helper in access.ts:

ColumnTypeNotes
actorIdtextAdmin who acted; NULL if that user is later deleted
actiontextusers:set-role, users:ban, users:unban, users:delete
targetIdtextThe affected user's id (kept even after deletion)
detailstexte.g. role: user -> admin, the ban reason, or the deleted email
ipAddresstextFirst hop of x-forwarded-for, when present
createdAttimestampIndexed

Audit writes never fail the request — errors are logged server-side instead. Record your own actions from any route:

ts
await recordAudit({  actorId: session.user.id,  action: "users:set-role",  targetId: id,  details: "role: user -> admin",})

Query the trail directly:

bash
psql "$DATABASE_URL" -c "SELECT created_at, action, details FROM audit_log ORDER BY created_at DESC LIMIT 20;"

Frontend

/dashboard/users shows a searchable, paginated table with role switching, ban/unban, and delete (with confirmation). The sidebar link is only rendered for admins (the nav-visibility marker), and the page itself renders a "Admins only" card for everyone else. Client-side checks are cosmetic — the backend enforces everything.

Promote your first admin

Roles default to user, so bootstrap an admin directly in the database:

bash
psql "$DATABASE_URL" -c "UPDATE \"user\" SET role = 'admin' WHERE email = '[email protected]';"

Then sign in again — the session picks up the new role.

Install / Uninstall

bash
pnpm add-feature access                  # copies files, applies insertionspnpm --filter @workspace/db db:push      # add the new user columns + audit_log tablepnpm remove-feature access               # reverses the install

No new environment variables are required. The feature needs auth and dashboard to be installed first.