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", "viewer"] 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 |
viewer | Read-only member | View organization data only |
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, defaultStatements } from "better-auth/plugins/organization/access";
const statement = {
...defaultStatements,
// Custom resource permissions
project: ["list", "create", "update", "delete"],
} as const;
export const ac = createAccessControl(statement);
// User role permissions
export const user = ac.newRole({
project: ["create"],
organization: ["update", "delete"],
});
// Admin role permissions
export const admin = ac.newRole({
project: ["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,
user
},
})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 tRPC Procedures
// src/trpc/procedures/routers/tasks.ts
import { authProcedure } from "@/trpc/procedures/trpc";
export const tasksRouter = createTRPCRouter({
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 TRPCError({
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 = createTRPCRouter({
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 TRPCError({ 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 === "viewer") {
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 TRPCError FORBIDDEN if user lacks permission
*/
export async function updateProject(
input: ProjectUpdate,
userId: string
) {
// Implementation
}Examples
Organization Member Check
// src/features/organizations/functions.ts
export async function requireOrganizationMembership({
userId,
organizationId,
minimumRole = "viewer"
}: {
userId: string;
organizationId: string;
minimumRole?: OrganizationRole;
}) {
const member = await getMember({ userId, organizationId });
if (!member) {
throw new Error("Not a member of this organization");
}
// Check role hierarchy
const roleHierarchy = { viewer: 1, admin: 2, owner: 3 };
if (roleHierarchy[member.role] < roleHierarchy[minimumRole]) {
throw new Error(`Requires ${minimumRole} role or higher`);
}
return member;
}Using in tRPC
// src/trpc/procedures/routers/projects.ts
import { authProcedure } from "@/trpc/procedures/trpc";
export const projectsRouter = createTRPCRouter({
update: authProcedure
.input(projectUpsertSchema)
.mutation(async ({ ctx, input }) => {
const project = await ctx.db.projects.getDocument({ id: input.id });
// Check organization permission
await requireOrganizationMembership({
userId: ctx.user.id,
organizationId: project.organizationId,
minimumRole: "admin" // Only admins can update
});
return await ctx.db.projects.updateDocument(input);
}),
delete: authProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.db.projects.getDocument({ id: input.id });
// Only owners can delete
await requireOrganizationMembership({
userId: ctx.user.id,
organizationId: project.organizationId,
minimumRole: "owner"
});
return await ctx.db.projects.deleteDocument({ id: input.id });
}),
});Client-Side Permission Check
"use client";
import { trpc } from "@/trpc/client";
export function ProjectActions({ projectId }: { projectId: string }) {
const { data: member } = trpc.members.getActiveMember.useQuery();
const canEdit = member?.role === "admin" || member?.role === "owner";
const canDelete = member?.role === "owner";
return (
<div className="flex gap-2">
{canEdit && <Button>Edit</Button>}
{canDelete && <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
- tRPC Procedures - API authorization