Skip to content

Roles & Permissions

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:

ValueDescription
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:

FieldTypeDescription
namestringDisplay name (e.g., “Program Director”)
slugstringURL-safe identifier. The owner slug has special meaning — it bypasses all checks
permissionsarrayList of permission keys granted to this role
is_defaultbooleanWhether this is a system-seeded role
sort_orderintegerDisplay 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') {
return true;
}

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:

KeyDescription
contacts.viewView contacts
contacts.createCreate contacts
contacts.editEdit contacts
contacts.deleteDelete contacts
KeyDescription
donations.viewView donations
donations.createRecord donations
donation_pages.viewView donation pages
donation_pages.createCreate donation pages
donation_pages.editEdit donation pages
KeyDescription
campaigns.viewView campaigns
campaigns.createCreate campaigns
campaigns.editEdit campaigns
campaigns.sendSend campaigns
sequences.viewView sequences
sequences.createCreate sequences
sequences.editEdit sequences
KeyDescription
scholarships.viewView scholarships
scholarships.createCreate scholarships
scholarships.reviewReview applications
KeyDescription
import.useImport data
KeyDescription
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) {
return true;
}
return false;
}
public function allPermissionKeys(): array
{
// Collects every permission key from config/permissions.php
}
}

New team members are invited via the TeamInvite model:

FieldDescription
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
  1. Owner calls POST /api/team/invite with email and role_id
  2. Miranda creates a Supabase account for the invitee (or updates their password if existing)
  3. A TeamInviteMail is sent with a join link containing the token and a temporary passphrase
  4. Invitee clicks the link and calls POST /api/team/invites/{token}/accept or POST /api/team/invites/{token}/accept-and-login
  5. 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)
MethodPathDescription
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