PermDock
Adapters

Better Auth

The permdock/better-auth provider builds a PermDock subject from Better Auth sessions and organization roles, including dynamic database roles, so PermDock's conditions, snapshots and adapters layer on top of Better Auth access control.

permdock/better-auth is a provider. Better Auth already answers "which roles does this user hold in this organization"; PermDock answers "may this subject perform this action on this resource", with conditions, three-outcome decisions, snapshots for the client, and every server, agent and data adapter. The provider connects the two by turning a Better Auth session into a PermDock subject and, optionally, by deriving PermDock role fragments from Better Auth createAccessControl statements.

Purpose

Better Auth's access control (createAccessControl(statement), ac.newRole(...), hasPermission, dynamic roles stored in the database) is RBAC bound to the organization and admin plugins: statements are resource: [actions] records, checks are boolean, there are no conditions, no explain, no client snapshot with conditions, and no MCP or data-layer story (landscape). Teams on Better Auth who need ownership rules or agent tooling end up with a second permission system. The provider avoids that by making Better Auth the source of identity and role membership while PermDock owns the permission model.

API

import { createPermDock, definePolicy, role, allow } from "permdock";
import {
  subjectFromBetterAuth,
  betterAuthRoleSource,
  rolesFromAccessControl,
} from "permdock/better-auth";
import { auth } from "./auth"; // betterAuth({ plugins: [organization({ ac, roles })] })
import { ac, owner, admin, member } from "./permissions.better-auth";

// 1. Subject: session + active organization + memberships (organization roles, team roles, dynamic roles)
const session = await auth.api.getSession({ headers });
const permdock = await createPermDock(
  policy,
  await subjectFromBetterAuth(auth, session),
  {
    customRoles: betterAuthRoleSource(auth), // organizationRole rows -> RoleSource
  },
);
// subject.principal = {
//   id,
//   tenant: activeOrganizationId,
//   roles: ['support'],                              // user.role from the admin plugin: global
//   memberships: [
//     { tenant: 'o_acme', roles: ['member', 'billing-admin'] },                 // member.role; 'billing-admin' is dynamic
//     { tenant: 'o_acme', team: 't_design', roles: ['lead'], via: 'team:t_design' },  // teamMember rows
//   ],
//   email,
// }

// 2. Optional: seed PermDock roles from Better Auth statements, then add what Better Auth cannot express
const seeded = rolesFromAccessControl(
  { ac, roles: { owner, admin, member } },
  permissions,
  { on: "tenant" },
);
export const policy = definePolicy(permissions, {
  roles: [
    ...seeded, // 'member' gets allow(post.read), allow(post.create), ... scoped to the organization
    role(
      "member",
      [allow(permissions.post.update, { where: { authorId: principal.id } })],
      { on: "tenant" },
    ), // fragment merges by name
    role("support", [allow(permissions.post.read)]), // global: admin plugin role
  ],
  scopes: {
    tenant: { key: "organizationId" },
    team: { key: "teamId", within: "tenant" },
  },
});
  • subjectFromBetterAuth(auth, session, options?) returns a principal with id, tenant (activeOrganizationId), roles (global roles from user.role, managed by the admin plugin) and memberships: one entry per member row the user holds (every organization by default, so the tenant switcher has the full list) with the row's role values, plus one entry per teamMember row with via: 'team:<id>'. Dynamic roles created through Better Auth's dynamicAccessControl (organizationRole rows) appear by name on the membership and resolve through the RoleSource below. The function is async because it reads member and team rows. Rows a customSession puts on the session (members, teamMembers) are used first. Otherwise it reads every member row of the user in one query through the database adapter on auth.$context (findMany on the member model, userId equal to the session user, at most 5,000 rows). Without that adapter, or when the query fails, it lists organization ids with auth.api.listOrganizations and reads the caller's own member row in each one: getActiveMember for the active organization, and elsewhere listMembers filtered with filterField: 'userId'. A row whose userId is not the session user is dropped, because listMembers returns every member of an organization. A failed lookup drops that organization only. Stock teamMember rows carry no role, so team memberships from listUserTeams appear only when a role is added to the row; otherwise put team roles on the session. options.memberships: 'active' limits the read to the active organization.
  • betterAuthRoleSource(auth) is a RoleSource over the organizationRole table: rolesFor(tenant) returns each dynamic role as a CustomRole whose includes are the declared assignable roles the Better Auth role's statement matches, and assignable(tenant) returns the declared assignable roles the organization may hand out. A dynamic role whose statement matches no declared assignable role resolves to nothing.
  • rolesFromAccessControl(config, permissions, options?) maps each Better Auth statement entry resource: [action] to allow(permissions.<resource>.<action>) where a matching PermDock permission exists, producing role fragments that merge with hand-written fragments of the same name. options.on sets the scope of the generated roles ('tenant' for organization plugin roles; omit for admin plugin roles). Unmatched entries are reported, never silently dropped.
  • Server adapters accept the provider directly: createPermDock(policy, { subject: (c) => subjectFromBetterAuth(auth, c.get('session')), customRoles: betterAuthRoleSource(auth) }) in permdock/hono.
  • options.schema (any Standard Schema) parses user.additionalFields into principal.claims. Without a schema no additional field reaches claims, because a user can edit their own additional fields unless the app sets input: false on each one. An invalid record drops the extra fields, never the subject.

