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 |
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 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, 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:
| 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; with grantUserId it reuses a named existing Grant user instead of creating one. |
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 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.
{
"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
grantUserIdnaming 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 nograntUserIdand 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. AgrantUserIdis also refused (naming the position) when it is not a UUID or when the user is not identified byfindBy: key: withfindBy: idthe value already is the id, and withfindBy: emailthe 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,groupsorpermissions) 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.cdmImportmarker 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
replaceimport 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: idreference, not as a keyedusers[]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/accountsor thedeleteMyAccountsmutation), 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 afindBy: emailidentity 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 inusers[](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_tombstonesby a database operation until a later release adds a mutation. There is no API to list or read tombstones; theuser.deletedevent 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:
{
"key": { "value": "ada@example.com", "findBy": "email" },
"name": "Ada Lovelace",
"password": { "algorithm": "bcrypt", "hash": "$2a$10$…" }
}- Format.
algorithmmust bebcrypt.hashis a 60-character bcrypt hash with a$2a$,$2b$or$2y$prefix, such as the output of Go'sbcrypt.GenerateFromPassword, with a cost between 4 and 15. Anything else, and apasswordon a user not identified byemail, 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 anypasswordHashyour ETL previously put intometadata: 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,groupsorpermissionson 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 inusers[]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 countspasswordsSkippedExisting. An absentpasswordnever removes a stored credential, andreplacemode 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,passwordsUnchangedandpasswordsSkippedExisting(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
| 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. 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 document | Outcome |
|---|---|
| Fingerprint equal | Keep — no bcrypt, no delete, no insert |
| Fingerprint different or missing | Rotate — bcrypt-10 the new secret, keep api_keys.id and clientId |
| Document key with no existing row | Create |
| Existing CDM key missing from the document | Delete |
Key without externalKey | Tear 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.
| 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, 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:
| Field | Role |
|---|---|
metadata.cdmImport | projectId, kind, externalKey, and for API keys secretFingerprint (HMAC only; never exported) |
metadata.cdmSource | Trace 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
- 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