# Authorization Policies

> Add custom authorization rules for organization operations via Better Auth hooks.

*Canonical: https://makerkit.dev/docs/tanstack-prisma/organizations/authorization-policies*

---

Authorization policies let you add custom business rules that control organization operations. Policies are evaluated by Better Auth hooks **before** each operation executes.

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│                        Better Auth                               │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  Organization Plugin (organizationHooks)                 │    │
│  │                                                          │    │
│  │  beforeCreateOrganization ──┐                            │    │
│  │  beforeUpdateOrganization   │                            │    │
│  │  beforeDeleteOrganization   │    ┌────────────────────┐  │    │
│  │  beforeCreateInvitation     ├───►│ Policy Runtimes    │  │    │
│  │  beforeAcceptInvitation     │    │ (evaluate all      │  │    │
│  │  beforeCancelInvitation     │    │  registered        │  │    │
│  │  beforeRemoveMember         │    │  policies)         │  │    │
│  │  beforeUpdateMemberRole ────┘    └────────────────────┘  │    │
│  │                                            │              │    │
│  └────────────────────────────────────────────┼──────────────┘    │
│                                               ▼                   │
│                                    ┌──────────────────┐           │
│                                    │ allow() → proceed│           │
│                                    │ deny()  → reject │           │
│                                    └──────────────────┘           │
└─────────────────────────────────────────────────────────────────┘
```

## How It Works

1. **User triggers an operation** — e.g., creates an invitation via the UI
2. **Better Auth receives the request** — via its API routes
3. **Organization hook fires** — e.g., `beforeCreateInvitation`
4. **Hook builds context** — extracts `userId`, `organizationId`, etc. from the request
5. **Policy runtime evaluates** — runs all registered policies for that operation
6. **Result determines outcome**:
   - All policies return `allow()` → operation proceeds
   - Any policy returns `deny()` → operation is rejected with `APIError`

## Package Structure

```
packages/organization/policies/src/
├── registry.ts      # Singleton registries + runtimes (edit to register policies)
├── types.ts         # Context type definitions
├── policies/
│   └── custom.ts    # YOUR POLICIES (won't be modified by upstream updates)
└── index.ts         # Package exports

