Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersFull-fidelity tenant export and the deletion certificate

Full-fidelity tenant export and the deletion certificate

Two related, but separate, guarantees:

  1. A tenant can export every model's tenant slice, in a documented JSON format, on demand.
  2. Offboarding produces a deletion certificate — a document that states exactly what was requested, confirmed and executed, and is explicit about what it does not claim.

Removing the lock-in objection only works if the completeness claim is provable, not asserted. Both halves below exist to make "full-fidelity" checkable rather than trust-me.

The export

POST /data-export (Owner role) generates a JSON archive of the calling tenant's data and returns a 24-hour download URL. The archive includes every tenant-scoped table by default: a new table is part of the export unless it is deliberately withheld, and every withheld table is listed in the archive with its reason.

What is withheld. Authentication, session and credential material is not customer-authored content and is never exported: refresh tokens, directory (LDAP) and SAML configuration secrets, inbound-webhook secrets, API keys and idempotency records. A table that belongs to the platform rather than to any single tenant (for example the shared auditor directory profiles) is also excluded and listed. Tables that belong to the tenant but are reached through a parent record (for example invoices, which belong to a contract) are included.

GET /data-export/plan returns the coverage manifest (included, parent-scoped, excluded and platform-excluded tables, with reasons and no row data) so the scope is inspectable before you run a full export.

Archive shape (exportVersion: "2.4")

json
{
  "exportedAt": "2026-08-22T00:00:00.000Z",
  "exportVersion": "2.4",
  "format": {
    "documentation": "docs/developers/data-export-format.md",
    "modelsIncluded": 182,
    "modelsExcluded": 8,
  },
  "organization": {
    "id": "...",
    "name": "...",
    "slug": "...",
    "industry": "...",
    "tier": "...",
    "status": "...",
    "createdAt": "...",
  },
  "members": [
    {
      "role": "OWNER",
      "joinedAt": "...",
      "user": { "id": "...", "email": "...", "name": "...", "createdAt": "..." },
    },
  ],
  "models": {
    "risks": [/* raw rows */],
    "invoices": [/* raw rows, resolved via the parent contract */],
    "...": "one array per included table, keyed by its database table name",
  },
  "excludedModels": [{ "table": "refresh_tokens", "model": "RefreshToken", "reason": "..." }],
  "proofLog": { /* log proofs for exported receipts; see "Transparency-log proofs" below */ },
  "configBundle": {
    /* redacted configuration record; see "The configuration bundle" below */
  },
  "seal": { /* G17 — always the LAST member; see "The export seal" below */ },
}

The modelsIncluded and modelsExcluded counts grow as new tenant-scoped tables ship. Do not treat a specific count as a permanent fact: read it from your own export's format field, or from GET /data-export/plan.

organization and members are curated views. members carries a deliberately narrow user profile (id, email, name, created date) because one person can belong to many tenants, and a raw user row would fall outside "this tenant's data". The raw membership rows (role, timestamps, business unit and custom role) are still exported under models.org_memberships; the two are views of the same underlying rows.

Binary columns (exportVersion: "2.2")

A few included tables carry binary data rather than text: a passkey's public key, a tenant's published proof-receipt verification key, and a proof receipt's signed bytes, signature and evidence manifest. Both export formats are text (JSON and CSV), so a binary value is rendered as an explicit marker object rather than the raw bytes:

