CloudRoleManager — Complete Manual
Table of Contents
1. Overview 2. Getting Started 3. Core Concepts 4. Authentication 5. The Dashboard UI 6. API Reference - Auth endpoints - Organisations - Projects - Org Units - Roles - Permissions - Users - Applications - Audit Log 7. JWT Token Structure 8. Integrating a Consumer App 9. Role Hierarchy & Inheritance 10. Permission Presets 11. Database Schema 12. Running & Deployment 13. Error Reference
Overview
CloudRoleManager (CRM) is a self-hosted, multi-tenant SaaS authorization service. It manages organisations, projects, users, roles, permissions, and org units. Consumer applications delegate authentication and authorization to CRM — they receive a signed JWT from CRM and verify it locally using a shared secret, or call the /introspect endpoint without a shared secret.
Architecture:
Browser / App
│
▼
Consumer App (e.g. TheSunburnedAge :5000)
│ proxies /api/v1/auth/* to CRM
▼
CloudRoleManager (:5001)
│
▼
PostgreSQL (cloud_role_manager DB)
Getting Started
Start the server
PGPASSWORD='<password>' \
DATABASE_URL="postgres://cloud_role_manager:x@127.0.0.1:5433/cloud_role_manager" \
JWT_SECRET=<your-secret> \
plackup bin/app.psgi --port 5001 --host 127.0.0.1
Or via the helper script:
bash start.sh
First-time setup (fresh database)
# Apply schema and seed superadmin
DATABASE_URL="postgres://..." PGPASSWORD="..." \
perl script/init_db.pl admin@example.com yourpassword
This creates:
- An organisation named
_superadmin - A user
admin_superadminwithis_superadmin = TRUE
Register your first organisation
POST /api/v1/auth/register with org_name, email, password.
On registration, every new organisation is automatically seeded with:
- A
defaultproject - Three roles:
viewer(level 10),editor(level 20),admin(level 100) in a hierarchy where admin inherits editor which inherits viewer - Eight starter permissions:
content:read,content:create,content:edit,content:delete,users:read,users:invite,reports:view,audit:read - Permissions distributed across the role chain
Core Concepts
Organisations
Top-level tenants. Each paying customer is one organisation. An org contains projects, users, and billing context.
Projects
Sub-units within an organisation (e.g. different apps or environments). Roles and permissions are scoped per project.
Users
People who belong to an organisation. A user can hold multiple roles across multiple projects.
Org Units
Optional groupings within a project (e.g. Finance, Engineering). Roles can be assigned to org units — two roles can share the same name if they're in different units.
Roles
Named groups assigned to users. Each role has a numeric level (higher = more privileged). Roles live in a nested-set hierarchy — a role inherits all permissions from its ancestors in the tree.
Level convention (recommended):
| Level | Typical meaning |
| 10 | Viewer / read-only |
| 20 | Editor / content writer |
| 50 | Manager |
| 100 | Admin |
Constraint: A role can only inherit from a role with a strictly lower level. A role cannot be its own parent or a descendant's parent (cycle detection).
Permissions
Named capabilities scoped to a project (e.g. content:read, users:invite). Assigned to roles. A role's effective permissions = its own permissions ∪ all ancestor permissions via the nested-set tree.
Refresh Tokens
Long-lived opaque tokens (30 days) stored server-side by hash. Used to get new access tokens without re-login. Revocable on logout.
Access Tokens
Short-lived JWTs (15 minutes). Contain the full projects/roles/permissions payload so consumer apps can do authorization locally without a DB call.
Authentication
All protected endpoints require:
Authorization: Bearer <access_token>
The token is obtained via POST /api/v1/auth/login and refreshed via POST /api/v1/auth/refresh.
Token lifecycle
login ──► access_token (15 min) + refresh_token (30 days)
│
▼ expires
POST /auth/refresh ──► new access_token
│
▼ user logs out
POST /auth/logout ──► refresh_token revoked
The Dashboard UI
Open http://localhost:5001 in a browser.
Tabs (visible based on role)
| Tab | Who can see it | Purpose |
| Organisations | Superadmin only | View all orgs, activate/deactivate |
| Projects | All logged-in users | Create and manage projects within your org |
| Org Units | All logged-in users | Create named units (Finance, Engineering, etc.) to group roles |
| Roles | All logged-in users | Create roles, set levels, assign to org units, set inheritance via nested-set |
| Users | All logged-in users | Create users, assign roles per project |
| Permissions | All logged-in users | Create permissions, import from presets, assign to roles |
| Audit Log | All logged-in users | Read-only paginated log of all mutations |
Logging in
Use the username (or email) and password set at registration or reset via the admin.
Registering a new organisation
Click Register on the login page. Enter org name, admin email, and password. The org is immediately seeded with starter roles and permissions.
API Reference
All endpoints are prefixed with /api/v1. All request and response bodies are application/json.
Error response shape
All errors return:
{ "error": "Human-readable message", "code": 400 }
Auth Endpoints
POST /api/v1/auth/register
Register a new organisation. Creates the org, a default project, an admin user, and starter roles/permissions.
No auth required.
Request:
{
"org_name": "Acme Corp",
"email": "admin@acme.com",
"password": "secret123"
}
Response 201:
{
"org_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"user_id": 6,
"message": "Organisation registered successfully"
}
| Code | Reason |
| 400 | Missing fields or invalid email |
| 409 | Org name or email already exists |
POST /api/v1/auth/login
Authenticate and receive tokens.
No auth required.
Request:
{
"username": "alice",
"password": "secret123"
}
username may be a username or email address.
Response 200:
{
"access_token": "eyJ...",
"refresh_token": "58a5ccb4...",
"user": {
"id": 2,
"username": "alice",
"email": "alice@acme.com",
"org_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}
| Code | Reason |
| 400 | Missing username or password |
| 401 | Invalid credentials or inactive account |
POST /api/v1/auth/refresh
Exchange a valid refresh token for a new access token.
No auth required.
Request:
{ "refresh_token": "58a5ccb4..." }
Response 200:
{ "access_token": "eyJ..." }
| Code | Reason |
| 401 | Invalid, expired, or revoked refresh token |
POST /api/v1/auth/logout
Revoke the refresh token server-side.
Auth required.
Request:
{ "refresh_token": "58a5ccb4..." }
Response 200:
{ "ok": true }
GET /api/v1/auth/me
Return the decoded payload of the current access token.
Auth required.
Response 200:
{
"id": 2,
"username": "alice",
"email": "alice@acme.com",
"org_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"superadmin": false,
"projects": [
{
"pid": 1,
"name": "default",
"roles": [
{ "name": "admin", "level": 100 }
],
"permissions": ["content:read", "users:invite", "audit:read"]
}
]
}
POST /api/v1/auth/introspect
Verify a token from another service without sharing JWT_SECRET. Returns the decoded payload if valid.
No auth required. (The token to verify is in the body, not the header.)
Request:
{ "token": "eyJ..." }
Response 200 (valid token):
{
"active": true,
"sub": 2,
"usr": "alice",
"email": "alice@acme.com",
"oid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"superadmin": false,
"projects": [...],
"iat": 1786015816,
"exp": 1786016716
}
Response 401 (invalid token):
{ "active": false, "code": 401 }
Organisations (Superadmin)
GET /api/v1/superadmin/orgs
List all organisations with user and project counts.
Superadmin only.
Response 200:
[
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "_superadmin",
"is_active": true,
"created_at": "2026-08-06T10:00:00Z",
"user_count": 1,
"project_count": 0
}
]
PUT /api/v1/superadmin/orgs/:id
Update an organisation's name or active status.
Superadmin only.
Request (all fields optional, at least one required):
{
"name": "New Name",
"is_active": false
}
Response 200: { "ok": true }
| Code | Reason |
| 400 | No updatable fields |
| 404 | Organisation not found |
Projects
GET /api/v1/admin/projects
List all projects for the authenticated user's organisation.
Auth required.
Response 200:
[
{
"id": 1,
"org_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "default",
"is_active": true,
"created_at": "2026-08-06T10:00:00Z"
}
]
POST /api/v1/admin/projects
Create a new project.
Auth required.
Request:
{ "name": "staging" }
Response 201:
{
"id": 3,
"org_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "staging",
"is_active": true,
"created_at": "2026-08-06T11:00:00Z"
}
| Code | Reason |
| 400 | Missing name |
| 409 | Project name already exists in this org |
DELETE /api/v1/admin/projects/:id
Deactivate a project (soft delete — sets is_active = false).
Auth required.
Response 200: { "ok": true }
Org Units
Org units group roles within a project (e.g. Finance, Engineering). Two roles can share the same name if they're in different units.
GET /api/v1/admin/projects/:pid/units
Response 200:
[
{
"id": 1,
"name": "Finance",
"description": "Financial operations",
"created_at": "2026-08-06T10:00:00Z",
"role_count": 3
}
]
POST /api/v1/admin/projects/:pid/units
Request:
{
"name": "Engineering",
"description": "Software development team"
}
Response 201:
{ "id": 2, "name": "Engineering", "description": "...", "role_count": 0 }
PUT /api/v1/admin/projects/:pid/units/:id
Request (at least one field):
{ "name": "People & Culture", "description": "HR department" }
Response 200: { "ok": true }
DELETE /api/v1/admin/projects/:pid/units/:id
Deletes the unit. Roles in this unit have their unit_id set to NULL (they are not deleted).
Response 200: { "ok": true }
Roles
Roles are hierarchical via a nested-set tree. A role inherits all permissions from its ancestors. The hierarchy is manipulated by setting parent_role_id — lft/rgt values are maintained automatically.
GET /api/v1/admin/projects/:pid/roles
Response 200:
[
{
"id": 10,
"name": "editor",
"unit_id": null,
"unit_name": null,
"level": 20,
"description": "Can create and edit content",
"lft": 2,
"rgt": 5,
"parent_role_id": 11,
"parent_role_name": "viewer",
"permissions": [
{ "id": "uuid...", "name": "content:create" }
],
"effective_permissions": [
{ "id": "uuid...", "name": "content:create" },
{ "id": "uuid...", "name": "content:read" }
]
}
]
effective_permissions = own permissions + all ancestor permissions via nested-set.
POST /api/v1/admin/projects/:pid/roles
Request:
{
"name": "moderator",
"level": 25,
"unit_id": 1,
"description": "Content moderator",
"parent_role_id": 11
}
level— required, numeric. Parent must have strictly lower level.unit_id— optional. Assign to an org unit.parent_role_id— optional. Move under this role in the hierarchy immediately on creation.
Response 201:
{
"id": 15,
"name": "moderator",
"unit_id": 1,
"level": 25,
"description": "Content moderator",
"permissions": [],
"effective_permissions": []
}
| Code | Reason |
| 400 | Missing name/level, invalid parent level |
| 409 | Role name already exists in this unit/project |
PUT /api/v1/admin/projects/:pid/roles/:id
Update role fields and/or move it in the hierarchy.
Request (all fields optional):
{
"name": "senior-editor",
"level": 30,
"unit_id": 2,
"description": "Updated description",
"parent_role_id": 11
}
Setting parent_role_id: null moves the role to the tree root (no inheritance).
Constraints on parent_role_id:
- Cannot be the role itself
- Cannot be a descendant (cycle detection)
- Must have a strictly lower level than this role
Response 200: { "ok": true }
DELETE /api/v1/admin/projects/:pid/roles/:id
Delete a role. Users assigned this role lose it. Cascades clean up user_roles, role_permissions, and role_hierarchy automatically.
Response 200: { "ok": true }
POST /api/v1/admin/projects/:pid/roles/:id/permissions/:perm_id
Assign a permission to a role.
perm_id must be a valid UUID belonging to the same project.
Response 200: { "ok": true }
DELETE /api/v1/admin/projects/:pid/roles/:id/permissions/:perm_id
Remove a permission from a role.
Response 200: { "ok": true }
Permissions
GET /api/v1/admin/projects/:pid/permissions
Response 200:
[
{
"id": "433ace07-913d-4663-ba6a-c470ad8905ab",
"name": "content:read",
"description": "View published content",
"active": true,
"created_at": "2026-08-06T10:00:00Z",
"updated_at": "2026-08-06T10:00:00Z"
}
]
POST /api/v1/admin/projects/:pid/permissions
Request:
{
"name": "reports:export",
"description": "Export report data"
}
Response 201:
{
"id": "uuid...",
"name": "reports:export",
"description": "Export report data",
"active": true
}
DELETE /api/v1/admin/projects/:pid/permissions/:id
Delete a permission. Automatically removed from all roles that held it.
Response 200: { "ok": true }
GET /api/v1/admin/projects/:pid/permissions/presets
Get the catalogue of standard permissions, with each one marked as already existing in this project or not.
Response 200:
[
{
"category": "Content",
"description": "Read, write, publish and delete content items",
"permissions": [
{ "name": "content:read", "description": "View published content", "exists": true },
{ "name": "content:create", "description": "Create new content", "exists": true },
{ "name": "content:publish", "description": "Publish or unpublish content", "exists": false }
]
}
]
Available preset categories:
| Category | Permissions |
| Content | content:read, content:create, content:edit, content:delete, content:publish |
| Users | users:read, users:invite, users:edit, users:delete |
| Roles | roles:read, roles:manage, roles:assign |
| Settings | settings:read, settings:edit, billing:read, billing:manage |
| API | api:read, api:write, api:keys |
| Reports | reports:view, reports:export, audit:read |
| Media | media:read, media:upload, media:delete |
POST /api/v1/admin/projects/:pid/permissions/presets
Bulk-import permissions from the preset catalogue. Idempotent — already-existing permissions are skipped.
Request:
{
"names": ["api:read", "api:write", "media:upload"]
}
Response 201:
{
"created": [
{ "id": "uuid...", "name": "api:read", "description": "Make read-only API calls" },
{ "id": "uuid...", "name": "api:write", "description": "Make write API calls" },
{ "id": "uuid...", "name": "media:upload","description": "Upload new media assets" }
],
"skipped": [],
"unknown": []
}
skipped = names that already exist. unknown = names not in the catalogue.
Users
GET /api/v1/admin/users
List all users in the authenticated org with their per-project role assignments.
Response 200:
[
{
"id": 2,
"email": "alice@acme.com",
"username": "alice",
"is_active": true,
"is_superadmin": false,
"created_at": "2026-08-06T10:00:00Z",
"roles": [
{
"project_id": 1,
"project_name": "default",
"role_name": "admin",
"role_level": 100
}
]
}
]
POST /api/v1/admin/users
Create a new user in the org.
Request:
{
"email": "bob@acme.com",
"username": "bob",
"password": "secret123",
"role_ids": [10, 11]
}
username defaults to the part of the email before @ if not provided. role_ids are optional initial role assignments.
Response 201:
{ "id": 7, "email": "bob@acme.com", "username": "bob" }
PUT /api/v1/admin/users/:id
Update a user's password or active status.
Request (at least one field):
{
"is_active": false,
"password": "newpassword"
}
Response 200: { "ok": true }
DELETE /api/v1/admin/users/:id
Deactivate a user (soft delete — sets is_active = false).
Response 200: { "ok": true }
POST /api/v1/admin/users/:id/roles/:role_id
Assign a role to a user. The role must belong to a project within the user's org.
Response 200: { "ok": true }
DELETE /api/v1/admin/users/:id/roles/:role_id
Revoke a role from a user.
Response 200: { "ok": true }
Applications
GET /api/v1/admin/applications/:id/project
Get the project link details for an application, including the JWT seed and tenant ID.
Auth required.
Response 200:
{
"application_id": 3,
"project_id": 1,
"tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"jwt_seed": "a3f8c2d1e4b7098f2a6c4d8e1f3b5a7c",
"jwt_secret": "9f2a6c4d8e1f3b5a7c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4",
"is_active": true,
"created_at": "2026-08-09T10:00:00Z"
}
Note: jwt_secret is always visible here. jwt_seed is also shown here, but is only displayed once in the UI at creation time — if lost, rotate it via the rotate-seed endpoint.
POST /api/v1/admin/applications/:id/project
Link a project to an application. Returns jwt_seed and tenant_id in the response — jwt_seed is shown only this once in the UI; store it immediately.
Auth required.
Request:
{
"project_id": 1
}
Response 201:
{
"application_id": 3,
"project_id": 1,
"tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"jwt_seed": "a3f8c2d1e4b7098f2a6c4d8e1f3b5a7c",
"jwt_secret": "9f2a6c4d8e1f3b5a7c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4",
"is_active": true,
"created_at": "2026-08-09T10:00:00Z"
}
| Code | Reason |
| 400 | Missing project_id |
| 404 | Application or project not found |
| 409 | Application already linked to a project |
POST /api/v1/admin/applications/:id/project/rotate-seed
Rotate the jwt_seed for an application. The old seed is immediately invalidated — the consumer app must update its configuration with the new seed before project logins will work again. The rotation is logged as seed_rotated in the audit changelog.
Auth required.
No request body.
Response 200:
{
"jwt_seed": "b7e2a5f3c1d0984e3b7c5d9f1e3a7b5c"
}
| Code | Reason |
| 404 | Application not found or not linked to a project |
Audit Log
The audit log is immutable — database-level triggers prevent any UPDATE or DELETE. It records every mutating action. The log is strictly tenant-isolated: each query filters by org_id using the UUID from the authenticated user's JWT oid claim, so users can only ever see audit entries belonging to their own organisation.
GET /api/v1/admin/audit
Query parameters:
page(integer, default 1)per_page(integer, default 50, max 200)
Response 200:
{
"data": [
{
"id": 42,
"org_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"project_id": 1,
"actor_user_id": 3,
"actor_username": "alice",
"action": "role.permission.assign",
"target_type": "role",
"target_id": "10",
"detail": { "permission_id": "433ace07-..." },
"created_at": "2026-08-06T12:34:56Z"
}
],
"total": 104,
"page": 1,
"per_page": 50
}
Action values logged: org.register, org.update, project.create, project.deactivate, user.login, user.logout, user.create, user.update, user.deactivate, user.role.assign, user.role.revoke, role.create, role.update, role.delete, role.permission.assign, role.permission.revoke, permission.create, permission.delete, permission.preset_import, org_unit.create, org_unit.update, org_unit.delete, seed_rotated
JWT Token Structure
The access token is a signed HS256 JWT. Payload:
{
"sub": 2,
"usr": "alice",
"email": "alice@acme.com",
"oid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"superadmin": false,
"projects": [
{
"pid": 1,
"name": "default",
"roles": [
{ "name": "admin", "level": 100 }
],
"permissions": [
"content:read",
"content:create",
"users:invite",
"audit:read"
]
}
],
"iat": 1786015816,
"exp": 1786016716
}
| Field | Type | Description |
sub | integer | User ID |
usr | string | Username |
email | string | Email address |
oid | string (UUID) | Organisation UUID |
superadmin | boolean | Whether the user is a platform superadmin |
projects | array | All projects the user has a role in, with effective permissions |
iat | integer | Issued-at Unix timestamp |
exp | integer | Expiry Unix timestamp (15 min after iat) |
permissions in each project entry contains the effective permissions — the union of the role's own permissions and all permissions inherited from ancestor roles in the nested-set hierarchy.
Integrating a Consumer App
Option A — Shared JWT secret (recommended for trusted services)
The consumer app validates JWTs locally using the shared JWT_SECRET. No network call per request.
Perl example:
use Crypt::JWT qw(decode_jwt);
sub verify {
my ($token) = @_;
my $payload = eval { decode_jwt(token=>$token, key=>$ENV{JWT_SECRET}, accepted_alg=>'HS256') };
return undef if $@ || ($payload->{exp}//0) < time();
return $payload;
}
# Check permission
sub has_permission {
my ($payload, $project_id, $perm_name) = @_;
for my $p (@{ $payload->{projects} // [] }) {
next unless $p->{pid} == $project_id;
return 1 if grep { $_ eq $perm_name } @{ $p->{permissions} // [] };
}
return 0;
}
JavaScript example:
// Decode without verification (signature already checked server-side)
function decodeJwt(token) {
const payload = token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/');
return JSON.parse(atob(payload));
}
function hasPermission(token, projectId, permName) {
const user = decodeJwt(token);
const proj = (user.projects || []).find(p => p.pid === projectId);
return proj ? (proj.permissions || []).includes(permName) : false;
}
Option B — Introspect endpoint (no shared secret)
curl -X POST http://localhost:5001/api/v1/auth/introspect \
-H 'Content-Type: application/json' \
-d '{"token": "eyJ..."}'
Returns { "active": true, ...payload } or { "active": false }.
Application credentials
Each application registered in CRM is identified by a jwt_seed + tenant_id (UUID) pair. These two values together uniquely identify the application and determine which organisation's JWT signing secret is used. A separate jwt_secret is also associated with each application and is used to sign the tokens it receives.
jwt_seed— a 32-char hex string. Shown only once at creation time (treat it like a password). Obtain it fromPOST /api/v1/admin/applications/:id/projectat link time.tenant_id— the UUID of the organisation this application belongs to.jwt_secret— the signing secret for this application's tokens. Can be viewed at any time viaGET /api/v1/admin/applications/:id/project.
Store all three values in the consumer app's configuration.
Proxying auth routes
Consumer apps can proxy /api/v1/auth/* to CRM so users log in against the consumer app's URL. For project apps, use POST /api/v1/auth/project/login and include jwt_seed + tenant_id to identify the application:
# In consumer app Routes/Auth.pm
post '/api/v1/auth/project/login' => sub {
my $body = from_json(request->body);
# Inject the application credentials from config
$body->{jwt_seed} = config->{crm_jwt_seed};
$body->{tenant_id} = config->{crm_tenant_id};
my $ua = HTTP::Tiny->new;
my $res = $ua->post('http://localhost:5001/api/v1/auth/project/login',
{ content => to_json($body),
headers => {'Content-Type'=>'application/json'} });
status $res->{status};
return $res->{content};
};
The request body sent to CRM must include:
{
"username": "alice",
"password": "secret123",
"jwt_seed": "<32-char hex from CRM>",
"tenant_id": "<organisation UUID>"
}
Handling token refresh in the frontend
async function apiCall(method, url, body, token) {
let res = await fetch(url, {
method,
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 401) {
// Try refresh
const rt = localStorage.getItem('refresh_token');
const refreshRes = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: rt })
});
if (refreshRes.ok) {
const { access_token } = await refreshRes.json();
localStorage.setItem('access_token', access_token);
// Retry original request
res = await fetch(url, {
method,
headers: { 'Authorization': `Bearer ${access_token}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
}
}
return res.json();
}
Role Hierarchy & Inheritance
Roles are organised in a nested-set tree per project. The tree encodes the inheritance chain — a role at position (lft, rgt) inherits permissions from all roles where ancestor.lft < role.lft AND ancestor.rgt > role.rgt.
Example tree
viewer (10) lft=1 rgt=6
└─ editor (20) lft=2 rgt=5
└─ admin (100) lft=3 rgt=4
In this tree:
admininherits fromeditorandviewer→ gets all their permissionseditorinherits fromviewer→ gets viewer's permissionsvieweris a root → only its own permissions
Setting inheritance
Use PUT /api/v1/admin/projects/:pid/roles/:id with parent_role_id:
{ "parent_role_id": 11 }
- Set to
nullto make the role a root node (no inheritance) - The system validates: no cycles, parent level must be strictly lower
Rules
1. A role cannot be its own parent 2. A role cannot inherit from a descendant (cycle detection) 3. Parent level must be strictly less than child level (e.g. parent=10, child=20 ✓; parent=20, child=10 ✗)
Permission Presets
The preset catalogue provides standard permission names grouped by category. Use it to quickly populate a new project.
Via UI
Go to Permissions tab → click ⚡ Import presets → check the permissions you want → click Import.
Via API
1. See what's available and what's already in your project:
GET /api/v1/admin/projects/1/permissions/presets
2. Import selected permissions:
POST /api/v1/admin/projects/1/permissions/presets
{ "names": ["api:read", "api:write", "reports:view"] }
Database Schema
organisations
| Column | Type | Note |
| uuid | UUID | PK, DEFAULT gen_random_uuid() |
| name | TEXT | UNIQUE |
| is_active | BOOLEAN | DEFAULT TRUE |
| created_at | TIMESTAMPTZ | DEFAULT NOW() |
projects
| Column | Type | Note |
| id | SERIAL | PK |
| org_id | UUID | FK→organisations(uuid), CASCADE |
| name | TEXT | UNIQUE per org |
| is_active | BOOLEAN | DEFAULT TRUE |
| created_at | TIMESTAMPTZ |
users
| Column | Type | Note |
| id | SERIAL | PK |
| org_id | UUID | FK→organisations(uuid), CASCADE |
| TEXT | UNIQUE | |
| username | TEXT | UNIQUE |
| password_hash | TEXT | bcrypt, cost 12 |
| is_active | BOOLEAN | DEFAULT TRUE |
| is_superadmin | BOOLEAN | DEFAULT FALSE |
| created_at | TIMESTAMPTZ |
org_units
| Column | Type | Note |
| id | SERIAL | PK |
| project_id | INTEGER | FK→projects, CASCADE |
| name | TEXT | UNIQUE per project |
| description | TEXT | |
| created_at | TIMESTAMPTZ |
roles
| Column | Type | Note |
| id | SERIAL | PK |
| project_id | INTEGER | FK→projects, CASCADE |
| unit_id | INTEGER | FK→org_units, SET NULL |
| name | TEXT | UNIQUE per (project, unit) |
| level | INTEGER | DEFAULT 0 |
| description | TEXT |
user_roles
| Column | Type | Note |
| user_id | INTEGER | FK→users, CASCADE |
| role_id | INTEGER | FK→roles, CASCADE |
| project_id | INTEGER | FK→projects, CASCADE (denormalised for query performance) |
| PK (user_id, role_id) |
permissions
| Column | Type | Note |
| permission_id | UUID | PK, gen_random_uuid() |
| project_id | INTEGER | FK→projects, CASCADE |
| name | VARCHAR(255) | UNIQUE per project |
| description | TEXT | |
| active | BOOLEAN | DEFAULT TRUE |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ |
role_permissions
| Column | Type | Note |
| role_id | INTEGER | FK→roles, CASCADE |
| permission_id | UUID | FK→permissions, CASCADE |
| PK (role_id, permission_id) |
role_hierarchy
| Column | Type | Note |
| role_id | INTEGER | PK, FK→roles, CASCADE |
| lft | SMALLINT | Nested-set left bound |
| rgt | SMALLINT | Nested-set right bound |
Auto-populated by trigger after_role_insert when a role is created. Updated by move_under() when inheritance changes.
refresh_tokens
| Column | Type | Note |
| id | UUID | PK |
| user_id | INTEGER | FK→users, CASCADE |
| token_hash | TEXT | SHA-256 of raw token |
| expires_at | TIMESTAMPTZ | NOW() + 30 days |
| revoked_at | TIMESTAMPTZ | NULL = active |
| created_at | TIMESTAMPTZ |
application_projects
| Column | Type | Note |
| id | SERIAL | PK |
| application_id | INTEGER | FK→applications, CASCADE |
| project_id | INTEGER | FK→projects, CASCADE |
| jwt_secret | TEXT | NOT NULL, DEFAULT encode(gen_random_bytes(32),'hex') |
| jwt_seed | TEXT | NOT NULL, DEFAULT encode(gen_random_bytes(16),'hex') |
| tenant_id | UUID | NOT NULL, FK→organisations(uuid), CASCADE |
| is_active | BOOLEAN | DEFAULT TRUE |
| created_at | TIMESTAMPTZ |
UNIQUE constraint on (jwt_seed, tenant_id).
audit_log
| Column | Type | Note |
| id | BIGSERIAL | PK |
| org_id | UUID | FK→organisations(uuid), SET NULL |
| project_id | INTEGER | FK→projects, SET NULL |
| actor_user_id | INTEGER | FK→users, SET NULL |
| action | TEXT | e.g. "role.create" |
| target_type | TEXT | e.g. "role" |
| target_id | TEXT | ID of affected record |
| detail | JSONB | Extra context |
| created_at | TIMESTAMPTZ |
Immutable: Database triggers audit_log_no_update and audit_log_no_delete block all modifications.
Running & Deployment
Environment variables
| Variable | Required | Description |
DATABASE_URL | Yes | postgres://user:x@host:port/db |
PGPASSWORD | Yes | PostgreSQL password (avoids special chars in URL) |
JWT_SECRET | Yes | HS256 signing key — keep secret, share with consumer apps |
CRM_CORS_ORIGIN | No | CORS allowed origin, defaults to * |
Docker
docker build -t cloud-role-manager .
docker run -p 5001:5001 \
-e DATABASE_URL="postgres://cloud_role_manager:x@db:5432/cloud_role_manager" \
-e PGPASSWORD="yourpassword" \
-e JWT_SECRET="yoursecret" \
cloud-role-manager
Init/seed (first time)
PGPASSWORD=... DATABASE_URL=postgres://... \
perl script/init_db.pl admin@example.com yourpassword
Migrate data from SQLite
SQLITE_URL=sqlite:///path/to/crm.db \
PG_URL=postgres://user:x@host:port/db \
PGPASSWORD=... \
perl script/migrate_sqlite_to_pg.pl [--force]
--force truncates existing Pg data before migrating.
Error Reference
| Code | Meaning |
| 400 | Bad request — missing required field, invalid ID format, or constraint violation (e.g. parent level too high) |
| 401 | Unauthorized — missing, expired, or invalid token |
| 403 | Forbidden — token is valid but the user lacks the required role (e.g. non-superadmin on a superadmin route) |
| 404 | Not found — the resource doesn't exist or belongs to a different org |
| 409 | Conflict — uniqueness constraint violation (duplicate name, email, etc.) |
| 500 | Internal server error — check server logs |
All error responses have the shape:
{ "error": "Human-readable description", "code": 400 }