Ability Definitions
The defineAbilityFor() function, type signatures, role-based permission switch, and personal org restrictions.
All CASL ability definitions live in packages/api/src/abilities/define-ability.ts. This is a single file containing one pure function and its supporting types.
Type Definitions
type Actions = "create" | "read" | "update" | "delete" | "manage";
type Subjects =
| "Organization"
| "Member"
| "Invitation"
| "ResearchPlan"
| "ResearchArtifact"
| "all";
type AppAbility = MongoAbility<[Actions, Subjects]>;
type OrgType = "personal" | "family" | "company";
Actions maps to CRUD operations plus manage (which is CASL's wildcard -- it matches any action). Subjects are PascalCase strings corresponding to domain entities, plus all (CASL's wildcard subject).
These are plain string unions, not typed CASL subjects. See CASL Decision for why typed subjects were not used.
The defineAbilityFor() Function
interface AbilityContext {
userId: string;
role: string;
orgType: OrgType;
}
function defineAbilityFor(ctx: AbilityContext): AppAbility;
This function is:
- Pure -- deterministic output, no database access, no side effects
- Isomorphic -- safe to call on server, client, or in tests
- The single source of truth for RBAC permissions
It takes a user's ID, their role within the organization, and the organization type, then returns a CASL ability object.
Role-Based Permission Switch
The function uses a switch on ctx.role to assign permissions:
switch (ctx.role) {
case "owner":
can("manage", "all");
break;
case "admin":
can("read", "Organization");
can("update", "Organization");
can("read", "Member");
can("create", "Member");
can("update", "Member");
can("delete", "Member");
can("manage", "Invitation");
break;
case "member":
can("read", "Organization");
can("read", "Member");
can("read", "Invitation");
can("create", "ResearchPlan");
can("read", "ResearchPlan");
can("update", "ResearchPlan");
can("create", "ResearchArtifact");
can("read", "ResearchArtifact");
can("update", "ResearchArtifact");
break;
default:
can("read", "Organization");
can("read", "Member");
break;
}
Owner gets manage all -- full access to everything. This is CASL's way of granting all actions on all subjects.
Admin can manage the organization (read/update but not delete), manage members (full CRUD), and manage invitations. Admins cannot delete the organization itself.
Member has read-only access to org metadata and membership, read-only access to invitations, and full CRUD on research plans and artifacts.
Default (unknown role) gets read-only access to organization and member data. This is the safe fallback -- a corrupted or unexpected role value degrades to minimal access rather than full access.
Personal Org Restrictions
After the role switch, a cannot() override denies collaboration features for personal organizations:
if (ctx.orgType === "personal") {
cannot("create", "Member");
cannot("manage", "Invitation");
}
In CASL, cannot() rules applied after can() rules override them. This means even an owner of a personal organization cannot create members or manage invitations. Personal orgs are single-user by design.
Exports
The packages/api/src/abilities/index.ts barrel re-exports everything needed by the rest of @repo/api:
export {
type Actions,
type AppAbility,
defineAbilityFor,
type OrgType,
type Subjects,
} from "./define-ability";
The AppAbility type is also re-exported from packages/api/src/trpc.ts so that consumer packages can reference it without importing from the abilities directory directly.
Where It Gets Called
defineAbilityFor() is called inside the authorizedProcedure middleware in packages/api/src/trpc.ts:
const ability = defineAbilityFor({
userId: ctx.session.user.id,
role: memberRecord.role,
orgType: (org?.type ?? "personal") as OrgType,
});
The middleware queries the member and organization tables (both within the RLS transaction from tenantProcedure), then passes the built ability into ctx.ability. For details on the full middleware chain, see Data & Storage -- Procedure Chain.
Adding a New Subject
- Add the subject string to the
Subjectstype union indefine-ability.ts - Add
can()rules for each role in theswitchblock - Add tests in
packages/api/src/abilities/__tests__/define-ability.test.ts - Use
ctx.ability.can("action", "NewSubject")in the router handler
Tests
CASL ability tests are in packages/api/src/abilities/__tests__/define-ability.test.ts. They are pure function tests with no database or network dependencies. The test file covers:
- Owner role: can manage everything
- Admin role: can read/update org, manage members, manage invitations, cannot delete org
- Member role: can read org/members/invitations, can CRUD research plans and artifacts, cannot manage members
- Personal org restrictions: owner and admin both denied member creation and invitation management
- Unknown role: read-only access to organization and member data
Run tests with pnpm test --filter @repo/api.