packages/organization/hooks/src/
└── authorization-hooks.ts   # Better Auth hook implementations
```

## Available Registries

The kit ships with **default policies already registered** on most registries (see `registry.ts`). These enforce organization creation limits, subscription checks on deletion, role-targeting on invitations/removals/role changes, and seat limits. You add further policies to enforce additional business rules.

| Registry | Better Auth Hook | When It Runs | Default policies |
|----------|-----------------|--------------|------------------|
| `beforeOrganizationCreateRegistry` | `beforeCreateOrganization` | User creates a new organization | `organizationCreationLimitPolicy` |
| `beforeOrganizationUpdateRegistry` | `beforeUpdateOrganization` | User updates org name/slug/logo | _(none)_ |
| `beforeOrganizationDeleteRegistry` | `beforeDeleteOrganization` | User deletes an organization | `organizationDeleteSubscriptionPolicy` |
| `beforeInvitationCreateRegistry` | `beforeCreateInvitation` | User sends a member invitation | `invitationRoleTargetingPolicy`, `invitationSeatLimitPolicy` |
| `beforeInvitationAcceptRegistry` | `beforeAcceptInvitation` | User accepts an invitation | `invitationAcceptSeatLimitPolicy` |
| `beforeInvitationCancelRegistry` | `beforeCancelInvitation` | User cancels a pending invitation | `invitationCancelTargetingPolicy` |
| `beforeMemberAddRegistry` | `beforeAddMember` | A member is added directly | `memberAddSeatLimitPolicy` |
| `beforeMemberRemoveRegistry` | `beforeRemoveMember` | User removes a member | `memberRemoveTargetingPolicy` |
| `beforeRoleUpdateRegistry` | `beforeUpdateMemberRole` | User changes a member's role | `roleUpdateTargetingPolicy` |

## Context Types

Each hook passes a typed context to policies. The context contains all information available at that point in the request.

### OrganizationCreateContext

```typescript
interface OrganizationCreateContext {
  userId: string;      // User attempting to create
  timestamp: string;   // ISO timestamp
  // Max organizations a single user may own (read by the limit policy)
  maxOrganizationsPerUser: number;
  // Callback returning how many organizations the user currently owns
  getOwnedOrganizationCount: () => Promise<number>;
}
```

No `organizationId` — the organization doesn't exist yet.

### OrganizationUpdateContext

```typescript
interface OrganizationUpdateContext {
  userId: string;
  organizationId: string;
  update: {
    name?: string;
    slug?: string;
    logo?: string | null;
  };
  timestamp: string;
}
```

### OrganizationDeleteContext

```typescript
interface OrganizationDeleteContext {
  userId: string;
  organizationId: string;
  timestamp: string;
  // Callback returning the org's subscriptions (injected to avoid cycles)
  getSubscriptions: () => Promise<SubscriptionInfo[]>;
}
```

### InvitationCreateContext

```typescript
interface InvitationCreateContext {
  userId: string;         // User sending the invitation
  organizationId: string;
  inviteeEmail: string;   // Email being invited
  inviteeRole: string;    // Role assigned to invitee
  timestamp: string;
  // Inviter's role in the org, or null if not a member (role-targeting policy)
  actingRole: string | null;
  // Merged default + custom role-hierarchy map for the org
  roleHierarchy: Record<string, number>;
  // Callback returning seat usage, or null when not seat-enforced
  getSeatUsage: () => Promise<SeatUsage | null>;
  // True when a pending+unexpired invite already exists for inviteeEmail
  isInviteeAlreadyReserved: () => Promise<boolean>;
}
```

### InvitationAcceptContext

```typescript
interface InvitationAcceptContext {
  userId: string;         // User accepting
  userEmail: string;      // User's email
  invitation: {
    id: string;
    email: string;        // Email the invite was sent to
    organizationId: string;
    role: string;
    expiresAt: Date;
  };
  timestamp: string;
  // Callback returning seat usage, or null when not seat-enforced
  getSeatUsage: () => Promise<SeatUsage | null>;
}
```

### InvitationCancelContext

```typescript
interface InvitationCancelContext {
  userId: string;         // User canceling
  invitation: {
    id: string;
    organizationId: string;
    inviterId: string;    // Who originally sent the invite
  };
  // Cancelling user's role in the org, or null if not a member
  actingRole: string | null;
  // Inviter's current role, or null if no longer a member (targeting policy)
  inviterRole: string | null;
  // Merged default + custom role-hierarchy map for the org
  roleHierarchy: Record<string, number>;
  timestamp: string;
}
```

### MemberRemoveContext

```typescript
interface MemberRemoveContext {
  userId: string;         // User performing removal
  organizationId: string;
  targetUserId: string;   // Member being removed
  timestamp: string;
  // Acting user's role in the org, or null if not a member
  actingRole: string | null;
  // Target member's current role, or null if not a member
  targetCurrentRole: string | null;
  // Merged default + custom role-hierarchy map for the org
  roleHierarchy: Record<string, number>;
}
```

### RoleUpdateContext

```typescript
interface RoleUpdateContext {
  userId: string;         // User changing the role
  organizationId: string;
  targetUserId: string;   // Member whose role is changing
  newRole: string;        // The new role
  timestamp: string;
  // Acting user's role in the org, or null if not a member
  actingRole: string | null;
  // Target member's current role, or null if not a member
  targetCurrentRole: string | null;
  // Merged default + custom role-hierarchy map for the org
  roleHierarchy: Record<string, number>;
}
```

## Adding Custom Policies

### Step 1: Define Your Policy

Create or edit `packages/organization/policies/src/policies/custom.ts`:

```typescript
import { definePolicy, allow, deny } from '@kit/policies';
import type { OrganizationCreateContext } from '../types';

export const orgCreationLimitPolicy = definePolicy<OrganizationCreateContext>({
  id: 'organization-create.limit-per-user',
  evaluate: async (context) => {
    const count = await getUserOrgCount(context.userId);

    if (count >= 3) {
      return deny({
        code: 'ORG_LIMIT_EXCEEDED',
        message: 'Maximum 3 organizations allowed',
        remediation: 'Delete an existing organization first',
      });
    }

    return allow();
  },
});
```

### Step 2: Register Your Policy

Edit `packages/organization/policies/src/registry.ts`:

```typescript
import { orgCreationLimitPolicy } from './policies/custom';

// Find the ORGANIZATION CREATE section and add:
beforeOrganizationCreateRegistry.registerPolicy(orgCreationLimitPolicy);
```

## Policy Results

Use helpers from `@kit/policies`:

```typescript
import { allow, deny } from '@kit/policies';

// Allow the operation to proceed
return allow();

