Permissions
CRM relies on identity for every authorisation decision: identity owns the permission registry, policy seeders, and per-role mappings. CRM only enforces the result. This page documents the contract between the two — what CRM names a permission, how it maps to a FQN, and where to add a new one.
Short permission vs FQN
There are two forms of the same name:
| Form | Where it lives | Example |
|---|---|---|
| Short | routes/api.php (->middleware('can:tasks.update')) | tasks.update |
| FQN (fully qualified) | Built at request time by CheckPermission; matches identity's PolicySeeder | tenant.acme.crm.tasks.update |
The FQN is constructed as:
tenant.{tenant_name}.{service_name}.{short}
{tenant_name}←config('app.tenant_name')(envTENANT_NAME){service_name}←config('app.service_name')(envSERVICE_NAME, defaultcrm){short}← the value passed tocan:middleware
config('app.tenant_name') is deployment-bound; the resulting FQN is
predictable per-deployment. The identity-side PolicySeeder lists the
same shape (tenant.*.crm.tasks.update with a single-segment wildcard
for the tenant name).
Where the names live
| Place | Role |
|---|---|
routes/api.php | The short permission attached via ->middleware('can:...') |
app/Http/Middleware/CheckPermission.php | Builds the FQN, then runs the gate sequence |
main-identity-backend/database/seeders/System/PolicySeeder.php | Identity-side allow-list — every permission CRM emits must exist here |
php artisan crm:list-permissions --fqn --json | The CRM-side canonical dump used by CI drift-detect |
Naming conventions
- Lowercase, snake_case segments —
task_labels.list, nottaskLabels.list. - Resource-then-verb —
contacts.createnotcreate.contacts. - Standard verbs —
list,view,create,update,delete,publish,unpublish,archive,restore,export,import,comment,manage,review. - Two-level resources use a dot —
blog.posts.list,blog.categories.manage(matches the directory structure of the controllers and the seeder's hierarchy). - Granular over coarse —
time_entries.exportis its own permission so a role can grant "record own time" without bulk-export (MAGAS #50 audit fix).
Gate sequence (recap)
When a request hits a can:foo.bar-protected route, CheckPermission
runs four checks in order. The full flow is in
security.md § permission gate; a
short version:
- Admin bypass —
is_system_admin: true→ allow. - Tenant membership — token's
tenants[].idmust includeconfig('app.tenant_id'), else 403tenant_not_a_member. - Service-account scope gate — only for tokens with
scopes[]; see service-accounts.md. - Policy evaluation — fetch policies from identity (cached 1h),
run
PolicyEvaluatorwith deny-overrides combining; see thePolicyDTOandPolicyEvaluatorunit tests in testing.md.
Permission catalogue (representative)
The list below mirrors the identity PolicySeeder for CRM (see
tenant-policies.md for the full
discussion of how identity ports them).
For the exact, live, machine-readable list, run:
cd tenant/tenant-backend-crm
php artisan crm:list-permissions --fqn --json
Core CRM
contacts.{list, view, create, update, delete}
companies.{list, view, create, update, delete}
deals.*
pipelines.*
communication_logs.*
HR / Projects / Tasks
projects.{list, view, create, update, delete}
tasks.{list, view, create, update, delete}
task_labels.*
time_entries.{list, create, update, delete, export}
leaves.{list, create, review}
shifts.*
shift_assignments.*
Documents
documents.{list, view, create, update, delete}
document_folders.{list, view, create, update, delete}
Chat
chat.access
chat.* (catch-all for chat-namespace verbs)
Content
kb_articles.{list, view, create, update, delete, publish, comment}
blog.posts.{list, view, create, update, publish}
blog.categories.{list, manage}
pages.{list, view, create, update, publish}
pages.categories.*
Comms
email_accounts.*
email_messages.*
sms_accounts.*
sms_messages.*
sms_templates.*
Misc
activity_logs.*
notification_rules.*
notifications.*
media.{list, upload, delete}
sla.*
Adding a new permission
-
CRM side: attach the
can:middleware inroutes/api.php. No model code is needed —CheckPermissiondiscovers it from the route registry. -
Identity side: add the same permission (under the appropriate role policy) to
PolicySeederand reseed the target environment. -
Run drift detect locally:
php artisan crm:list-permissions --fqn --json | jq '.permissions'then compare against the identity seeder. CI runs this on every PR (cross-repo #38).
A typo at step 2 fails closed: requests to the new endpoint will 403
permission_denied until the seeder catches up.
Drift CI gate
| Side | What's checked | Artifact |
|---|---|---|
| CRM → identity | Every short permission attached to a route exists in identity's PolicySeeder | php artisan crm:list-permissions --fqn --json |
| Identity → CRM | (manual) Permissions added to PolicySeeder map to a real CRM route | Out of band — owner is the identity PR author |
The CRM → identity drift catches the common case (CRM ships a route without identity catching up). The reverse direction is a slow burn that's only caught in PR review.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
403 permission_denied on a brand-new endpoint | Identity seeder not reseeded after PolicySeeder addition | php artisan db:seed --class=PolicySeeder --force on identity |
| 403 in dev but not prod | TENANT_NAME differs between dev and prod, and the seeder uses a tenant.*.crm.* wildcard only in prod | use the wildcard form in dev too |
crm:list-permissions shows a permission that's not in the route file | Route was deleted but the seeder still has it (cosmetic; identity logs warn but it's not a failure) | trim the seeder |
service_account_scope_denied on a permission the user role has | Service-account token's scopes[] doesn't include it — distinct gate from policy | re-issue token with the missing scope |
Where to learn more
- security.md — 5-layer gate model + cache invalidation
- service-accounts.md — scope-gate, deeper
- api.md — per-endpoint permission map
- tenant-policies.md — identity-side per-action policy port
main-identity-backend/database/seeders/System/PolicySeeder.php— the source of truth