Skip to content

Permissions

Purpose

Authentication establishes who is making a request. Permissions answer what that identity is allowed to do. The two are distinct layers: authentication is always required first; permissions are applied per endpoint.

See Authentication for how identities are established and HTTP Pipeline for how the middleware stack is ordered.


Responsibilities

  • Define the permission model (roles, resources, actions)
  • Derive the required permission automatically from the route shape
  • Check permissions before executing handlers
  • Cache permission lookups to avoid per-request DB queries
  • Enforce tenant isolation (no cross-tenant access)

Permission DSL

EERP uses role-based access control (RBAC) with a three-part permission code:

permission = module + ":" + resource + ":" + action

Examples:

Permission Meaning
crm:contacts:read Read contacts in the CRM module
crm:contacts:write Create and update contacts
crm:contacts:delete Soft-delete contacts
inventory:items:read Read inventory items
*:*:* Full super-admin access

Roles are assigned to users at the tenant level. A user may have multiple roles. The effective permission set is the union of all permissions granted by all roles.


Wildcard Matching

The permission check supports wildcards so that broad grants like super-admin or module-admin roles do not require enumerating every permission:

Pattern Matches
*:*:* Every permission (super-admin)
module:*:* All actions on all resources in a module
module:*:action A specific action on any resource in a module
*:*:action A specific action across all modules
*:resource:action A specific action on a named resource in any module

The middleware evaluates the exact code first, then all applicable wildcard patterns.


Automatic Route-to-Permission Derivation

Permissions are not declared per-route in code. The permission middleware derives the required permission automatically from the URL path and HTTP method.

Path rules

Route shape Example path Derived permission
Two segments after /api/v1/ GET /api/v1/crm/contacts crm:contacts:read
One segment after /api/v1/ GET /api/v1/crm crm:crm:read
Unknown path shape or empty "" — middleware passes through

HTTP method mapping

HTTP method Action
GET read
POST write
PUT write
PATCH write
DELETE delete
Unknown "" — middleware passes through

When the derived permission is "" (unknown method or unrecognised path), the middleware does not block the request. This ensures that routes outside the standard CRUD shape are not accidentally denied.


Enforcement Flow

flowchart TD
    A["Request arrives (JWT already validated)"] --> B["JWT middleware: extract Identity\n(roles from token, injected into context)"]
    B --> C["Permission middleware: derive required permission\nfrom HTTP method + URL path"]
    C --> D{Permission string empty?}
    D -- Yes --> E["Pass through — unknown route shape"]
    D -- No --> F["PermissionRepository.Has(roles, permission)"]
    F --> G{Cached result?}
    G -- Yes --> H{Allowed?}
    G -- No --> I["DB query: permissions JOIN role_permissions\nJOIN roles WHERE name IN roles"]
    I --> J["Cache result for 5 minutes"]
    J --> H
    H -- No --> K["403 FORBIDDEN"]
    H -- Yes --> L["Handler executes"]

Permission Repository

PermissionRepository.Has(roles []string, permission string) bool is the single entry point for permission checks. It:

  1. Queries permissions JOIN role_permissions JOIN roles WHERE r.name IN (roles...).
  2. Evaluates both the exact permission code and all applicable wildcard patterns.
  3. Caches the boolean result in a sync.Map keyed by (sorted_roles, permission) with a 5-minute TTL.

After any write to the role_permissions table, call InvalidateCache() to flush stale entries:

permRepo.InvalidateCache()

Cache scope

The cache is in-process and per-server instance. In a multi-instance deployment, each instance caches independently. A role change is reflected within 5 minutes on all instances without explicit coordination.


Permission Storage

erDiagram
    users {
        uuid id PK
        uuid tenant_id
        string email
        string password_hash
    }
    roles {
        uuid id PK
        uuid tenant_id
        string name
        string description
    }
    permissions {
        uuid id PK
        string code
        string description
        string module
    }
    user_roles {
        uuid user_id FK
        uuid role_id FK
    }
    role_permissions {
        uuid role_id FK
        uuid permission_id FK
    }

    users ||--o{ user_roles : has
    roles ||--o{ user_roles : granted_to
    roles ||--o{ role_permissions : grants
    permissions ||--o{ role_permissions : granted_by

user_roles and role_permissions use composite primary keys and carry no BaseModel — they are pure join tables.

Seeding permissions

Permissions are created by inserting rows into the permissions table directly (via API or migration). There is no automatic seeding from module.json — permission codes must be created and assigned to roles manually.


Tenant Isolation

Tenant isolation is enforced at two levels:

  1. Token level: The JWT contains tenant_id. All permission checks use role names scoped to that tenant.
  2. Query level: Every service filters by tenant_id from the identity context.

A user from tenant A cannot access tenant B's data even if they somehow obtain a valid token for tenant A, because every query includes WHERE tenant_id = $1 with the value from their token.


Interactions

graph LR
    JWT["JWT token\n(roles[])"] --> JWTMiddleware["JWT Middleware"]
    JWTMiddleware -->|injects Identity| PermMW["Permission Middleware"]
    PermMW -->|derives permission from| Route["URL path + HTTP method"]
    PermMW -->|checks via| PermRepo["PermissionRepository\n(DB + 5-min cache)"]
    PermRepo -->|queries| DB["permissions table\n(PostgreSQL)"]
    Handler -->|reads Identity from| Context["request context"]

See also: Authentication, HTTP Pipeline, Automatic Router.


Extension Points

Extension How
Attribute-based access control (ABAC) Add resource-attribute evaluation after the RBAC check
Shorter cache TTL Lower the 5-minute TTL in PermissionRepository for high-churn role assignments
Audit logging Wrap Has() to write (user, permission, allowed, timestamp) before returning
Dynamic roles Allow tenants to create custom roles via the permissions API; call InvalidateCache() after writes