Documentation
Documentation
Introduction

Getting Started

Getting started
Getting StartedInstallationQuick StartProject Structure

Configuration

Configuration
ConfigurationEnvironment ConfigurationEdge ConfigDatabaseAuth SecretStripeFirebaseStorageGoogle Maps And Cloud Service AccountOAuth ProvidersEmail DeliverySentryFeature Flags

Architecture

Architecture
Architecture OverviewTech StackoRPC MiddlewareDesign Principles

Patterns

Patterns
Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

Database
DatabaseSetupSchema DefinitionDatabase OperationsMigrationsCaching
Data Tables

API

oRPCProceduresRoutersoRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

Storage
StorageConfigurationUsageBuckets
Stripe Billing

Extra

Caching

Templates

Templates
Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate oRPC RouterAdd Translations

Development

Development
DevelopmentCommandsAI AgentsBest Practices
Pulling Updates

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];
RoleDescriptionCapabilities
ownerOrganization ownerFull control, can delete org, manage billing
adminAdministratorManage members, settings, all resources
memberRead-only memberView 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 }
ParameterTypeDescription
ctx{ db, user, t }The oRPC context (passed automatically)
organizationIdstringThe target organization
allowedRolesOrganizationRole[]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:

RouterActionAllowed Roles
Organizationsupsert / updateowner, admin
Organizationsdeleteowner
MembersupdateRole / removeowner, admin
Invitationscreate / cancelowner, admin
Subscriptionsupsert / deleteowner, 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 ValueTypeDescription
roleOrganizationRole | nullRaw role string ("owner", "admin", "member", or null)
isOwnerbooleantrue when role is "owner"
isAdminbooleantrue when role is "admin"
canManagebooleantrue when role is "owner" or "admin"
isMemberbooleantrue when role is "member"
isOrgMemberbooleantrue when user has any role in the organization
isLoadingbooleantrue 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 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 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

  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
  • oRPC Procedures - API authorization

On this page

Overview
Organization Permissions
Organization Roles
Member Schema
Access Control Configuration
Better-Auth Organization Plugin
Server-Side: requireOrgRole
requireOrgRole(ctx, organizationId, allowedRoles)
isOrgRoleDenied(result)
requireOrgMembership(ctx, organizationId)
Permission Matrix
Full Router Example
Client-Side: useOrganizationRole
Usage
Disable Form Fields
Hide Row Actions
Disable Individual Buttons
Adding Permission Translations
Resource Ownership
Ownership Checks
Owner-Based oRPC 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 Role Check (Server)
Organization Role Check (Client)
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