Permissions & Authorization Guide
This skill helps you work with Lightdash's CASL-based permissions system, including scopes, custom roles, and authorization enforcement.
What do you need help with?
- Add a new scope/permission - Step-by-step guide to add a new permission
- Debug a permission issue - Troubleshoot why a user can't access something
- Understand the permission flow - Learn how permissions work end-to-end
- Work with custom roles - Create or modify custom roles with specific scopes
Quick Reference
Key Files
| Purpose | Location |
|---|
| Scope definitions | packages/common/src/authorization/scopes.ts
|
| CASL types | packages/common/src/authorization/types.ts
|
| Ability builder (system role vs custom role path) | packages/common/src/authorization/index.ts
|
| System role abilities (project level) | packages/common/src/authorization/projectMemberAbility.ts
|
| System role abilities (org level) | packages/common/src/authorization/organizationMemberAbility.ts
|
| Service account abilities (enterprise, CI/CD) | packages/common/src/authorization/serviceAccountAbility.ts
|
| Role-to-scope mapping | packages/common/src/authorization/roleToScopeMapping.ts
|
| Scope-to-CASL conversion | packages/common/src/authorization/scopeAbilityBuilder.ts
|
Common Patterns
Backend permission check (services take
account: RegisteredAccount
and build the ability via
this.createAuditedAbility(account)
— raw
is legacy, see
):
typescript
import { subject } from '@casl/ability';
import { ForbiddenError } from '@lightdash/common';
const ability = this.createAuditedAbility(account);
if (ability.cannot('manage', subject('Dashboard', { organizationUuid, projectUuid }))) {
throw new ForbiddenError('You do not have permission');
}
CASL Subject Scoping: Resource, Not Actor
CASL actor is passed before the check:
typescript
getUserAbilityBuilder({
user: lightdashUser, // actor
projectProfiles,
permissionsConfig,
});
const ability = this.createAuditedAbility(accountOrUser); // actor
must describe only the target resource:
typescript
ability.can(
'manage',
subject('X', {
organizationUuid: target.organizationUuid,
projectUuid: target.projectUuid,
}),
);
Never fill
from actor fields like
. Org-level grants may only check
, so actor-sourced subject fields can become cross-org access on multi-org instances. Single-org dev hides it.
Frontend permission check:
typescript
const { user } = useApp();
if (user.data?.ability.can('manage', 'Dashboard')) {
return <EditButton />;
}
or wrap in a CASL component:
tsx
import { Can } from '../../providers/Ability';
<Can I="manage" a="Dashboard">
<EditButton />
</Can>
Full Documentation
For comprehensive documentation, read:
This includes:
- Architecture diagram showing the complete permission flow
- All scope groups and modifiers (@self, @public, @space, etc.)
- Database schema for custom roles
- Step-by-step guide to add new scopes
- Troubleshooting guide
Adding a New Scope (Quick Guide)
You must update ALL the relevant ability layers:
-
Add subject (if new) to
in
packages/common/src/authorization/types.ts
-
Define scope in
packages/common/src/authorization/scopes.ts
:
typescript
{
name: 'manage:NewFeature',
description: 'Description for custom role UI',
isEnterprise: false,
group: ScopeGroup.PROJECT_MANAGEMENT,
getConditions: (context) => [addUuidCondition(context)],
}
-
Update project-level abilities in
packages/common/src/authorization/projectMemberAbility.ts
— add to the appropriate system role function (e.g.,
,
)
-
Update org-level abilities in
packages/common/src/authorization/organizationMemberAbility.ts
if needed — note: org-level abilities are additive and
cannot be restricted by project-level custom roles
-
Add to system role in
in
packages/common/src/authorization/roleToScopeMapping.ts
(must stay in sync with
— the parity test
roleToScopeParity.test.ts
enforces this)
-
Update service accounts in
packages/common/src/authorization/serviceAccountAbility.ts
— add to
(or other service account scopes) if service accounts need this permission.
Forgetting this breaks CI/CD pipelines.
-
Enforce in service via
this.createAuditedAbility(account)
+
— never raw
(legacy pattern, see
)
-
Add frontend check with
→
Changing the Scope Vocabulary (Migrating Custom Roles)
Custom roles persist scope names as strings in the
table (
,
,
). They are decoupled from system roles and
do not auto-update when the scope vocabulary changes. Any rename / split / merge / removal must include a Knex migration that reconciles existing rows, otherwise self-hosted instances silently lose or retain permissions.
Before merging a scope change, evaluate the impact and write a migration:
| Change | Impact on | Required migration |
|---|
| Rename a scope (e.g. → ) | Old rows reference a name that no longer exists in . drops them as invalid, silently revoking access. | UPDATE scoped_roles SET scope_name = 'new' WHERE scope_name = 'old'
|
| Split one scope into two (e.g. → + ) | Roles with the original scope lose access to whichever capability moved to the new scope. | Backfill the new scope for every role that has the original (INSERT ... SELECT ... ON CONFLICT DO NOTHING
). See 20260417111420_grant_custom_fields_to_custom_sql_roles.ts
. |
| Merge two scopes into one | Roles with only one of the merged scopes may gain or lose capability. | Insert the merged scope where either source exists; then delete the old rows. |
| Remove a scope | Rows reference a non-existent scope name. silently drops them; logs "Custom role(s) for user ... reference scopes not in the runtime vocabulary" warnings on every ability build. | Delete the orphaned rows. See 20260519142606_remove_legacy_dashboard_export_scopes.ts
. |
| Tighten conditions on an existing scope | No row change needed, but the behavioral change is invisible to operators. | None on the table; note in PR description. |
| Add a brand-new scope | No existing rows are affected. Only system roles in need updating. | None for custom roles. |
Migration conventions (see
packages/backend/src/database/CLAUDE.md
for general safe-migration rules):
- Wrap the body in and log a recoverable manual-fix command on failure. These backfills are best-effort cleanup — failing them should never block subsequent migrations.
- Use for inserts since is the natural unique key.
- Preserve from the source row when copying a scope, so audit history points back at the original grantor rather than .
- Provide a sensible — usually deleting the rows the inserted. If the change is irreversible (legacy cleanup), document why is a no-op.
Checklist when changing the scope vocabulary:
- Determine which change type applies (rename / split / merge / remove / add / tighten).
- If a migration is required, create it with
pnpm -F backend create-migration <name>
and follow the patterns above.
- Update so system roles reflect the new vocabulary, and run the parity test.
- Call this out in the PR description so reviewers can verify the data migration matches the code change.
Debugging Permission Issues
When a user gets "ForbiddenError":
- Check scope exists - Is the scope defined in ?
- Check role assignment - Does the user's role include this scope?
- Check conditions - Do the CASL conditions match the resource?
- Check enterprise flag - Is but deployment isn't enterprise?
- Check subject name - Case-sensitive match in ?
Use grep to find where the permission is checked:
bash
grep -r "ability.cannot.*'manage'.*'YourSubject'" packages/backend/src/services/
Please describe what you're trying to accomplish, or ask me to explain any aspect of the permissions system.