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:

Register your first organisation

POST /api/v1/auth/register with org_name, email, password.

On registration, every new organisation is automatically seeded with:


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

LevelTypical meaning
10Viewer / read-only
20Editor / content writer
50Manager
100Admin

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)

TabWho can see itPurpose
OrganisationsSuperadmin onlyView all orgs, activate/deactivate
ProjectsAll logged-in usersCreate and manage projects within your org
Org UnitsAll logged-in usersCreate named units (Finance, Engineering, etc.) to group roles
RolesAll logged-in usersCreate roles, set levels, assign to org units, set inheritance via nested-set
UsersAll logged-in usersCreate users, assign roles per project
PermissionsAll logged-in usersCreate permissions, import from presets, assign to roles
Audit LogAll logged-in usersRead-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"
}
CodeReason
400Missing fields or invalid email
409Org 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"
  }
}
CodeReason
400Missing username or password
401Invalid 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..." }
CodeReason
401Invalid, 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 }

CodeReason
400No updatable fields
404Organisation 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"
}
CodeReason
400Missing name
409Project 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
}

Response 201:

{
  "id": 15,
  "name": "moderator",
  "unit_id": 1,
  "level": 25,
  "description": "Content moderator",
  "permissions": [],
  "effective_permissions": []
}
CodeReason
400Missing name/level, invalid parent level
409Role 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:

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:

CategoryPermissions
Contentcontent:read, content:create, content:edit, content:delete, content:publish
Usersusers:read, users:invite, users:edit, users:delete
Rolesroles:read, roles:manage, roles:assign
Settingssettings:read, settings:edit, billing:read, billing:manage
APIapi:read, api:write, api:keys
Reportsreports:view, reports:export, audit:read
Mediamedia: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"
}
CodeReason
400Missing project_id
404Application or project not found
409Application 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"
}
CodeReason
404Application 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:

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
}
FieldTypeDescription
subintegerUser ID
usrstringUsername
emailstringEmail address
oidstring (UUID)Organisation UUID
superadminbooleanWhether the user is a platform superadmin
projectsarrayAll projects the user has a role in, with effective permissions
iatintegerIssued-at Unix timestamp
expintegerExpiry 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

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.

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:

Setting inheritance

Use PUT /api/v1/admin/projects/:pid/roles/:id with parent_role_id:

{ "parent_role_id": 11 }

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

ColumnTypeNote
uuidUUIDPK, DEFAULT gen_random_uuid()
nameTEXTUNIQUE
is_activeBOOLEANDEFAULT TRUE
created_atTIMESTAMPTZDEFAULT NOW()

projects

ColumnTypeNote
idSERIALPK
org_idUUIDFK→organisations(uuid), CASCADE
nameTEXTUNIQUE per org
is_activeBOOLEANDEFAULT TRUE
created_atTIMESTAMPTZ

users

ColumnTypeNote
idSERIALPK
org_idUUIDFK→organisations(uuid), CASCADE
emailTEXTUNIQUE
usernameTEXTUNIQUE
password_hashTEXTbcrypt, cost 12
is_activeBOOLEANDEFAULT TRUE
is_superadminBOOLEANDEFAULT FALSE
created_atTIMESTAMPTZ

org_units

ColumnTypeNote
idSERIALPK
project_idINTEGERFK→projects, CASCADE
nameTEXTUNIQUE per project
descriptionTEXT
created_atTIMESTAMPTZ

roles

ColumnTypeNote
idSERIALPK
project_idINTEGERFK→projects, CASCADE
unit_idINTEGERFK→org_units, SET NULL
nameTEXTUNIQUE per (project, unit)
levelINTEGERDEFAULT 0
descriptionTEXT

user_roles

ColumnTypeNote
user_idINTEGERFK→users, CASCADE
role_idINTEGERFK→roles, CASCADE
project_idINTEGERFK→projects, CASCADE (denormalised for query performance)
PK (user_id, role_id)

permissions

ColumnTypeNote
permission_idUUIDPK, gen_random_uuid()
project_idINTEGERFK→projects, CASCADE
nameVARCHAR(255)UNIQUE per project
descriptionTEXT
activeBOOLEANDEFAULT TRUE
created_atTIMESTAMPTZ
updated_atTIMESTAMPTZ

role_permissions

ColumnTypeNote
role_idINTEGERFK→roles, CASCADE
permission_idUUIDFK→permissions, CASCADE
PK (role_id, permission_id)

role_hierarchy

ColumnTypeNote
role_idINTEGERPK, FK→roles, CASCADE
lftSMALLINTNested-set left bound
rgtSMALLINTNested-set right bound

Auto-populated by trigger after_role_insert when a role is created. Updated by move_under() when inheritance changes.

refresh_tokens

ColumnTypeNote
idUUIDPK
user_idINTEGERFK→users, CASCADE
token_hashTEXTSHA-256 of raw token
expires_atTIMESTAMPTZNOW() + 30 days
revoked_atTIMESTAMPTZNULL = active
created_atTIMESTAMPTZ

application_projects

ColumnTypeNote
idSERIALPK
application_idINTEGERFK→applications, CASCADE
project_idINTEGERFK→projects, CASCADE
jwt_secretTEXTNOT NULL, DEFAULT encode(gen_random_bytes(32),'hex')
jwt_seedTEXTNOT NULL, DEFAULT encode(gen_random_bytes(16),'hex')
tenant_idUUIDNOT NULL, FK→organisations(uuid), CASCADE
is_activeBOOLEANDEFAULT TRUE
created_atTIMESTAMPTZ

UNIQUE constraint on (jwt_seed, tenant_id).

audit_log

ColumnTypeNote
idBIGSERIALPK
org_idUUIDFK→organisations(uuid), SET NULL
project_idINTEGERFK→projects, SET NULL
actor_user_idINTEGERFK→users, SET NULL
actionTEXTe.g. "role.create"
target_typeTEXTe.g. "role"
target_idTEXTID of affected record
detailJSONBExtra context
created_atTIMESTAMPTZ

Immutable: Database triggers audit_log_no_update and audit_log_no_delete block all modifications.


Running & Deployment

Environment variables

VariableRequiredDescription
DATABASE_URLYespostgres://user:x@host:port/db
PGPASSWORDYesPostgreSQL password (avoids special chars in URL)
JWT_SECRETYesHS256 signing key — keep secret, share with consumer apps
CRM_CORS_ORIGINNoCORS 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

CodeMeaning
400Bad request — missing required field, invalid ID format, or constraint violation (e.g. parent level too high)
401Unauthorized — missing, expired, or invalid token
403Forbidden — token is valid but the user lacks the required role (e.g. non-superadmin on a superadmin route)
404Not found — the resource doesn't exist or belongs to a different org
409Conflict — uniqueness constraint violation (duplicate name, email, etc.)
500Internal server error — check server logs

All error responses have the shape:

{ "error": "Human-readable description", "code": 400 }