Guide for Lightdash's CASL-based authorization system. Use when working with scopes, custom roles, abilities, permissions, ForbiddenError, authorization, or access control...
This skill helps you work with Lightdash's CASL-based permissions system, including scopes, custom roles, and authorization enforcement.
| 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 |
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 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>
For comprehensive documentation, read: .context/PERMISSIONS.md
This includes:
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 BASE_ROLE_SCOPES 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()
Custom roles persist scope names as strings in the scoped_roles table (role_uuid, scope_name, granted_by). 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 scoped_roles SET scope_name = '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 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. 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 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 roleToScopeMapping.ts need updating. |
None for custom roles. |
Migration conventions (see packages/backend/src/database/CLAUDE.md for general safe-migration rules):
try/catch and log a recoverable manual-fix command on failure. These backfills are best-effort cleanup — failing them should never block subsequent migrations.ON CONFLICT DO NOTHING for inserts since (role_uuid, scope_name) is the natural unique key.granted_by from the source row when copying a scope, so audit history points back at the original grantor rather than NULL.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:
pnpm -F backend create-migration <name> and follow the patterns above.roleToScopeMapping.ts so system roles reflect the new vocabulary, and run the parity test.When a user gets "ForbiddenError":
scopes.ts?isEnterprise: true but deployment isn't enterprise?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.
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:
saved_queries.dashboard_uuid set, space_id null) inherits the dashboard's grants — sharing the dashboard shares its own charts.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:
getDashboardAccessContext with the chart's owning dashboardUuid.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.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.