Miranda uses a two-tier permission system: a legacy UserRole enum on the User model for base access level, and a foundation-scoped Role model with granular permissions for fine-grained control.
Every User record has a role field using the UserRole enum:
Value Description ownerFull access — billing, account deletion, team management. Bypasses all permission checks. adminFull operational access, cannot manage billing or delete the foundation memberAccess governed by their assigned Role model and its permissions array readonlyView-only access across the foundation
The legacy enum is being superseded by the Role model but remains on all User records for backward compatibility.
Foundations define custom roles via the Role model. Each role is scoped to a foundation and contains:
Field Type Description namestring Display name (e.g., “Program Director”) slugstring URL-safe identifier. The owner slug has special meaning — it bypasses all checks permissionsarray List of permission keys granted to this role is_defaultboolean Whether this is a system-seeded role sort_orderinteger Display ordering
When a foundation’s roles are first accessed (via GET /api/team/roles), Miranda seeds default roles based on the foundation’s org_type from config/org-types.php. The Owner role is always created first with an empty permissions array (owners bypass all checks).
// The Owner role always bypasses permission checks
if ( $user -> teamRole -> slug === ' owner ' ) {
Users are assigned a role via the role_id foreign key on the User model. When a user is invited, they receive the role specified in the invitation.
Permissions are defined in config/permissions.php and organized into groups:
Key Description contacts.viewView contacts contacts.createCreate contacts contacts.editEdit contacts contacts.deleteDelete contacts
Key Description donations.viewView donations donations.createRecord donations donation_pages.viewView donation pages donation_pages.createCreate donation pages donation_pages.editEdit donation pages
Key Description campaigns.viewView campaigns campaigns.createCreate campaigns campaigns.editEdit campaigns campaigns.sendSend campaigns sequences.viewView sequences sequences.createCreate sequences sequences.editEdit sequences
Key Description scholarships.viewView scholarships scholarships.createCreate scholarships scholarships.reviewReview applications
Key Description import.useImport data
Key Description settings.viewView settings settings.editEdit settings billing.manageManage billing team.viewView team members team.inviteInvite team members team.removeRemove team members
The RolePermissionService handles all permission checks:
class RolePermissionService
public function can ( User $user , string $permission ) : bool
// New Role system: check role_id -> teamRole -> permissions array
if ( $user -> role_id && $user -> teamRole ) {
if ( $user -> teamRole -> slug === ' owner ' ) {
return true ; // Owner bypasses all checks
return in_array ($ permission , $ user -> teamRole -> permissions ?? [], true );
// Legacy: Owner enum always has access
if ( $user -> role === UserRole :: Owner ) {
public function allPermissionKeys () : array
// Collects every permission key from config/permissions.php
New team members are invited via the TeamInvite model:
Field Description emailInvitee’s email address role_idThe Role to assign when accepted token64-character random token (serves as invite URL auth) expires_atInvitation expiry (7 days from creation) accepted_atNull until accepted
Owner calls POST /api/team/invite with email and role_id
Miranda creates a Supabase account for the invitee (or updates their password if existing)
A TeamInviteMail is sent with a join link containing the token and a temporary passphrase
Invitee clicks the link and calls POST /api/team/invites/{token}/accept or POST /api/team/invites/{token}/accept-and-login
A User record is created in the foundation with the specified role
Cannot invite someone who is already a team member
Cannot send a duplicate invite to the same email while one is pending
Cannot modify or delete the owner role
Cannot change an owner’s role or remove an owner
Cannot remove yourself from the team
Deleting a role unassigns all users from it (sets role_id to null)
Method Path Description GET /api/teamList team members and pending invites GET /api/team/rolesList foundation roles (seeds defaults if empty) POST /api/team/rolesCreate a custom role PUT /api/team/roles/{role}Update role name and permissions DELETE /api/team/roles/{role}Delete a role POST /api/team/inviteSend a team invitation DELETE /api/team/invites/{invite}Cancel a pending invite PUT /api/team/members/{user}/roleChange a member’s role DELETE /api/team/members/{user}Remove a team member GET /api/team/permissionsList all permission groups and keys