Ld Permissions logo

Ld Permissions

OrganizationPopular
lightdash
ld-permissions

Guide for Lightdash's CASL-based authorization system. Use when working with scopes, custom roles, abilities, permissions, ForbiddenError, authorization, or access control. Helps with adding new scopes, debugging permission issues, understanding the permission flow, and creating custom roles.

Overview

Publisherlightdash
Repositorylightdash
Skill nameld-permissions
Stars
6.2K
Forks
790
Bundled files
Instructions only
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by lightdash on GitHub. Read the source before you install it.

Installation

Install the Ld Permissions AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/lightdash/lightdash/tree/main/.claude/skills/ld-permissions
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/lightdash/lightdash.git /tmp/lightdash
mkdir -p .claude/skills
cp -r /tmp/lightdash/.claude/skills/ld-permissions .claude/skills/ld-permissions
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Ld Permissions in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Ld Permissions on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Ld Permissions is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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?

  1. Add a new scope/permission - Step-by-step guide to add a new permission
  2. Debug a permission issue - Troubleshoot why a user can't access something
  3. Understand the permission flow - Learn how permissions work end-to-end
  4. Work with custom roles - Create or modify custom roles with specific scopes

Quick Reference

Key Files

PurposeLocation
Scope definitionspackages/common/src/authorization/scopes.ts
CASL typespackages/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 mappingpackages/common/src/authorization/roleToScopeMapping.ts
Scope-to-CASL conversionpackages/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):

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

subject(...) must describe only the target resource:

typescript
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:

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

  1. Add subject (if new) to CaslSubjectNames in packages/common/src/authorization/types.ts

  2. 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)],
}
  1. Update project-level abilities in packages/common/src/authorization/projectMemberAbility.ts — add to the appropriate system role function (e.g., developer, admin)

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

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

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

  5. Enforce in service via this.createAuditedAbility(account) + ability.cannot() — never raw user.ability (legacy pattern, see docs/account-patterns.md)

  6. 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 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:

ChangeImpact on scoped_rolesRequired 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 oneRoles 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 scopeRows 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 scopeNo row change needed, but the behavioral change is invisible to operators.None on the table; note in PR description.
Add a brand-new scopeNo 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 (role_uuid, scope_name) 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:

  1. Determine which change type applies (rename / split / merge / remove / add / tighten).
  2. If a migration is required, create it with pnpm -F backend create-migration <name> and follow the patterns above.
  3. Update roleToScopeMapping.ts so system roles reflect the new vocabulary, and run the parity test.
  4. 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":

  1. Check scope exists - Is the scope defined in scopes.ts?
  2. Check role assignment - Does the user's role include this scope?
  3. Check conditions - Do the CASL conditions match the resource?
  4. Check enterprise flag - Is isEnterprise: true but deployment isn't enterprise?
  5. Check subject name - Case-sensitive match in CaslSubjectNames?

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.

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 (saved_queries.dashboard_uuid 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.

Frequently asked questions

What does the Ld Permissions AI skill do?

Guide for Lightdash's CASL-based authorization system. Use when working with scopes, custom roles, abilities, permissions, ForbiddenError, authorization, or access control. Helps with adding new scopes, debugging permission issues, understanding the permission flow, and creating custom roles.

Why use Ld Permissions on TypingMind?

Because you install it once and use it with any model. Ld Permissions is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Ld Permissions in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/lightdash/lightdash/tree/main/.claude/skills/ld-permissions. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Ld Permissions?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Ld Permissions?

As many as you like. As long as a model supports skills, you can use Ld Permissions with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Ld Permissions AI skill free?

It is published on GitHub by lightdash. Check the repository for licensing terms. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