Skip to main content

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:

FormWhere it livesExample
Shortroutes/api.php (->middleware('can:tasks.update'))tasks.update
FQN (fully qualified)Built at request time by CheckPermission; matches identity's PolicySeedertenant.acme.crm.tasks.update

The FQN is constructed as:

tenant.{tenant_name}.{service_name}.{short}
  • {tenant_name}config('app.tenant_name') (env TENANT_NAME)
  • {service_name}config('app.service_name') (env SERVICE_NAME, default crm)
  • {short} ← the value passed to can: 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

PlaceRole
routes/api.phpThe short permission attached via ->middleware('can:...')
app/Http/Middleware/CheckPermission.phpBuilds the FQN, then runs the gate sequence
main-identity-backend/database/seeders/System/PolicySeeder.phpIdentity-side allow-list — every permission CRM emits must exist here
php artisan crm:list-permissions --fqn --jsonThe CRM-side canonical dump used by CI drift-detect

Naming conventions

  • Lowercase, snake_case segmentstask_labels.list, not taskLabels.list.
  • Resource-then-verbcontacts.create not create.contacts.
  • Standard verbslist, 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 coarsetime_entries.export is 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:

  1. Admin bypassis_system_admin: true → allow.
  2. Tenant membership — token's tenants[].id must include config('app.tenant_id'), else 403 tenant_not_a_member.
  3. Service-account scope gate — only for tokens with scopes[]; see service-accounts.md.
  4. Policy evaluation — fetch policies from identity (cached 1h), run PolicyEvaluator with deny-overrides combining; see the PolicyDTO and PolicyEvaluator unit 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

  1. CRM side: attach the can: middleware in routes/api.php. No model code is needed — CheckPermission discovers it from the route registry.

  2. Identity side: add the same permission (under the appropriate role policy) to PolicySeeder and reseed the target environment.

  3. 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

SideWhat's checkedArtifact
CRM → identityEvery short permission attached to a route exists in identity's PolicySeederphp artisan crm:list-permissions --fqn --json
Identity → CRM(manual) Permissions added to PolicySeeder map to a real CRM routeOut 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

SymptomCauseFix
403 permission_denied on a brand-new endpointIdentity seeder not reseeded after PolicySeeder additionphp artisan db:seed --class=PolicySeeder --force on identity
403 in dev but not prodTENANT_NAME differs between dev and prod, and the seeder uses a tenant.*.crm.* wildcard only in produse the wildcard form in dev too
crm:list-permissions shows a permission that's not in the route fileRoute 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 hasService-account token's scopes[] doesn't include it — distinct gate from policyre-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