json
{ "$bytes": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..." } // base64 of the exact original bytes

In the JSON archive this is an ordinary nested object at that column's key. In the CSV bundle it is the same object, JSON-encoded into that column's one cell (see "Cell format" below). Which fields get this treatment is decided by the column's declared type, never guessed from a value's shape.

Archives taken before exportVersion: "2.2" that touched a binary column carry a numeric-key object ({"0":48,"1":130,...}) instead of the marker. Re-import accepts both shapes, validating that the keys are a contiguous run of 0 to 255 integers before treating the value as bytes. A value that is neither shape is refused with the registered error TENANT_IMPORT_INVALID_BYTES, naming the table, field and row; it is never a best-effort guess. The private signing-key table is never exported.

The export seal (seal, exportVersion: "2.4")

The JSON archive of every on-demand export ends with a seal member, so the hosted service can restore an export that it produced for your workspace, and nothing else. The CSV and files ZIPs are not sealed and cannot be restored.

json
"seal": {
  "version": "cmdash-export-seal/1",
  "keyId": "<which deployment key sealed it>",
  "sealId": "<uuid>",
  "nonce": "<base64url of 32 random bytes>",
  "tenantId": "<source organization id>",
  "exportJobId": "<export job id>",
  "exportVersion": "2.4",
  "exportedAt": "<ISO-8601>",
  "digest": "<sha256 hex of the canonical archive without seal>",
  "rowCounts": { "<table>": 0 },
  "mac": "<base64url keyed MAC over the seal fields>"
}
  • Digest. SHA-256 over the canonical form of the parsed archive without seal: object members sorted by name, no whitespace, values written as JSON.stringify writes them.
  • Verification. Only the deployment that sealed an export can verify it, and nobody can forge a seal without that deployment's secret. A seal is single-use, expires, and is bound to the workspace that exported it.
  • Restore. Restoring needs an owner, a dry run, a fresh sign-in and, for a new workspace, a one-time code sent to the owner's address. Exports taken before this version carry no seal and cannot be restored: export again. The user guide describes the steps.
  • If sealing is unavailable the archive is still returned, unsealed, and cannot be restored on the hosted service.
  • The importer accepts archives of versions 2.0 to 2.4 and ignores seal.

Transparency-log proofs (proofLog, exportVersion: "2.3")

models.proof_receipts carries each receipt's exact signed bytes and its logIndex. The platform's log tables are not any tenant's rows and stay in excludedModels with reasons. So that a receipt stays checkable after the archive leaves, the archive adds a top-level proofLog.entries array, one element per exported receipt:

  • status: "PROVEN" with section (the log section: index, treeSize, rootHash, proof, checkpoint). The path is verified against the signed root before it is returned.
  • status: "NOT_LOGGED": no signed checkpoint covers the receipt yet.
  • status: "UNAVAILABLE" with a reason: the log could not produce a verified proof. It is never rendered as proven.

The section is taken against the log's latest checkpoint at export time. The key is additive: the importer reads only models, accepts 2.0 to 2.3, and ignores proofLog. The tenant's private signing key is never exported. The public statement of all this is the Exit Guarantee page. Re-import is available on self-hosted and licensed deployments only.

The download URL returned when the export is created is signed once and expires. Use the download routes, which sign a fresh link on every call:

  • GET /data-export/:id/json
  • GET /data-export/:id/csv

Both require the Owner or Admin role, and both return 404 rather than a link to an object that does not exist.

The configuration bundle (configBundle)

Six tables are withheld from models as credential material; four of them also hold the tenant's configuration, so a customer reading their own archive would otherwise find their SSO setup simply absent. configBundle is the redacted record of it: configBundle in the JSON archive and config-bundle.json in the CSV zip.

Sections: sso.saml, sso.scimGroups, directory.ldap, integrations.connections, integrations.inboundWebhookSecrets, notifications.channels, notifications.alertRules, outboundWebhooks, apiKeys, frameworkScope, customFields.

No credential column is ever read for the bundle, so a secret is never in it. Free-form configuration values whose key names a secret are replaced with [redacted], with the keys kept. The bundle's own redaction block and the CSV README restate the list.

Two caveats worth repeating wherever this is described:

  1. Notification channels and integration connections are ordinary tenant tables, so their config JSON is also in models unredacted. configBundle is the readable view, not a filter over the rest of the archive. Treat every archive as sensitive.
  2. It is a record, not a restore artifact. Import reads models and nothing else, so configBundle has no effect on import. The data-import guide describes what a configuration restore involves.

The bundle's version is tracked separately from exportVersion, so a bundle-only change does not invalidate an archive for the importer's version check.

Row cap

Each table is capped at 20,000 rows per export, which keeps one export job's memory and JSON size bounded while being generous for essentially every tenant.

The CSV export

A second output mode, produced in the same job as the JSON archive, from the same collected snapshot. Both formats are serializations of one answer to "what is this tenant's data", so they can never disagree about which tables or rows exist at a given exportVersion.

Shape: a ZIP containing:

  • <table>.csv — one per table in the archive's models (exactly the set GET /data-export/plan lists), even when that table has zero rows for this tenant, so a table is never silently absent.
  • organization.csv / members.csv — CSV renderings of the same two curated views described above.
  • README.txt — format notes, plus the same exclusion list (table and reason) as the JSON archive's excludedModels, so a CSV-only consumer sees the exclusions stated as plainly as the inclusions.
  • config-bundle.json — the configuration bundle described above.

Cell format: RFC 4180 (comma-separated, quoted where needed, doubled internal quotes, CRLF line endings). A column whose value is JSON, an array or a nested object is rendered as that value's JSON text in one cell, not expanded into further columns, which is lossless and matches what the JSON archive calls that column. A binary column is the same rule applied to the {"$bytes": "<base64>"} marker. Dates are ISO 8601 UTC strings; null is an empty cell (indistinguishable in the CSV from a stored empty string, as in the JSON archive).

Versioning: the CSV bundle does not carry its own version number; its shape is tied to the JSON archive's exportVersion.

Route: GET /data-export/:id/csv returns { downloadUrl } for a completed job's CSV file. An export created before the CSV output existed returns a clear 404 ("request a new export") instead of a link to a file that was never written.

See also: OSCAL export

OSCAL export (GET /oscal/:frameworkSlug/{system-security-plan, component-definition,assessment-results}) is a different, narrower artifact from everything above: one federal-framework submission document (SSP, component definition or assessment results) for a framework a tenant has assessed itself against, not a full-tenant portability format. It does not attempt to force CMDash's generic business tables (risks, vendors, policies, incidents) into OSCAL's schema, and every field CMDash cannot derive is stamped [PLACEHOLDER — REQUIRES HUMAN COMPLETION] rather than invented. The framework coverage page lists which frameworks support it.

Neither format replaces the other; "the export" above means the full-tenant JSON/CSV archive unless stated otherwise. OSCAL export is reached through the API, as the data portability guarantee page states.

The import

Re-import of an archive is available on self-hosted and licensed deployments. It imports into the caller's own tenant (POST /data-import, Owner role), with a dry-run POST /data-import/preflight that reports every blocker at once and writes nothing. Preflight checks conflicts, seat quota and format version. It cannot predict failures that only appear while inserting (a dangling reference, a malformed binary value); those surface during the apply.

Accepted format versions. Versions 2.0, 2.1 and 2.2 are accepted (and the 2.3 proofLog key is ignored), not only the current one, so an older archive you already hold remains restorable. They carry an identical models section, which is all the importer reads.

The import policy:

  • Conflict policy: a non-empty target is refused (409). Merging two independent id spaces is a much larger design; refusing is always safe to retry against a clean tenant.
  • Quota: the plan's seat limit is checked against the archive's membership row count before anything is written.
  • References: every tenant-scope identifier is remapped to the target tenant. Any reference the importer cannot resolve to something in the archive fails the import loudly rather than silently importing a row that still points at the source tenant.
  • File references. An archive names stored files (evidence attachments, document-room items, contract documents, scheduled report artifacts and similar). The importer copies rows, never files, so a reference is kept only when it names an object in the target tenant's own storage. Anything else is dropped: the field is emptied, the row is still imported, an evidence row records the drop, and the preflight and the apply report the count per table.field (droppedFileReferences).
  • Integrity: database foreign-key constraints stay enforced throughout, so a dangling reference outside the archive fails the import.
  • Progress and failure reporting follows the same lifecycle as an export job (pending, processing, complete, failed).

The deletion certificate

Closing an organization produces a certificate and requires an explicit, hard-to-trigger-by-accident confirmation before it fires. It builds on the existing organization lifecycle (see the organization status guide), which is the mechanism that puts an organization into the deleted-pending state; it is not a second, parallel deletion path.

A customer's closure request is handled by a CMDash platform administrator (multi-factor authentication required), and that administrator's action produces a certificate instead of a silent status change.

The flow

  1. POST /admin/organizations/:id/deletion-certificate/request creates a REQUESTED certificate and a single-use confirmation token (15-minute lifetime). It changes nothing else: calling it any number of times never moves the organization's status.
  2. POST /admin/organizations/:id/deletion-certificate/:certificateId/confirm requires both the token from step 1 and the organization's slug typed back verbatim. A wrong token or slug is rejected without confirming, and is counted against a 5-attempt limit before the certificate is auto-cancelled. On success, the organization status changes exactly as it does from the plain admin status control (sessions revoked, audit row written), and the certificate is stamped CONFIRMED with its PDF rendered and stored. If the status transition itself fails, the certificate is rolled back to REQUESTED with a fresh token rather than left stuck.
  3. POST /admin/organizations/:id/deletion-certificate/:certificateId/execute records that CMDash operations actually ran the manual purge procedure. This step does not delete any data itself.
  4. POST .../:certificateId/cancel withdraws a still-REQUESTED certificate; no token is required because nothing has happened yet to undo.

Every route requires a multi-factor-authenticated platform administrator, plus the two-call token-and-slug requirement above, so a single stray authenticated call cannot delete anything.

What the certificate claims, and what it explicitly does not

It does not claim to have run an automatic data purge. A CONFIRMED certificate's PDF says exactly that: the organization is in the deleted-pending state (all authentication and API access is blocked), and the tenant-scoped data enumerated under "Models in scope" has not been purged by the confirmation itself, because CMDash does not run an automated per-tenant deletion sweep. Removal is a manual operations procedure, and the execute step is where that procedure's completion is recorded, after the fact.

Retained items, stated on every certificate:

  • The certificate record itself survives the purge it documents. It has no link to the organization record, so it is the one record a customer or CMDash support can still point to after everything else about the organization is gone.
  • Database backups and snapshots taken before the purge may persist independently for a period governed by CMDash's backup practice. The certificate does not track or control backup expiry, and does not claim a specific retention window it cannot verify against the live deployment.
  • Everything else, including the organization's own audit log, is not exempt. Every table under "Models in scope" (the same list the export uses), audit logs included, is deleted by the purge once it runs. The certificate is explicit that nothing about the organization survives in the live database except itself.

Access to certificates

Certificates are tenant-scoped like every other table: the organization's own Owner can read its historical certificates, and platform administrators read and write across organizations under a platform-admin scope.

This page is the product manual, published from the repository at docs/developers/data-export-format.md. Customers read the same text inside CMDash under Help. Last updated October 9, 2026.

Full-fidelity tenant export and the deletion certificate — CMDash Docs