Documentation
Documentation
Introduction

Getting Started

Getting StartedInstallationQuick StartProject Structure

Architecture

Architecture OverviewTech StacktRPC MiddlewareDesign Principles

Patterns

Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

DatabaseSchema DefinitionDatabase OperationsMigrationsCaching

API

tRPCProceduresRouterstRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

StorageConfigurationUsageBuckets

Configuration

ConfigurationEnvironment VariablesFeature Flags

Templates

Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate tRPC RouterAdd Translations

Development

DevelopmentCommandsAI AgentsBest Practices

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];
RoleDescriptionCapabilities
ownerOrganization ownerFull control, can delete org, manage billing
adminAdministratorManage members, settings, all resources
viewerRead-only memberView 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 database

4. 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

  1. Check if RLS is enabled:

    .enableRLS() // Must be called
  2. Verify database roles exist:

    SELECT rolname FROM pg_roles WHERE rolname IN ('authenticated', 'admin');
  3. Check RLS context is set:

    await setRLSContext(userId, userRole);

Permission Denied Errors

  1. Log the current user context:

    console.log("User:", ctx.user.id, "Role:", ctx.user.role);
  2. Verify organization membership:

    const member = await ctx.db.members.getMember({
      userId: ctx.user.id,
      organizationId
    });
    console.log("Member role:", member?.role);
  3. 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

On this page

Overview
Organization Permissions
Organization Roles
Member Schema
Access Control Configuration
Better-Auth Organization Plugin
Resource Ownership
Ownership Checks
Owner-Based tRPC Procedures
Row-Level Security (RLS)
What is RLS?
RLS Configuration
RLS Helper Functions
Ownership Check
Role Check
Organization Membership
Subscription Tier
Universal Policies
Applying RLS Policies
Basic Ownership Policy
Organization-Based Policy
Admin Override
Tier-Based Access
RLS Context
Best Practices
1. Layer Security (Defense in Depth)
2. Fail Securely
3. Use Type-Safe Roles
4. Check Early
5. Document Permission Requirements
Examples
Organization Member Check
Using in tRPC
Client-Side Permission Check
Complete RLS Example
Common Patterns
Pattern 1: Public + Owner
Pattern 2: Organization-Scoped
Pattern 3: Tier-Gated
Pattern 4: Admin Override
Troubleshooting
RLS Policy Not Working
Permission Denied Errors
See Also