Authorization
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
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 returns
null instead of guessing.
The policy
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 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.
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 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:
- Add the name of your action to the
actionslist. - 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:
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.
Related
- Authentication — where a role is read from a session
- Billing —
canManageBillingand theauthorizeReferencehook - Database — the
membertable that stores the role