Permissions
Organization-level permissions and resource access control
Overview
While roles provide system-wide authorization, permissions enable fine-grained access control within organizations and for specific resources. This template implements permissions at multiple levels:
- Organization Permissions - Role-based access within organizations
- Resource Ownership - Users can access their own resources
- Row-Level Security (RLS) - Database-enforced access policies
Organization Permissions
Organization Roles
Organizations have their own role hierarchy, separate from system-wide roles:
// src/features/organizations/schema.ts
export const organizationRoles = ["owner", "admin", "member"] as const;
export type OrganizationRole = typeof organizationRoles[number];| Role | Description | Capabilities |
|---|---|---|
owner | Organization owner | Full control, can delete org, manage billing |
admin | Administrator | Manage members, settings, all resources |
member | Read-only member | View organization data, create own resources |
Member Schema
Members are linked to organizations with specific roles:
// src/db/tables/members.ts
export const members = createTable("members", {
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id, { onDelete: "cascade" }),
role: text("role", { enum: organizationRoles }).notNull(),
});Access Control Configuration
From src/lib/auth/permissions.ts:
import { createAccessControl } from "better-auth/plugins/access";
import {
adminAc,
memberAc,
defaultStatements,
} from "better-auth/plugins/organization/access";
const statement = {
...defaultStatements,
// Custom resource permissions
todo: ["create", "update", "delete"],
} as const;
export const ac = createAccessControl(statement);
// System-level user role
export const user = ac.newRole({
todo: ["create"],
});
// Organization member role (read-only + create own resources)
export const member = ac.newRole({
todo: ["create"],
...memberAc.statements,
});
// Organization admin role
export const admin = ac.newRole({
todo: ["create", "update"],
...adminAc.statements,
});Better-Auth Organization Plugin
The organization plugin handles permission checks automatically:
// src/lib/auth/server.ts
organization({
allowUserToCreateOrganization: true,
organizationLimit: 10,
creatorRole: "owner",
membershipLimit: 100,
ac: ac,
roles: {
admin,
member,
},
})Server-Side: requireOrgRole
The shared utility in src/rpc/procedures/utils/require-org-role.ts provides a single function to verify organization-level role access inside any oRPC procedure. It replaces all inline role checks across routers.
import {
requireOrgRole,
isOrgRoleDenied,
requireOrgMembership,
} from "@/rpc/procedures/utils/require-org-role";requireOrgRole(ctx, organizationId, allowedRoles)
Checks that the current user is a member of the organization with one of the allowed roles. Returns the member record on success or an ActionResponse with success: false on failure.
const memberOrError = await requireOrgRole(ctx, organizationId, ["owner", "admin"]);
if (isOrgRoleDenied(memberOrError)) return memberOrError; // 403 response
const member = memberOrError; // typed: { id, userId, organizationId, role }| Parameter | Type | Description |
|---|---|---|
ctx | { db, user, t } | The oRPC context (passed automatically) |
organizationId | string | The target organization |
allowedRoles | OrganizationRole[] | One or more of "owner", "admin", "member" |
Returns: OrgRoleMember \| ActionResponse
isOrgRoleDenied(result)
Type-guard that narrows the return value of requireOrgRole to an ActionResponse (i.e. the user was denied).
if (isOrgRoleDenied(result)) {
// result is ActionResponse — return it to the client
return result;
}
// result is { id, userId, organizationId, role }requireOrgMembership(ctx, organizationId)
Convenience wrapper that checks any role (owner, admin, or member). Use when you only need to verify the user belongs to the organization.
const memberOrError = await requireOrgMembership(ctx, organizationId);
if (isOrgRoleDenied(memberOrError)) return memberOrError;Permission Matrix
The following table shows which roles are required for each action across the built-in routers:
| Router | Action | Allowed Roles |
|---|---|---|
| Organizations | upsert / update | owner, admin |
| Organizations | delete | owner |
| Members | updateRole / remove | owner, admin |
| Invitations | create / cancel | owner, admin |
| Subscriptions | upsert / delete | owner, admin |
Full Router Example
// src/rpc/procedures/routers/projects.ts
import { requireOrgRole, isOrgRoleDenied } from "@/rpc/procedures/utils/require-org-role";
export const projectsRouter = createRPCRouter({
update: authProcedure
.input(projectUpsertSchema)
.mutation(async ({ ctx, input }) => {
// Only admins and owners can update
const memberOrError = await requireOrgRole(
ctx, input.organizationId, ["owner", "admin"]
);
if (isOrgRoleDenied(memberOrError)) return memberOrError;
const result = await ctx.db.projects.updateDocument(input);
return {
success: true,
message: ctx.t("toasts.saved"),
payload: result,
} satisfies ActionResponse;
}),
delete: authProcedure
.input(z.object({ id: z.string(), organizationId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Only owners can delete
const memberOrError = await requireOrgRole(
ctx, input.organizationId, ["owner"]
);
if (isOrgRoleDenied(memberOrError)) return memberOrError;
await ctx.db.projects.deleteDocument({ id: input.id });
return {
success: true,
message: ctx.t("toasts.deleted"),
} satisfies ActionResponse;
}),
});Client-Side: useOrganizationRole
The useOrganizationRole hook in src/features/members/hooks.ts provides reactive access to the current user's organization role for client-side UI gating.
import { useOrganizationRole } from "@/features/members/hooks";Usage
const { canManage, isMember, isOwner, isAdmin, isOrgMember, isLoading } =
useOrganizationRole(organizationId);| Return Value | Type | Description |
|---|---|---|
role | OrganizationRole | null | Raw role string ("owner", "admin", "member", or null) |
isOwner | boolean | true when role is "owner" |
isAdmin | boolean | true when role is "admin" |
canManage | boolean | true when role is "owner" or "admin" |
isMember | boolean | true when role is "member" |
isOrgMember | boolean | true when user has any role in the organization |
isLoading | boolean | true while the member list is being fetched |
The organizationId parameter is optional — when omitted, the hook falls back to the active organization from useOrganizationActive().
Disable Form Fields
Wrap form controls in a <fieldset disabled> and show a tooltip on the submit button:
const { canManage, isLoading } = useOrganizationRole(organizationId);
const isDisabled = !canManage && !isLoading;
<Form form={form} executeAction={saveAction}>
<fieldset disabled={isDisabled}>
<FormFields form={form} />
</fieldset>
{isDisabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div><SubmitButton disabled /></div>
</TooltipTrigger>
<TooltipContent>
<p>{t("permissions.memberCannotEdit")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<SubmitButton />
)}
</Form>Hide Row Actions
Conditionally hide table row actions (e.g. edit, delete) for members:
const { canManage } = useOrganizationRole(organizationId);
const actions: TableRowActionType<Row>[] = [];
if (row.role !== "owner" && canManage) {
actions.push({ label: "Change role", onClick: ... });
actions.push({ label: "Remove", onClick: ... });
}Disable Individual Buttons
Disable specific buttons (e.g. resend invite, cancel invite) for members:
const { canManage } = useOrganizationRole(organizationId);
<Button disabled={!canManage} onClick={handleCancel}>
Cancel Invitation
</Button>Client-side checks are for UX only. Always enforce permissions server-side with requireOrgRole — a determined user can bypass disabled buttons.
Adding Permission Translations
When gating UI with tooltips, add translation keys to the relevant dictionary files:
// src/messages/dictionaries/en/pageDashboardOrganizationSlug.json
{
"permissions": {
"memberCannotEdit": "Only admins and owners can edit organization settings",
"memberCannotManage": "Only admins and owners can perform this action"
}
}// src/messages/dictionaries/en/pageDashboardOrganizationSlugMembers.json
{
"permissions": {
"memberCannotInvite": "Only admins and owners can invite members",
"memberCannotManageMembers": "Only admins and owners can manage members"
}
}Remember to add the same keys in every locale (e.g. it/).
Resource Ownership
Ownership Checks
Users have implicit access to resources they own. Implement ownership checks in your functions:
// src/features/tasks/functions.ts
export async function getTask(
{ id, userId }: { id: string; userId: string }
) {
const task = await operations.getDocument({ id });
// Verify ownership
if (task.ownerId !== userId) {
throw new Error("Access denied: You don't own this task");
}
return task;
}Owner-Based oRPC Procedures
// src/rpc/procedures/routers/tasks.ts
import { authProcedure } from "@/rpc/procedures/rpc";
export const tasksRouter = createRPCRouter({
getById: authProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const task = await ctx.db.tasks.getDocument({ id: input.id });
// Check ownership
if (task.ownerId !== ctx.user.id) {
throw new ORPCError({
code: "FORBIDDEN",
message: "You don't have permission to access this task"
});
}
return task;
}),
});Row-Level Security (RLS)
What is RLS?
Row-Level Security enforces access policies at the database level, providing an additional security layer that cannot be bypassed through application code.
RLS Configuration
From src/db/rls.ts:
import { sql } from "drizzle-orm";
import { type PgColumn, pgRole } from "drizzle-orm/pg-core";
// ------------------------------ ROLES ------------------------------
export const anonymousRole = pgRole("anonymous").existing();
export const authenticatedRole = pgRole("authenticated").existing();
export const adminRole = pgRole("admin").existing();
export const serviceRole = pgRole("service").existing();RLS Helper Functions
Ownership Check
/**
* Checks if the current user ID matches the provided user ID column
*/
export const isOwner = (userIdColumn: PgColumn) =>
sql`(auth.user_id() = ${userIdColumn})`;Role Check
/**
* Checks if the current user has admin role
*/
export const isAdmin = sql`(
COALESCE(
(current_setting('request.jwt.claims', true)::json->>'role')::text,
''
) = 'admin'
)`;
/**
* Checks if the current user has a specific role
*/
export const hasRole = (role: string) => sql`(
COALESCE(
(current_setting('request.jwt.claims', true)::json->>'role')::text,
''
) = ${role}
)`;Organization Membership
/**
* Checks if the user is a member of an organization
*/
export const isOrganizationMember = (organizationIdColumn: PgColumn) => sql`(
EXISTS (
SELECT 1 FROM members
WHERE members."userId" = auth.user_id()
AND members."organizationId" = ${organizationIdColumn}
)
)`;
/**
* Checks if the user is an owner or admin of an organization
*/
export const isOrganizationOwnerOrAdmin = (organizationIdColumn: PgColumn) => sql`(
EXISTS (
SELECT 1 FROM members
WHERE members."userId" = auth.user_id()
AND members."organizationId" = ${organizationIdColumn}
AND members.role IN ('owner', 'admin')
)
)`;
/**
* Checks if the user is an organization owner
*/
export const isOrganizationOwner = (organizationIdColumn: PgColumn) => sql`(
EXISTS (
SELECT 1 FROM members
WHERE members."userId" = auth.user_id()
AND members."organizationId" = ${organizationIdColumn}
AND members.role = 'owner'
)
)`;Subscription Tier
/**
* Checks if the user has a specific tier
*/
export const hasTier = (tier: "free" | "pro") => sql`(
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.user_id()
AND users.tier = ${tier}
)
)`;
/**
* Checks if the user has pro tier
*/
export const hasProTier = sql`(
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.user_id()
AND users.tier = 'pro'
)
)`;Universal Policies
/** Always allow - for public access */
export const allowAll = sql`true`;
/** Always deny - for restricted operations */
export const denyAll = sql`false`;Applying RLS Policies
Basic Ownership Policy
import { createTable } from "@/db/table-utils";
import { isOwner, authenticatedRole } from "@/db/rls";
export const tasks = createTable("tasks", {
title: text("title").notNull(),
ownerId: uuid("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
})
.enableRLS() // Enable Row-Level Security
.withPolicy("select", {
to: authenticatedRole,
using: isOwner(tasks.ownerId)
})
.withPolicy("update", {
to: authenticatedRole,
using: isOwner(tasks.ownerId)
})
.withPolicy("delete", {
to: authenticatedRole,
using: isOwner(tasks.ownerId)
});Organization-Based Policy
import { isOrganizationMember, authenticatedRole } from "@/db/rls";
export const projects = createTable("projects", {
name: text("name").notNull(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id, { onDelete: "cascade" }),
})
.enableRLS()
// Members can read
.withPolicy("select", {
to: authenticatedRole,
using: isOrganizationMember(projects.organizationId)
})
// Only admins/owners can create/update/delete
.withPolicy("insert", {
to: authenticatedRole,
using: isOrganizationOwnerOrAdmin(projects.organizationId)
})
.withPolicy("update", {
to: authenticatedRole,
using: isOrganizationOwnerOrAdmin(projects.organizationId)
})
.withPolicy("delete", {
to: authenticatedRole,
using: isOrganizationOwner(projects.organizationId)
});Admin Override
import { isAdmin, isOwner, authenticatedRole, adminRole } from "@/db/rls";
export const posts = createTable("posts", {
content: text("content").notNull(),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
})
.enableRLS()
// Users can read all posts
.withPolicy("select", {
to: authenticatedRole,
using: allowAll
})
// Users can update their own posts OR admins can update any post
.withPolicy("update", {
to: authenticatedRole,
using: sql`(${isOwner(posts.authorId)} OR ${isAdmin})`
})
// Only owners can delete, except admins
.withPolicy("delete", {
to: authenticatedRole,
using: sql`(${isOwner(posts.authorId)} OR ${isAdmin})`
});Tier-Based Access
import { hasProTier, authenticatedRole } from "@/db/rls";
export const premiumContent = createTable("premium_content", {
title: text("title").notNull(),
content: text("content").notNull(),
})
.enableRLS()
// Only pro users can access
.withPolicy("select", {
to: authenticatedRole,
using: hasProTier
});RLS Context
Set user context for RLS policies:
// src/db/rls-context.ts
import { dbDrizzle } from "@/db";
import { sql } from "drizzle-orm";
export async function setRLSContext(userId: string, userRole: string) {
await dbDrizzle.execute(
sql`SELECT set_config('request.jwt.claims',
json_build_object(
'sub', ${userId},
'role', ${userRole}
)::text,
true)`
);
}Best Practices
1. Layer Security (Defense in Depth)
Implement checks at multiple levels:
// ✅ Good: Multiple layers
export const tasksRouter = createRPCRouter({
update: authProcedure // Layer 1: Require authentication
.input(taskUpsertSchema)
.mutation(async ({ ctx, input }) => {
// Layer 2: Check ownership in application
const existing = await ctx.db.tasks.getDocument({ id: input.id });
if (existing.ownerId !== ctx.user.id) {
throw new ORPCError({ code: "FORBIDDEN" });
}
// Layer 3: RLS policy enforces at database level
return await ctx.db.tasks.updateDocument(input);
}),
});2. Fail Securely
Default to denying access:
// ✅ Good: Explicit allow
if (member.role !== "owner" && member.role !== "admin") {
throw new Error("Access denied");
}
// ❌ Bad: Implicit allow
if (member.role === "member") {
throw new Error("Access denied");
}3. Use Type-Safe Roles
// ✅ Good: Type-safe enum
if (member.role === "owner") { }
// ❌ Bad: String literals
if (member.role === "Owner") { } // Might not match database4. Check Early
// ✅ Good: Check at the start
export async function deleteProject(id: string, userId: string) {
const project = await getDocument({ id });
// Check permission first
if (!await canDeleteProject(project, userId)) {
throw new Error("Access denied");
}
// Then proceed with operation
await deleteDocument({ id });
}5. Document Permission Requirements
/**
* Updates a project
*
* @requires Organization admin or owner role
* @requires Project must belong to user's active organization
* @throws ORPCError FORBIDDEN if user lacks permission
*/
export async function updateProject(
input: ProjectUpdate,
userId: string
) {
// Implementation
}Examples
Organization Role Check (Server)
Use requireOrgRole for all organization-level permission checks in routers:
import {
requireOrgRole,
isOrgRoleDenied,
} from "@/rpc/procedures/utils/require-org-role";
export const projectsRouter = createRPCRouter({
update: authProcedure
.input(projectUpsertSchema)
.mutation(async ({ ctx, input }) => {
const memberOrError = await requireOrgRole(
ctx, input.organizationId, ["owner", "admin"]
);
if (isOrgRoleDenied(memberOrError)) return memberOrError;
return await ctx.db.projects.updateDocument(input);
}),
delete: authProcedure
.input(z.object({ id: z.string(), organizationId: z.string() }))
.mutation(async ({ ctx, input }) => {
const memberOrError = await requireOrgRole(
ctx, input.organizationId, ["owner"]
);
if (isOrgRoleDenied(memberOrError)) return memberOrError;
return await ctx.db.projects.deleteDocument({ id: input.id });
}),
});Organization Role Check (Client)
Use useOrganizationRole instead of manual member lookups:
"use client";
import { useOrganizationRole } from "@/features/members/hooks";
export function ProjectActions({ organizationId }: { organizationId: string }) {
const { canManage, isOwner } = useOrganizationRole(organizationId);
return (
<div className="flex gap-2">
{canManage && <Button>Edit</Button>}
{isOwner && <Button variant="destructive">Delete</Button>}
</div>
);
}Complete RLS Example
// src/db/tables/documents.ts
import { createTable } from "@/db/table-utils";
import {
isOwner,
isOrganizationMember,
isAdmin,
authenticatedRole,
allowAll
} from "@/db/rls";
export const documents = createTable("documents", {
title: text("title").notNull(),
content: text("content").notNull(),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
organizationId: uuid("organization_id")
.references(() => organizations.id, { onDelete: "cascade" }),
isPublic: boolean("is_public").default(false),
})
.enableRLS()
// Anyone can read public documents
// Organization members can read private org documents
// Admins can read everything
.withPolicy("select", {
to: authenticatedRole,
using: sql`(
${documents.isPublic}
OR ${isOrganizationMember(documents.organizationId)}
OR ${isAdmin}
)`
})
// Only organization members can create documents
.withPolicy("insert", {
to: authenticatedRole,
using: isOrganizationMember(documents.organizationId)
})
// Authors and org admins can update
.withPolicy("update", {
to: authenticatedRole,
using: sql`(
${isOwner(documents.authorId)}
OR ${isOrganizationOwnerOrAdmin(documents.organizationId)}
OR ${isAdmin}
)`
})
// Only authors and org owners can delete
.withPolicy("delete", {
to: authenticatedRole,
using: sql`(
${isOwner(documents.authorId)}
OR ${isOrganizationOwner(documents.organizationId)}
OR ${isAdmin}
)`
});Common Patterns
Pattern 1: Public + Owner
Resource is public to read, but only owner can modify:
.withPolicy("select", { to: authenticatedRole, using: allowAll })
.withPolicy("update", { to: authenticatedRole, using: isOwner(table.ownerId) })
.withPolicy("delete", { to: authenticatedRole, using: isOwner(table.ownerId) })Pattern 2: Organization-Scoped
Resource belongs to organization, members have different access levels:
.withPolicy("select", {
to: authenticatedRole,
using: isOrganizationMember(table.organizationId)
})
.withPolicy("update", {
to: authenticatedRole,
using: isOrganizationOwnerOrAdmin(table.organizationId)
})Pattern 3: Tier-Gated
Resource requires specific subscription tier:
.withPolicy("select", {
to: authenticatedRole,
using: hasProTier
})Pattern 4: Admin Override
Normal permissions with admin bypass:
.withPolicy("delete", {
to: authenticatedRole,
using: sql`(${isOwner(table.ownerId)} OR ${isAdmin})`
})Troubleshooting
RLS Policy Not Working
-
Check if RLS is enabled:
.enableRLS() // Must be called -
Verify database roles exist:
SELECT rolname FROM pg_roles WHERE rolname IN ('authenticated', 'admin'); -
Check RLS context is set:
await setRLSContext(userId, userRole);
Permission Denied Errors
-
Log the current user context:
console.log("User:", ctx.user.id, "Role:", ctx.user.role); -
Verify organization membership:
const member = await ctx.db.members.getMember({ userId: ctx.user.id, organizationId }); console.log("Member role:", member?.role); -
Check RLS policies:
SELECT * FROM pg_policies WHERE tablename = 'your_table';
See Also
- Roles - System-wide user roles
- Organizations - Organization management
- Database - Database setup and RLS configuration
- oRPC Procedures - API authorization