// Block the operation
return deny({
  code: 'CUSTOM_ERROR_CODE',      // Stable code for programmatic handling
  message: 'Human-readable error', // Shown to user
  remediation: 'How to fix this',  // Optional guidance
});
```

## Error Handling

When a policy denies an operation, the hook throws a Better Auth `APIError`:

```typescript
throw new APIError('FORBIDDEN', {
  code: 'CUSTOM_ERROR_CODE',
  message: 'Human-readable error',
});
```

The error propagates to the client and can be handled in your UI.

## Example Policies

### Limit Organizations Per User

```typescript
import { definePolicy, allow, deny } from '@kit/policies';
import { db } from '@kit/database';
import type { OrganizationCreateContext } from '../types';

export const orgLimitPolicy = definePolicy<OrganizationCreateContext>({
  id: 'organization-create.user-limit',
  evaluate: async (context) => {
    const count = await db.member.count({
      where: { userId: context.userId },
    });

    if (count >= 5) {
      return deny({
        code: 'ORG_LIMIT_EXCEEDED',
        message: 'You can only belong to 5 organizations',
      });
    }

    return allow();
  },
});
```

### Email Domain Restriction for Invitations

```typescript
import { definePolicy, allow, deny } from '@kit/policies';
import type { InvitationCreateContext } from '../types';

export const domainPolicy = definePolicy<InvitationCreateContext>({
  id: 'invitation-create.domain-restriction',
  evaluate: async (context) => {
    const allowedDomains = ['company.com', 'subsidiary.com'];
    const domain = context.inviteeEmail.split('@')[1];

    if (!allowedDomains.includes(domain)) {
      return deny({
        code: 'DOMAIN_NOT_ALLOWED',
        message: `Only ${allowedDomains.join(', ')} emails can be invited`,
      });
    }

    return allow();
  },
});
```

### Prevent Self-Removal

```typescript
import { definePolicy, allow, deny } from '@kit/policies';
import type { MemberRemoveContext } from '../types';

export const preventSelfRemovalPolicy = definePolicy<MemberRemoveContext>({
  id: 'member-remove.prevent-self',
  evaluate: async (context) => {
    if (context.userId === context.targetUserId) {
      return deny({
        code: 'CANNOT_REMOVE_SELF',
        message: 'You cannot remove yourself from the organization',
        remediation: 'Ask another admin to remove you',
      });
    }

    return allow();
  },
});
```

### Require Subscription for Invitations

```typescript
import { definePolicy, allow, deny } from '@kit/policies';
import { db } from '@kit/database';
import type { InvitationCreateContext } from '../types';

export const subscriptionRequiredPolicy = definePolicy<InvitationCreateContext>({
  id: 'invitation-create.subscription-required',
  evaluate: async (context) => {
    const activeSub = await db.subscription.findFirst({
      where: {
        referenceId: context.organizationId,
        status: { in: ['active', 'trialing'] },
      },
      select: { id: true },
    });

    if (!activeSub) {
      return deny({
        code: 'SUBSCRIPTION_REQUIRED',
        message: 'An active subscription is required to invite members',
        remediation: 'Subscribe to a plan first',
      });
    }

    return allow();
  },
});
```

## Seat Enforcement

Seat-based subscription limits are enforced via `SeatEnforcementService`, not policies. This is because seat enforcement has special logic for resend operations that policies can't easily handle.

See [Seat Enforcement](../billing/seat-enforcement) for details.

## Testing Policies

Policies are pure functions that can be unit tested:

```typescript
import { describe, it, expect } from 'vitest';
import { orgLimitPolicy } from './custom';

describe('orgLimitPolicy', () => {
  it('allows creation when under limit', async () => {
    const result = await orgLimitPolicy
      .create({
        userId: 'user-with-2-orgs',
        timestamp: new Date().toISOString(),
        maxOrganizationsPerUser: 5,
        getOwnedOrganizationCount: async () => 2,
      })
      .evaluate();

    expect(result.allowed).toBe(true);
  });

  it('denies creation when at limit', async () => {
    const result = await orgLimitPolicy
      .create({
        userId: 'user-with-5-orgs',
        timestamp: new Date().toISOString(),
        maxOrganizationsPerUser: 5,
        getOwnedOrganizationCount: async () => 5,
      })
      .evaluate();

    expect(result.allowed).toBe(false);
    expect(result.metadata?.code).toBe('ORG_LIMIT_EXCEEDED');
  });
});
```

## Related

- [Seat Enforcement](../billing/seat-enforcement) — Subscription-based member limits
- [Roles & Permissions](../members-management/roles-permissions) — RBAC system
- [Invitations](../members-management/invitations) — Invitation workflow
