---
title: Authorization
description: One policy that says what an owner, an admin and a member of an organization may do, and one way to ask it.
---

`@zap-ts/authorization` answers one question: may this member do this? It is
the smallest package in the workspace. It holds three roles, four actions, and
a policy. A policy is a set of rules that maps a role to the actions it may do.

The package never touches the database or the session. You pass it a role, and
it answers.

It is built on `@zap-studio/permit`. You declare the actions with Zod. So an
action that does not exist is a type error, not a silent `false`.

## Roles

```ts
import { isOrganizationRole } from "@zap-ts/authorization";

if (isOrganizationRole(role)) {
  // `role` is now "owner", "admin" or "member".
}
```

`OrganizationRole` is `"owner" | "admin" | "member"`.

A role reaches your code as a plain string. It comes from a `member` row, or
from a Better Auth call. So you call `isOrganizationRole` first. It tells
TypeScript which of the three values you have.

A role the policy does not know counts as no role at all. That is why
`getActiveMembership` in [Authentication](/packages/authentication) returns
`null` instead of guessing.

## The policy

```ts
import { organizationPolicy } from "@zap-ts/authorization";

const canInvite = await organizationPolicy.can(
  { actor: { role: "admin" } },
  "organization:invite-member",
  { id: "org_123" },
);
```

The `organization` resource has four actions:

| Action            | Who               |
| ----------------- | ----------------- |
| `view`            | everyone          |
| `update-settings` | owners and admins |
| `manage-billing`  | owners and admins |
| `invite-member`   | owners and admins |

`can` is async and returns `true` or `false`. You pass it `{ actor: { role } }`
and `{ id }`. It reads nothing else. So the call is cheap. You can put it
inside a render or a loop.

The policy is exported from `.` and from
`@zap-ts/authorization/organization`. Import it from `.`.

## Who calls it

Three callers use the policy. All of them live outside this package. All of
them narrow the role first.

[Billing](/packages/billing) is the main one. `canManageBilling` reads the
`member` row for a user and an organization. It narrows the role. Then it asks
the policy for `organization:manage-billing`.

```ts
import { canManageBilling } from "@zap-ts/billing/access";
```

Every payment provider passes that same function to its `authorizeReference`
hook. So one place decides who may start a checkout or open the customer
portal. No provider decides it on its own.

`readOnboardingState` and `completeOnboarding` in
[Authentication](/packages/authentication) ask for
`organization:update-settings`. They ask before they offer to rename the
organization. So a member who joined through an invitation never sees that
field.

## Adding a rule

Open `packages/authorization/src/organization.ts`. Then:

1. Add the name of your action to the `actions` list.
2. Add a rule for it under `rules.organization`.

Use `allow()` to let everyone do it. Use `when(predicate)` to run a check on
the role:

```ts
const canManageOrganization = (ctx: PermissionsContext) =>
  ctx.actor.role === "owner" || ctx.actor.role === "admin";
```

TypeScript checks the two lists against each other. An action with no rule does
not build. A rule for an action that does not exist does not build either.

:::note
To add a role, widen `OrganizationRole` and the `organizationRoles` list next
to it. Better Auth stores the role as a string on the `member` row, so you need
no migration. But every `isOrganizationRole` call will start to accept the new
value.
:::

## Related

- [Authentication](/packages/authentication) — where a role is read from a session
- [Billing](/packages/billing) — `canManageBilling` and the `authorizeReference` hook
- [Database](/packages/database) — the `member` table that stores the role
