CDM Import & Export
CDM (Canonical Data Model) is a versioned JSON document that captures a project’s permission graph—roles, groups, permissions, resources, tags, and optional users. Use it to clone, back up, or port a model between Grant instances or from an external system.
Operators typically work from Project → Import/Export in the dashboard. Each import or export runs as an async job; large payloads are never applied inline.
How it works
You start work from Import/Export. Grant runs the job in the background while the jobs list keeps updating; when it finishes, open the job to review tabs or download a file.
Import
modein the file sets merge (default) or replace; replace requiresconfirmDestructive.- Only CDM-managed rows are updated; other project data is untouched.
- Domain events: entity-level events (
role.created,user.role_assigned, …) are suppressed for the duration ofimportProjectCdm(including replace teardown) so imports do not flood webhooks or in-app notifications. When the async job finishes, Grant emits a single summary event —project_sync.completedorproject_sync.failed— with job/project ids, operation (import|export), and import counters or a short error message. See Events, Webhooks & Notifications.
Export
- Contents chooses what goes in the file (e.g. permissions requires resources).
- Options are embedded in the download as
mode; they do not change live data now. Job Details shows the same strategy and version—there is no separate options tab.
Payload shape
Imports and exports use SyncProjectInput (GraphQL / codegen). Top-level fields:
| Field | Purpose |
|---|---|
version | Schema version (currently 1) |
id | Optional correlation id for audit / idempotency |
mode | Import policy: strategy, onConflict, confirmDestructive |
resources | Project-scoped custom resources + membership |
permissions | Project-scoped custom permissions + membership |
groups | Group definitions and permission grouping |
roles | Roles with groups, permissions, tags |
users | User definitions / assignments + optional userKey |
tags | Tag definitions + project_tags membership |
Section rules
- Every section is optional; omit a section to leave it unchanged (export) or skip it (import).
- If
permissionsis present,resourcesmust be present in the same document. - Grant does not auto-generate synthetic roles. Use the explicit CDM fields below for each grant path.
user_tagsare global—re-importing them affects every project the user belongs to. See Tags & Relationships → CDM lifecycle.
Permission assignment paths (v1)
Each path in the document maps to native Grant edges on import:
| CDM fields | Runtime path |
|---|---|
groups[].permissions + roles[].groups + users[].roles | User → Role → Group → Permission |
groups[].permissions + users[].groups | User → Group → Permission |
roles[].permissions (no roles[].groups) + users[].roles | User → Role → Permission |
users[].permissions | User → Permission |
Export emits the same fields. Users may have roles only, direct permissions only, direct groups only, or any combination.
Legacy documents: if users[].roles contains synthetic:role:user:…:direct, import strips those keys and adds a job warning. Use users.permissions instead.
Replace-import removes existing CDM-managed rows for the project, then applies the payload. See RBAC for how roles, groups, and permissions connect.
Global catalog vs CDM-owned
| Global catalog | CDM-owned rows | |
|---|---|---|
| What | Seeded resources / permissions shared by all projects | Rows with metadata.cdmImport for this project |
| In export JSON | Referenced as resourceSlug:action strings; resources / permissions sections often empty | Listed in CDM sections when custom definitions exist |
| On edit | Changes affect every project using that catalog row | Scoped to the project’s CDM import envelope |
| Isolation | Not isolated per project today | — |
Imports resolve standard grants via permissionRefs (resourceSlug + action [+ condition]). CDM resources / permissions arrays list only project-created definitions (metadata.cdmImport), not the full system catalog.
INFO
Strict per-project catalog isolation (forking global permissions so edits never leak across projects) is not shipped. Treat catalog rows as instance-wide until a portable-catalog mode exists. See Future: portable catalog below.
Permission strings in roles and users
Each roles[].permissions or users[].permissions entry is either:
| Form | Example | When |
|---|---|---|
| Document key | Matches permissions[].key in the same file | Custom permission defined in CDM |
| Catalog ref | project:update | Slug + action; slug must not contain : |
Slugs are normalized (lowercase, hyphenated). Export emits slug:action for catalog-backed grants even when resources / permissions sections are empty.
External keys
Every CDM-portable entity has an opaque externalKey:
| Source | Rule |
|---|---|
| Importer | You supply the key; must be unique within its section |
| Exporter | Grant generates stable keys like cdm-tag-3f2a9b1c… via buildExternalKey(kind, …) |
| Traceability | Original Grant ids live in metadata.cdmSource (grantRoleId, grantTagId, …)—never as cross-references |
Users: reference an existing account with userAssignments[].userId, or define users[] and link via userAssignments[].userKey → users[].externalKey.
Resolving permission refs (in roleTemplates / userAssignments), in order:
permissionKey→ permission in the same documentpermissionId→ existing Grant UUID (legacy; new exports omit this)(resourceSlug, action [, condition])→ global catalog
Provisioned users
The optional users section creates or resolves global Grant users for inbound porting, then the assignment fields (roles, groups, or permissions) add project membership. CDM user imports never create Grant accounts, sessions, passwords, OTPs, or outgoing verification emails.
User keys support two important modes:
| Resolver | Behavior |
|---|---|
findBy: id | key.value is an existing Grant user id. Import skips global user provisioning and uses that user directly. |
findBy: key/empty | key.value is a CDM-local external key. Grant creates or reuses a user with metadata.cdmImport for this project. |
findBy: email | key.value is normalized to lowercase and resolved against the global authentication catalog. If no identity exists, Grant creates a global user plus a passwordless email authentication method with isVerified: false. |
For findBy: email, import treats the address as an identity claim, not proof of mailbox ownership. The email method reserves/links the global identity but remains unverified until Grant sees a later inbox-proof flow, such as project OAuth email magic link.
Export remains conservative: CDM-managed users are emitted with opaque external keys unless a future export mode explicitly opts into exposing global login identifiers.
Schema reference: SyncProjectInput.
Dashboard permissions
| Action | Permission | Notes |
|---|---|---|
| Start import | Project:Update | Enqueues async job |
| Start export | Project:Update | Enqueues async job |
| View jobs | Project:Query | List and detail |
| Cancel job | Project:Update | Best-effort if already running |
Scope must match the project (organization or personal), same as other project screens.
Web UI
- Open Project → Import/Export (
…/projects/{projectId}/import-export). - Import — pick a
.jsonfile; validated, then enqueued. Original payload stored on the job. - Export — dialog with Contents and Options tabs (below). Download CDM from the job row when complete.
- The list polls while any job is
PENDINGorRUNNING. - Job details — status on Details; import adds Result, Payload, Rollback snapshot; export adds Exported CDM.
Export dialog
The export dialog enqueues a job. It does not change live data—the worker snapshots the database when the job runs.
| Tab | Purpose |
|---|---|
| Contents | Which CDM sections to include |
| Options | Re-import defaults embedded in exported mode (hints only) |
Contents
| Checkbox | Notes |
|---|---|
| Users | Optional; nested User API keys (identities only, no secrets) |
| Roles, Groups, Resources, Permissions, Tags | Omit to exclude from JSON |
| Permissions | Requires Resources checked |
- Selecting every section = full export (same as omitting
sectionsfilter). - Exporting users without roles is valid when users have direct
permissionsorgroupsassignments.
Re-import options
Values are stored in the job payload and copied into the artifact’s mode block. They describe how a future import of this file should behave—they do not run merge/replace during export.
The jobs table Strategy column shows the chosen mode for quick scanning.
Job name
Optional label for the jobs table and idempotency (StartProjectExportInput.jobName). Empty → defaults to project display name.
| Rule | Behavior |
|---|---|
| Same name, job in flight | Returns existing pending/running job |
| After completion | May enqueue again with the same name |
| Failed / cancelled | Retry with the same name allowed |
Version
Schema version written into the export (version, currently 1). Must match importer support.
Strategy
| Value | On import |
|---|---|
merge | Apply alongside existing CDM-managed rows (default) |
replace | Remove CDM-managed rows, then apply; requires confirm destructive |
On conflict
Maps to mode.onConflict when set: fail, skip, or update. Importer default omits the field (typically fail for merge).
Confirm destructive
Required when strategy is replace (mode.confirmDestructive: true in JSON). Acknowledges that a future replace import may delete CDM-managed data. Does not delete anything during export.
Sync jobs
Each operation is a row in project_sync_jobs (operation = import | export), run by the job adapter.
| State | Meaning |
|---|---|
pending | Queued |
running | Worker active |
completed | Success |
failed | Error on job row |
cancelled | Operator or system cancelled |
Idempotency — optional jobName (export) or CDM id (import):
| Import | Export | |
|---|---|---|
| Key | SyncProjectInput.id (often omitted) | jobName (defaults to project name) |
| In-flight duplicate | Returns same job per (project, operation, job_name) | Same |
| After complete | New upload = new row if no id | Same name allowed again |
- Poll — GraphQL
projectSyncJobs/projectSyncJob, or REST equivalents. - Payload —
GET …/sync/jobs/{jobId}/payload(submitted import JSON or export options).
Rollback snapshot
Before an import, the worker exports the full project CDM inside the same transaction, then applies the import. If the import fails, the snapshot rolls back with the transaction.
| Surface | Content |
|---|---|
| GraphQL job fields | hasSnapshot, snapshotTakenAt, snapshotSizeBytes |
GET …/snapshot | Full JSON bytes (not inlined in GraphQL) |
TIP
There is no one-click “rollback” in the UI yet. Download the snapshot and start a new import to replay. Future secret-bearing CDM fields (e.g. BYOK) would need secrets re-supplied on replay.
REST API
Routes under /api/projects/{id}/… with scopeId and tenant (see REST API).
| Method | Path | Role |
|---|---|---|
GET | …/sync/jobs | List jobs |
POST | …/sync/jobs | Enqueue import (body = CDM JSON) |
POST | …/sync/jobs/export | Enqueue export |
GET | …/sync/jobs/{jobId} | Job status |
GET | …/sync/jobs/{jobId}/payload | Request JSON (import CDM or export options) |
GET | …/sync/jobs/{jobId}/snapshot | Import: pre-sync snapshot; export: generated CDM when completed |
DELETE | …/sync/jobs/{jobId} | Cancel |
GraphQL
| Operation | Purpose |
|---|---|
startProjectSync | Enqueue import |
startProjectExport | Enqueue export (sections, mode, jobName, …) |
cancelProjectSync | Cancel job |
projectSyncJobs | Paginated list |
projectSyncJob | Single job |
Operation documents live in packages/@grantjs/schema for codegen.
Searchable metadata
List-view text search uses denormalized search_document columns (not raw JSONB scans). Importers can supply explicit search tokens via the top-level CDM field searchable on users[], roles[], and groups[].
| Entity | Storage | List search |
|---|---|---|
| Project users | project_users.search_document | Project-scoped user lists |
| Roles | roles.search_document | Role lists (searchDocument field) |
| Groups | groups.search_document | Group lists (searchDocument field) |
On import, searchable is merged into metadata.cdmSource.searchable for export round-trip. Grant also extracts allowlisted metadata paths (e.g. cdmSource.legacy.email) when searchable is absent.
Never indexed: passwordHash, clientSecret, accessKeySecretReferenceId, raw permissionJson, and similar secret fields.
Backfill: Re-run a CDM replace import after deploying Grant + ETL with searchable support; pivot and entity rows are recomputed on apply.
Row metadata
CDM-managed rows carry:
| Field | Role |
|---|---|
metadata.cdmImport | projectId, kind, externalKey — teardown, export filter, correlation |
metadata.cdmSource | Trace ids (grantRoleId, grantUserId, …) and importer-owned JSON |
Contributor details: CDM README in the API tree.
Future: portable catalog
Copying global catalog permissions per project (so edits never cross projects) would need coordinated changes to resolution, export, and slug rules. Not shipped—plan against global catalog semantics until such a mode exists.
Extending CDM (contributors)
New entity types register via ICdmEntityHandler (shared by sync and export). Handler order, cdmVersion bumps, and tests are documented in:
Related
- Events, Webhooks & Notifications — CDM import event suppression and
project_sync.*summaries - RBAC System — roles, groups, permissions
- Job scheduling — background workers
- REST API — OpenAPI and conventions
- Transport layers — GraphQL vs REST