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
policiesthat decide resource-level access (who may act on whom/what), evaluated withauthorize()
Endpoints
All endpoints require a session with the admin role and are additionally checked against the ABAC policies.
| Method | Path | Description |
|---|---|---|
GET | /api/admin/users | List users (?search=&page=&limit=) |
PATCH | /api/admin/users/:id/role | Set a user's role (user or admin) |
POST | /api/admin/users/:id/ban | Ban a user (revokes their sessions) |
POST | /api/admin/users/:id/unban | Lift a ban |
DELETE | /api/admin/users/:id | Delete 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/
├── 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:
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:
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):
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:
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):
| Column | Type | Notes |
|---|---|---|
role | text | Defaults to "user" |
banned | boolean | Defaults to false |
banReason | text | Optional |
banExpires | timestamp | Optional |
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:
| Column | Type | Notes |
|---|---|---|
actorId | text | Admin who acted; NULL if that user is later deleted |
action | text | users:set-role, users:ban, users:unban, users:delete |
targetId | text | The affected user's id (kept even after deletion) |
details | text | e.g. role: user -> admin, the ban reason, or the deleted email |
ipAddress | text | First hop of x-forwarded-for, when present |
createdAt | timestamp | Indexed |
Audit writes never fail the request — errors are logged server-side instead. Record your own actions from any route:
await recordAudit({ actorId: session.user.id, action: "users:set-role", targetId: id, details: "role: user -> admin",})Query the trail directly:
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:
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
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 installNo new environment variables are required. The feature needs auth and dashboard to be installed first.