Verified material

subjectFromBetterAuth(auth, session) consumes the session Better Auth's server API returned; it never reads the cookie or the session token itself (Authentication and PermDock).

  • Input. The result of auth.api.getSession({ headers }) on the server, which Better Auth has looked up (or decoded from the signed cookie cache) and validated for expiry. A session object assembled by the app, a client-side useSession() value or a raw cookie is not accepted; a null session yields the anonymous subject.
  • Fields used. session.user.id becomes principal.id; session.session.activeOrganizationId becomes principal.tenant; session.session.expiresAt becomes subject.expiresAt; session.session.id is carried on audit events.
  • Roles and memberships. Global roles come from user.role as managed by the admin plugin. Organization roles come from the member rows the organization plugin holds (one membership per organization), team roles from teamMember rows, and dynamic roles from organizationRole rows through betterAuthRoleSource. All are server-managed through Better Auth's server API; a user cannot edit them through the client API. Ids (organizationId, teamId) are the membership keys, never slugs or names. Profile fields the user can change (name, image) are never used for grants, and other additionalFields reach principal.claims only through options.schema.
  • Cookie cache staleness. When Better Auth's cookie cache is enabled, getSession may answer from the signed cookie without hitting the database until the cache maxAge elapses. During that window user.role and the member record reflect the state at cache time: a demoted admin keeps the admin role until the cookie refreshes. If role changes must apply on the next request, disable the cookie cache for routes that use PermDock, call getSession with disableCookieCache: true, or pair the provider's onRoleChange hook with updateTag and a session refresh.
  • No token parsing. Better Auth's bearer and JWT plugins produce tokens for other consumers; if an API receives one of those, verify it with Better Auth's own endpoint or with permdock/jwt against the plugin's JWKS, then pass the verified result on. The provider does not verify tokens.
  • MFA. The two-factor plugin creates a session only after TOTP, OTP or a backup code verifies. user.twoFactorEnabled says the user enrolled; the plugin does not store RFC 8176 amr on the session. subjectFromBetterAuth does not invent one. Map twoFactorEnabled plus a completed session in a wrapper when a grant uses assurance({ amr: ['mfa'] }).

Request lifecycle

  1. Better Auth authenticates the request and yields a session with activeOrganizationId.
  2. The provider loads the user's own member and teamMember rows (never another member's), producing the subject with tenant and memberships.
  3. createPermDock(policy, subject, { customRoles }) builds the request-scoped instance; declared roles resolve to grants, dynamic role names resolve through the RoleSource for the active organization.
  4. Checks run through whichever adapter the app uses; snapshot() carries roles and grants to the client so usePermission answers ownership checks offline.
  5. Role changes in Better Auth (updateMemberRole, dynamic role edits) should trigger updateTag for the affected users; the provider exposes an onRoleChange helper that wraps Better Auth's hooks for this. Pass { sink } to write a membership event (source: 'better-auth') with role, previousRole, teamId and by.

Sessions and devices

List and revoke sessions through Better Auth's session API (listSessions, revokeSession). PermDock does not own a device list UI. Revocation reaches PermDock through Back-Channel Logout or CAEP into permdock/ssf, joined on subject.session. With approvals set on the SSF factory, pending approval requests for that session are cancelled.

What it validates

  • Session validity is Better Auth's responsibility; the provider never reads cookies or tokens itself.
  • Role names from Better Auth must exist in definePolicy or resolve through betterAuthRoleSource; names that do neither are dropped with a warning (fail closed).
  • rolesFromAccessControl checks every resource:action pair against listPermissions(permissions) and reports pairs that have no PermDock permission, so the two catalogs stay aligned; permdock usage includes these findings.
  • No condition data comes from Better Auth; conditions are evaluated on app data as in any other setup.
  • rolesFromAccessControl seeds roles at runtime from the statements; there is no build step and no reverse mapping that emits Better Auth statements from permissions.
  • betterAuthRoleSource gives a dynamic organizationRole the declared assignable roles whose statements it covers, matched by permission set; organizationRole needs no extra column.
  • A role name used both as a global user.role and as an organization role is one declared role with two scopes, which permdock doctor reports.

How denials surface

  • Through PermDock, per the shared contract on adapters. Better Auth's own hasPermission is not called on the request path, so there is one denial format.
  • Apps that keep calling auth.api.hasPermission for Better Auth plugin routes (organization management) continue to get Better Auth's boolean; the provider does not intercept those.
  • A user with no active organization yields a subject with tenant undefined; tenant-scoped grants are denied with no-membership, global roles still apply. A row from another organization is denied with tenant-mismatch (tenancy).

Example app

apps/examples/better-auth: a Hono server on 127.0.0.1:3463. createPermDock from permdock/hono resolves the subject with subjectFromBetterAuth over a fixed session with one member row, and reads dynamic roles through betterAuthRoleSource. protect guards PATCH /posts/:id (granted) and POST /posts/:id/delete (a Problem Details 403). No Better Auth server and no database.

Last updated on

On this page