Roles & Permissions
Free · MITv0.1.0Organizations, memberships, email invitations and role-based permissions. Replaces the kernel's permission check with real global and per-organization roles that your RLS policies and can() share.
terminalnpx ajolla add rolesWhat you get
- Organizations with owner, admin and member roles plus custom roles
- Email invitations with single-use, expiring, hashed tokens
- Global app roles with a permissions editor at /admin/roles
- Wildcard permissions: exact, "forms.*" and "*"
- One ajolla_has_permission() shared by RLS, can() and the hosted API
- Last-owner protection enforced in the database
Routes it adds
/admin/roles/invites/[token]/orgs/orgs/[orgId]
Plus 1 Supabase migration with row-level security, and an AGENTS.md your coding agent reads after install.
Installs with
Kernel (added automatically).
Configuration
- NEXT_PUBLIC_SITE_URL (required)
- Public URL of your app (e.g. https://app.example.com), used to build invitation links. Defaults to http://localhost:3000 outside production.
Events
Other modules (and your own code) can react to these:
roles.org_createdroles.member_addedroles.member_removedroles.role_changedroles.invite_sentroles.invite_acceptednotifications.requested
Use it from any stack
Ajolla Hosted runs the same code as a multi-tenant API. Create an app in your dashboard, enable Roles & Permissions, and call /api/m/roles/v1 with your app key.
OpenAPI document — feed it to Ajolla to generate SDKs in 7 languages and an MCP server.
AGENTS.md
Read the instructions installed with this module
markdown# Ajolla Roles & Permissions
Organizations, memberships, email invitations and role-based permissions. It replaces the kernel's `ajolla_has_permission()` with real roles. Every module's RLS policies and `can()` / `requirePermission()` use it.
## What was installed
**Routes**
- `/orgs`: your organizations, and a form to create one (you become its owner).
- `/orgs/[orgId]`: members (change role, suspend, remove), invite by email, pending invitations, custom roles, rename, leave, and delete (owners only; you type the name to confirm).
- `/invites/[token]`: accept or decline an invitation. Requires sign-in, and the signed-in email must match the invited address (case-insensitive).
- `/admin/roles`: global roles with a permissions editor, plus granting them to users by email or user id. Requires `roles.manage`. Registered in `lib/ajolla/admin/roles.ts`.
**Code**
- `lib/ajolla/roles/core.ts`: framework-free logic (also used by Ajolla Hosted).
- `lib/ajolla/roles/permissions.ts`: the pure permission matcher and the built-in org roles.
- `components/ajolla/roles/*`: client forms.
- `app/orgs/server.ts`: `getRolesContext()` and `siteUrl()`.
**Tables** (`supabase/migrations/*_roles_0001_roles.sql`)
- `ajolla_roles_orgs`: name, unique slug, created_by.
- `ajolla_roles_roles`: global roles (`org_id` null) and per-org custom roles: key, name, description, `permissions text[]`.
- `ajolla_roles_members`: one row per (org, user): role key, status (`active` or `suspended`), and email.
- `ajolla_roles_invites`: email (lowercased), role, `token_hash` (sha256; the token itself is never stored), expires_at, accepted_at/by, declined_at.
- `ajolla_roles_user_roles`: global role grants.
**Events**: `roles.org_created`, `roles.member_added`, `roles.member_removed` (`reason: "removed" | "left"`), `roles.role_changed`, `roles.invite_sent` and `roles.invite_accepted`. Each invitation also emits `notifications.requested` (`category: "invites"`), which the Notifications module delivers as an email. The event carries the accept link, and that link contains the token.
**Env**: `NEXT_PUBLIC_SITE_URL`, the base for invitation links. Outside production it defaults to `http://localhost:3000`. Request headers are never used for links.
## How permissions work
- Permission strings look like `<module>.<action>`. A role may grant an exact string, `<module>.*` (every permission starting with `<module>.`) or `*`.
- **Global check** (`ajolla_has_permission('forms.manage')`): true if the user holds a global role that matches.
- **Org check** (`ajolla_has_permission('forms.manage', org_id)`): true if the user's *active* membership role in that org matches, or the global check passes.
- **Built-in org roles**:
- `owner` and `admin` hold `*` within the org.
- `member` holds nothing.
- Only owners (and global `roles.manage` holders) may delete the org or grant or remove the owner role.
- Every org keeps at least one active owner. The core and a database trigger both enforce this.
- **Custom org roles** (for example an `editor` with `cms.publish`) are added on the org page.
- **Bootstrap admin**: users whose `app_metadata.role` is `"admin"` hold every permission. This lets a fresh app name its first administrator:
```sql
update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}' where email = 'you@example.com';
```
They must sign in again. Then open `/admin/roles`, create an "Administrator" role with `*` (or `roles.manage`), grant it to yourself, and remove the flag.
## Setup
1. Apply migrations: `supabase db push`.
2. Set `NEXT_PUBLIC_SITE_URL` in production.
3. Keep **email confirmation enabled** in Supabase Auth. Invitations trust the email in the user's JWT.
4. Install Notifications (`npx ajolla add notifications`) to email invitations. Without it, the org page shows each new invitation link once so you can share it yourself.
5. Link `/orgs` from your navigation.
## Customising
- **Scope your own data by org**: `using (public.ajolla_has_permission('projects.edit', org_id))` in RLS, or `await can("projects.edit", orgId)` in pages and actions.
- **Change the built-in org roles**: edit `BUILT_IN_ORG_ROLES` in `permissions.ts` **and** `ajolla_roles_role_permissions()` in a new migration.
- **Add your own permissions to the role editor**: extend `KNOWN_PERMISSIONS`.
- **Deleting a user**: deleting the last owner's account leaves their org without an owner. An app admin can promote someone from `/orgs/[orgId]`.