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:
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:
- Queries
permissions JOIN role_permissions JOIN roles WHERE r.name IN (roles...). - Evaluates both the exact permission code and all applicable wildcard patterns.
- Caches the boolean result in a
sync.Mapkeyed by(sorted_roles, permission)with a 5-minute TTL.
After any write to the role_permissions table, call InvalidateCache() to flush stale entries:
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:
- Token level: The JWT contains
tenant_id. All permission checks use role names scoped to that tenant. - Query level: Every service filters by
tenant_idfrom 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 |