Skip to content

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 ​

  • mode in the file sets merge (default) or replace; replace requires confirmDestructive.
  • 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 of importProjectCdm (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.completed or project_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:

FieldPurpose
versionSchema version (currently 1)
idOptional correlation id for audit / idempotency
modeImport policy: strategy, onConflict, confirmDestructive
resourcesProject-scoped custom resources + membership
permissionsProject-scoped custom permissions + membership
groupsGroup definitions and permission grouping
rolesRoles with groups, permissions, tags
usersUser definitions / assignments + optional userKey
tagsTag definitions + project_tags membership

Section rules

  • Every section is optional; omit a section to leave it unchanged (export) or skip it (import).
  • If permissions is present, resources must be present in the same document.
  • Grant does not auto-generate synthetic roles. Use the explicit CDM fields below for each grant path.
  • user_tags are 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 fieldsRuntime path
groups[].permissions + roles[].groups + users[].rolesUser → Role → Group → Permission
groups[].permissions + users[].groupsUser → Group → Permission
roles[].permissions (no roles[].groups) + users[].rolesUser → Role → Permission
users[].permissionsUser → Permission

A role may also carry no permissions at all: roles[] without permissions and without groups is valid. Import creates the role itself (name, description, tags, metadata) with no role_permissions, no group and no permission links; it is a pure label whose permissions can be attached later, for example by a later import or through the API. Export emits it again as a role with an empty permissions list, so it survives a round trip. Validation also accepts empty permissions on the group path: when roles[].groups is set and those groups carry no permissions, the role has no permissions either, and import creates the role and its paired group with no permission links.

Export emits the same fields. Users may have roles only, direct permissions only, direct groups only, or any combination.

Duplicate users[].roles keys are kept in the expanded document. Import writes each unique (user, role) pair once and still counts userRolesAssigned once per listing. HTTP role assignment stays one request per pair.

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 catalogCDM-owned rows
WhatSeeded resources / permissions shared by all projectsRows with metadata.cdmImport for this project
In export JSONReferenced as resourceSlug:action strings; resources / permissions sections often emptyListed in CDM sections when custom definitions exist
On editChanges affect every project using that catalog rowScoped to the project’s CDM import envelope
IsolationNot 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:

FormExampleWhen
Document keyMatches permissions[].key in the same fileCustom permission defined in CDM
Catalog refproject:updateSlug + 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:

SourceRule
ImporterYou supply the key; must be unique within its section
ExporterGrant generates stable keys like cdm-tag-3f2a9b1c… via buildExternalKey(kind, …)
TraceabilityOriginal 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:

  1. permissionKey → permission in the same document
  2. permissionId → existing Grant UUID (legacy; new exports omit this)
  3. (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, OTPs, or outgoing verification emails, and never choose a password: the only credential an import can carry is a hash you already hold (below).

User keys support two important modes:

ResolverBehavior
findBy: idkey.value is an existing Grant user id. Import skips global user provisioning and uses that user directly.
findBy: key/emptykey.value is a CDM-local external key. Grant creates or reuses a user with metadata.cdmImport for this project; with grantUserId it reuses a named existing Grant user instead of creating one.
findBy: emailkey.value is normalized to lowercase and resolved against the global authentication catalog. If no identity exists, Grant creates a global user plus an email authentication method with isVerified: false. The identity has no platform password; a password becomes a credential of the importing project only.

Matching an existing Grant user: key.grantUserId ​

A findBy: key user may also carry key.grantUserId, the id of an existing Grant user that this key is. Use it when a person was created in Grant first and your own system later learns of them: the import reuses that user instead of creating a second one, and your document keeps the same key on every import.

json
{
  "key": {
    "value": "legacy-user-17",
    "findBy": "key",
    "grantUserId": "0b6f3b0e-8f55-4d37-9d1f-3a1e5c2a7d10"
  },
  "name": "Ada Lovelace",
  "roles": ["editor"]
}
  • The rule, in order. (1) If a live user of this project already carries the key, that user is used; a grantUserId naming a different user is refused. (2) Otherwise the named user must exist and be live (not deleted), and must not already be imported into this project under another key (neither the membership row's marker nor the global user's marker may name this project with a different key), and no other entry of the same document may already resolve to that user, since a membership row holds one key; that user is then the one. (3) With no grantUserId and nothing under the key, a user is created, as before.
  • Refusals fail and roll back the whole import, like the other document rules. The message names the position in users[] and never the key or an id: grantUserId does not name an existing user, grantUserId is already imported under another key, grantUserId names another user than the one already imported under this key. A grantUserId is also refused (naming the position) when it is not a UUID or when the user is not identified by findBy: key: with findBy: id the value already is the id, and with findBy: email the person is resolved by address.
  • Membership comes from the assignment. Users are global in Grant, and there is no tenant a user belongs to, so the match does not require the person to be a member already; as for findBy: id, the document's assignment (roles, groups or permissions) is what adds them to the importing project. A user the document names but does not assign gets no membership and no marker yet; the next import with the hint matches again.
  • The key sticks. For a user the document assigns, the import writes this project's metadata.cdmImport marker on the project membership row (the global user is not changed), so the next import resolves the same key to the same user without the hint. The key lookup reads the membership row's marker as well as the global one (see Row metadata).
  • No events, no removals. Imports publish no domain events, so a match publishes none. A replace import removes no membership and no user, so a matched user keeps their membership and marker.
  • Not exported as keyed. The export lists users by the global marker, so a user matched this way is exported as a findBy: id reference, not as a keyed users[] entry.

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.

Deletion tombstones ​

A person who deletes an identity that an import created or marked stays deleted: the next import of the project does not create it again. (This is not the soft-delete a replace import applies to CDM-managed rows; see the contributor README for the two meanings of the word.)

  • When one is written. When a person deletes themselves (the user deletion where the actor is the deleted user, DELETE /api/me/accounts or the deleteMyAccounts mutation), and the identity carries an import key for a project: the key on the project membership row, else the key on the global user when it names that project. One tombstone per such project, written in the deletion's own transaction, for a soft or a hard delete. A Grant-native user (no import key anywhere) leaves none. An operator deleting someone else, or removing a member from a project, writes none: the next import brings that identity back.
  • What it stores. The project, the kind (user) and an HMAC-SHA256 fingerprint of the key. No address, no name, no user id, no copy of the record: for a findBy: email identity the key is the address, so it is only ever stored as a digest, and the table cannot be read back to a key. The import fingerprints the keys of its own document and checks those.
  • The rule, in one sentence. A tombstone blocks the creation of an identity for its key in its project; it never blocks a document user that resolves to a live identity. So a person who comes back, an identity matched with grantUserId, a live e-mail identity and a user a live marker resolves to are all imported as usual.
  • What a skipped user gets. Nothing: no user, no membership, no role, group, permission or tag, no project credential from password, no API key. The entry is dropped silently, so a document that still lists the person does not fail. A deleted account's e-mail method is not treated as a live identity: an address held only by a deleted account, with no tombstone, is refused naming the position in users[] (and never the address).
  • The counter. The import result reports usersSkippedTombstoned, a number only: the document users not created because of a tombstone. Keys are never listed, in the result, the warnings, the logs or error messages.
  • Requires the fingerprint secret. Tombstones use CDM_API_KEY_FINGERPRINT_HMAC_SECRET, the secret that already fingerprints API-key secrets. Without it no tombstone is written on a deletion (Grant logs a warning, without the key) and none can be checked on an import. Changing the secret orphans the existing tombstones: they no longer match any key.
  • Lifetime. Grant never removes a tombstone and none expires. A person who recreates an identity in Grant is not blocked, because the rule blocks only creation by an import. An operator who must lift one deletes the row from cdm_import_tombstones by a database operation until a later release adds a mutation. There is no API to list or read tombstones; the user.deleted event already names the deleted user's import keys and the actor.

Password credentials ​

A user with key.findBy: email can carry the password hash your legacy system already holds, so existing credentials survive a migration:

json
{
  "key": { "value": "ada@example.com", "findBy": "email" },
  "name": "Ada Lovelace",
  "password": { "algorithm": "bcrypt", "hash": "$2a$10$…" }
}
  • Format. algorithm must be bcrypt. hash is a 60-character bcrypt hash with a $2a$, $2b$ or $2y$ prefix, such as the output of Go's bcrypt.GenerateFromPassword, with a cost between 4 and 15. Anything else, and a password on a user not identified by email, is a validation error; the message names the position and never repeats the hash.
  • Where it goes. The hash becomes the importing project's credential for that person (ADR 0011), not a field of the global email authentication method. It is checked only by that project's apps and never opens the Grant console or another project. It is not copied into metadata, search tokens, audit values or events. Remove any passwordHash your ETL previously put into metadata: Grant does not scrub what you sent before.
  • Who must be a member. The credential is written in a step after the user assignment, in the same transaction, and only for a person who is then a member of the importing project: either this document assigns them (roles, groups or permissions on the row) or they already are. A password-only row for an existing member is fine, so a passwords-only delta works. A password for anyone who is not a member after the import is a validation error: the whole import is rejected and rolled back, and the message names the position in users[] and never the address or the hash.
  • Who owns it. An imported credential is marked as imported. Your legacy system owns it until the person resets or changes it in Grant: a later import of this project with a different hash replaces it (passwordsUpdated), and an identical one changes nothing (passwordsUnchanged). Once the password is set, changed or reset in Grant, Grant owns the credential and imports never touch it again; the import skips it and counts passwordsSkippedExisting. An absent password never removes a stored credential, and replace mode keeps the credentials of users it re-creates.
  • Which identities. Every identity can receive a project credential, including one that is verified or found through GitHub or Google: the credential opens this project's apps only, and the project already decides who is a member and what roles they hold. The person's platform account, their other projects and their GitHub or Google login are untouched. Two environments (projects) can hold different hashes for the same person without conflict, because each project has its own credential.
  • Earlier imports. Hashes written by earlier versions into the email authentication method are moved by the upgrade into the platform credential store when the address was verified, and dropped when it was not. They are not copied into any project credential: send the passwords again with an import of the project.
  • Counters. The import result reports passwordsImported, passwordsUpdated, passwordsUnchanged and passwordsSkippedExisting (the person owns the project credential).
  • Verification. The email method stays unverified, as for any imported email identity. Platform login accepts an unverified method only for the configured verification window after it was created.
  • The job payload. The hash is in the stored payload while the job is pending or running, because the worker reads it from there, and is replaced by [redacted] when the job completes, fails or is cancelled. The payload download always shows [redacted], so a downloaded payload must be given its hashes again before it is re-imported. A by-reference upload is deleted when the job ends (ADR 0009).

Signing in to a project app with this password is not part of this change (the project password provider is a later step of ADR 0011); see ADR 0010 and ADR 0011.

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 ​

ActionPermissionNotes
Start importProject:UpdateEnqueues async job
Start exportProject:UpdateEnqueues async job
View jobsProject:QueryList and detail
Cancel jobProject:UpdateBest-effort if already running

Scope must match the project (organization or personal), same as other project screens.

Web UI ​

  1. Open Project → Import/Export (…/projects/{projectId}/import-export).
  2. Import — pick a .json file; validated, then enqueued. Original payload stored on the job.
  3. Export — dialog with Contents and Options tabs (below). Download CDM from the job row when complete.
  4. The list polls while any job is PENDING or RUNNING.
  5. 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.

TabPurpose
ContentsWhich CDM sections to include
OptionsRe-import defaults embedded in exported mode (hints only)

Contents ​

CheckboxNotes
UsersOptional; nested User API keys (identities only, no secrets)
Roles, Groups, Resources, Permissions, TagsOmit to exclude from JSON
PermissionsRequires Resources checked
  • Selecting every section = full export (same as omitting sections filter).
  • Exporting users without roles is valid when users have direct permissions or groups assignments.

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.

RuleBehavior
Same name, job in flightReturns existing pending/running job
After completionMay enqueue again with the same name
Failed / cancelledRetry with the same name allowed

Version ​

Schema version written into the export (version, currently 1). Must match importer support.

Strategy ​

ValueOn import
mergeApply alongside existing CDM-managed rows (default)
replaceRemove CDM-managed rows, then apply; requires confirm destructive. Project-user API keys keep or rotate by fingerprint instead of delete-all.

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.

Replace API-key identity ​

On replace, CDM-managed project-user API keys are matched by importer externalKey plus a server-side HMAC-SHA256 fingerprint of the BYOK secret, stored under metadata.cdmImport.secretFingerprint (never under cdmSource).

Existing vs documentOutcome
Fingerprint equalKeep — no bcrypt, no delete, no insert
Fingerprint different or missingRotate — bcrypt-10 the new secret, keep api_keys.id and clientId
Document key with no existing rowCreate
Existing CDM key missing from the documentDelete
Key without externalKeyTear down and recreate (no keep/rotate)

The HMAC key is CDM_API_KEY_FINGERPRINT_HMAC_SECRET, resolved like other credentials (not a Lambda environment literal). Rotating or leaving it empty makes every fingerprint miss, so the next replace re-hashes. Importers cannot supply a fingerprint or a pre-computed hash. Export, snapshots, job payloads, warnings, audit values, and logs omit the fingerprint. Merge still creates new key rows. Replace fails closed if the same externalKey already exists on more than one CDM key (for example after repeated merges) instead of rebinding a secret to the first row. SyncProjectResult reports projectUserApiKeysCreated / Unchanged / Rotated / Deleted.

Sync jobs ​

Each operation is a row in project_sync_jobs (operation = import | export), run by the job adapter.

StateMeaning
pendingQueued
runningWorker active
completedSuccess
failedError on job row
cancelledOperator or system cancelled

Idempotency — optional jobName (export) or CDM id (import):

ImportExport
KeySyncProjectInput.id (often omitted)jobName (defaults to project name)
In-flight duplicateReturns same job per (project, operation, job_name)Same
After completeNew upload = new row if no idSame 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.

SurfaceContent
GraphQL job fieldshasSnapshot, snapshotTakenAt, snapshotSizeBytes
GET …/snapshotFull 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).

MethodPathRole
GET…/sync/jobsList jobs
POST…/sync/jobsEnqueue import (body = CDM JSON)
POST…/sync/jobs/exportEnqueue export
GET…/sync/jobs/{jobId}Job status
GET…/sync/jobs/{jobId}/payloadRequest JSON (import CDM or export options)
GET…/sync/jobs/{jobId}/snapshotImport: pre-sync snapshot; export: generated CDM when completed
DELETE…/sync/jobs/{jobId}Cancel

GraphQL ​

OperationPurpose
startProjectSyncEnqueue import
startProjectExportEnqueue export (sections, mode, jobName, …)
cancelProjectSyncCancel job
projectSyncJobsPaginated list
projectSyncJobSingle 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[].

EntityStorageList search
Project usersproject_users.search_documentProject-scoped user lists
Rolesroles.search_documentRole lists (searchDocument field)
Groupsgroups.search_documentGroup 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, secretFingerprint, 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:

FieldRole
metadata.cdmImportprojectId, kind, externalKey, and for API keys secretFingerprint (HMAC only; never exported)
metadata.cdmSourceTrace ids (grantRoleId, grantUserId, …) and importer-owned JSON

For a findBy: email or findBy: key user the import also writes this project's metadata.cdmImport (kind: 'user', the user's external key) on the project membership row, whether it created the identity or reused an existing one. The project-scoped users read merges the global user's metadata with the membership row's (the row wins), so a project that imported a user always sees its own marker, even when the identity pre-existed and its global metadata carries none or another project's. The global metadata of a reused identity is never changed. The findBy: key and findBy: email lookups of an earlier import's user (findUserIdByCdmImport) read both places: the global user's marker first, then the live membership row's of this project, for a live user only. A marker on another project's row never matches.

user.deleted and project.user_removed webhook events name the user's import key for the receiving project in data.before.externalKeys. The key is the membership row's marker externalKey, or, when that is an e-mail address (an identity keyed by findBy: email always is), the importer-declared metadata.legacy.id of the user (stored under cdmSource.legacy.id): an importer that wants a deleted or removed user to be nameable from the event declares its own id there. Nothing else under cdmSource is read, and a value that looks like an e-mail address is never emitted. See External keys of a removed user.

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

Released under the MIT License.