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 user.ability is legacy, see docs/account-patterns.md):
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:
getUserAbilityBuilder({
user: lightdashUser, // actor
projectProfiles,
permissionsConfig,
});
const ability = this.createAuditedAbility(accountOrUser); // actor
subject(...) must describe only the target resource:
ability.can(
'manage',
subject('X', {
organizationUuid: target.organizationUuid,
projectUuid: target.projectUuid,
}),
);
Never fill subject(...) from actor fields like user.organizationUuid. Org-level grants may only check organizationUuid, so actor-sourced subject fields can become cross-org access on multi-org instances. Single-org dev hides it.
Frontend permission check:
const { user } = useApp();
if (user.data?.ability.can('manage', 'Dashboard')) {
return <EditButton />;
}
or wrap in a CASL component:
import { Can } from '../../providers/Ability';
<Can I="manage" a="Dashboard">
<EditButton />
</Can>
Full Documentation
For comprehensive documentation, read: .context/PERMISSIONS.md
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
CaslSubjectNames in packages/common/src/authorization/types.ts
- Define scope in
packages/common/src/authorization/scopes.ts:
{
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., developer, admin)
- 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
BASEROLESCOPES in packages/common/src/authorization/roleToScopeMapping.ts (must stay in sync with projectMemberAbility.ts — the parity test roleToScopeParity.test.ts enforces this)
- Update service accounts in
packages/common/src/authorization/serviceAccountAbility.ts — add to ORG_ADMIN (or other service account scopes) if service accounts need this permission. Forgetting this breaks CI/CD pipelines.
- Enforce in service via
this.createAuditedAbility(account) + ability.cannot() — never raw user.ability (legacy pattern, see docs/account-patterns.md)
- Add frontend check with
useApp() → user.data?.ability.can()
Changing the Scope Vocabulary (Migrating Custom Roles)
Custom roles persist scope names as strings in the scopedroles table (roleuuid, scopename, grantedby). 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 scoped_roles |
Required migration |
Rename a scope (e.g. manage:Foo → manage:Bar) |
Old rows reference a name that no longer exists in scopes.ts. parseScopes drops them as invalid, silently revoking access. |
UPDATE scopedroles SET scopename = 'new' WHERE scope_name = 'old' |
Split one scope into two (e.g. manage:CustomSql → manage:CustomSql + manage:CustomFields) |
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 20260417111420grantcustomfieldstocustomsql_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. parseScopes silently drops them; UserModel logs "Custom role(s) for user ... reference scopes not in the runtime vocabulary" warnings on every ability build. |
Delete the orphaned rows. See 20260519142606removelegacydashboardexport_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 roleToScopeMapping.ts need updating. |
None for custom roles. |
Migration conventions (see packages/backend/src/database/CLAUDE.md for general safe-migration rules):
- Wrap the body in
try/catch and log a recoverable manual-fix command on failure. These backfills are best-effort cleanup — failing them should never block subsequent migrations.
- Use
ON CONFLICT DO NOTHING for inserts since (roleuuid, scopename) is the natural unique key.
- Preserve
granted_by from the source row when copying a scope, so audit history points back at the original grantor rather than NULL.
- Provide a sensible
down() — usually deleting the rows the up() inserted. If the change is irreversible (legacy cleanup), document why down() 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
roleToScopeMapping.ts 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
scopes.ts?
- Check role assignment - Does the user's role include this scope?
- Check conditions - Do the CASL conditions match the resource?
- Check enterprise flag - Is
isEnterprise: true but deployment isn't enterprise?
- Check subject name - Case-sensitive match in
CaslSubjectNames?
Use grep to find where the permission is checked:
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.
Dashboard direct grants — the boundary rule
Direct grants let a user be shared a dashboard and gain access to the charts that dashboard owns, without being a member of the space the dashboard lives in. This is enforced through the CASL access array, not through special rules, and is gated behind the direct-access feature flag (off → behaves exactly like a plain space check).
The rule: a direct grant authorizes operations whose effect stays inside the owning dashboard. Anything that reads, moves, or copies content beyond the dashboard requires real space access.
It turns on one distinction:
- A chart owned by a dashboard (
savedqueries.dashboarduuid set, space_id null) inherits the dashboard's grants — sharing the dashboard shares its own charts.
- A chart that merely lives in a space (
space_id set) is governed by space access only — sharing a dashboard that references it grants nothing over it.
Why: the grant means "you may work within this dashboard", so it must never become a lever to read a chart's private space or relocate content into a space the granter never saw.
The single choke point is SpacePermissionService.getDashboardAccessContext(userUuid, { uuid, spaceUuid }) — read its doc comment before touching any grant call site. It returns the space context plus the requester's grants appended as access rows tagged grantedVia: 'dashboard'.
When you add or change a chart/dashboard access check:
- Owned-chart read/edit/create/delete that stays inside the dashboard → call
getDashboardAccessContext with the chart's owning dashboardUuid.
- Boundary-crossing op (move to space, promote, content-as-code, pin, copy into a different dashboard) → pass
uuid: null (or use getSpaceAccessContext) so grants never count. The expectNoGrantRows test tripwire guards these — if you route a boundary op through a grant, that test fails.
- Never resolve a grant against a
spaceUuid the granting dashboard does not belong to; the helper asserts this pairing.
The write-vs-boundary split lives in SavedChartService (grant-aware: update, createVersion, create, delete, softDelete, duplicate; space-only: moveToSpace, the move half of updateMultiple, pinning). See also packages/backend/src/services/SpaceService/CLAUDE.md.