Skip to main content

Tenant clone helper

App\Support\TenantClone is the cross-service helper that powers SystemController::cloneFrom — the system-token-protected endpoint used during tenant provisioning to seed a freshly-spun deployment with data from a template tenant. It landed in batch B16 alongside two new audit tables.

This is system-token traffic only: no user context, no auth_user, no policy gate. The trust boundary is the VerifySystemToken middleware (hash_equals constant-time compare, cross-repo #40).

Where it runs

[provisioner] ── POST /api/system/clone-from ──▶ [SystemController::cloneFrom]


App\Support\TenantClone::execute(
fromTenantId,
toTenantId,
tables[],
idempotencyKey,
actor,
service,
rowMutator?,
)

The helper is service-agnostic: the same class ships in tenant-backend-crm, tenant-backend-warehouse, and tenant-backend-webshop so each service can offer a uniform clone API. The caller passes a service-specific tables[] ordering — FK-safe, parents first.

Idempotency

Every call requires a UUID idempotency_key (validated by the controller). The helper checks the tenant_clone_idempotency table:

ColumnPurpose
idempotency_keyUUID supplied by the caller
servicecrm, warehouse, etc. — keys are scoped per service
response_statusHTTP status to replay
response_bodySerialized JSON body to replay
expires_atnow() + 24h

A replay returns the cached body with replayed: true added. After 24h the row expires and the same key becomes free for reuse — this is intentional: clone keys are not meant to live longer than a provisioning window.

Per-row mechanics

Inside one chunk transaction (CHUNK = 500 rows):

  1. Skip the table if it doesn't exist on the target schema.
  2. Skip the table if it has no tenant_id column (this is the single discriminator the helper relies on — note the contrast with CRM's main app code, which dropped the column; the clone audit layer keeps tenant_id because it spans tenants by design).
  3. Read source rows in 500-row chunks.
  4. For each row:
    • Mint a new UUID id, record oldId → newId in $idMap[$table].
    • For every _id-suffixed column, look up the prior $idMap to remap (best-effort — a foreign key into a yet-uncloned table is left as-is).
    • Set tenant_id = $toTenantId.
    • Apply optional $rowMutator($row, $table) (used by webshop for domain-specific rewriting).
  5. DB::table(...)->insertOrIgnore($chunk) — duplicates (from a resumed clone) are silently skipped.
  6. Per-chunk transaction so a failure mid-table doesn't half-write.

The choice to mint new UUIDs (vs preserve source IDs) is deliberate: collisions across template tenants would otherwise force the caller to know the source's UUID space. The trade-off is that external references to source IDs don't survive — the target is a fresh copy.

Audit

Every successful run writes one row per table to tenant_clone_audit_log:

ColumnValue
idempotency_keyas supplied
servicecrm
actorsystem-token caller identifier
from_tenant_idsource UUID
to_tenant_idtarget UUID
table_nameone row per cloned table
rows_copiedcount for that table
started_at / completed_attimestamps

Failures also write a final row with the error message in error_message and rows_copied = 0 for the failing table.

Calling shape

The controller-level entry-point:

POST /api/system/clone-from
Headers: X-System-Token: <constant-time-shared-secret>
Body: {
"from_tenant_id": "01J6...",
"to_tenant_id": "01J6...",
"idempotency_key": "01J6..." // caller-minted UUID
}

Response (first call):

{
"success": true,
"copied": { "contacts": 1024, "companies": 318, "...": "..." },
"status_code": 200
}

Response (replay within 24h):

{
"success": true,
"copied": { "contacts": 1024, "companies": 318, "...": "..." },
"status_code": 200,
"replayed": true
}

Source = target returns 422 immediately:

{
"success": false,
"error": "source and target tenants must differ",
"status_code": 422
}

What CRM clones

The CRM tables[] ordering is defined in SystemController — roughly: lookup tables and roles first, then primary resources (contacts, companies, projects, ...), then dependent tables (tasks, communication_logs, ...), then pivot tables last.

Audit-trail tables (activity_logs, task_history) are not cloned — the target tenant's history starts fresh.

What it explicitly does NOT do

  • No cross-service coordination — cloning warehouse data is a separate POST /api/system/clone-from against the warehouse service. The orchestrator (provisioner) is responsible for fan-out
    • retry.
  • No schema migration — the target schema must already be migrated; the helper assumes table parity.
  • No file copy — chat attachments and document blobs live on S3 / filesystem and are out of scope. A separate object-store clone is required if those need to come along.
  • No identity copy — users / tenants / policies are an identity concern; provisioning seeds those upstream of the clone call.

Failure modes

FailureBehavior
Source = target422, no DB writes
Idempotency key duplicate within 24hCached response replayed (200), no work re-done
Table missing on targetSkipped silently, audit row still written
Table exists but no tenant_id columnSkipped silently (probably a pivot / lookup that's not multi-tenant)
FK target not yet clonedBest-effort — the column stays as-is; downstream readers may see a dangling reference
Chunk insert fails mid-tablePer-chunk transaction rolls back; helper logs + bubbles a 500

Files

FileRole
app/Support/TenantClone.phpThe helper itself (300+ lines)
app/Http/Controllers/SystemController.php (cloneFrom)HTTP entry-point + validation
database/migrations/2026_05_13_000001_create_tenant_clone_tables.phpBoth audit tables

Where to learn more

  • tenant-backend-crm/AUDIT_FINDINGS.md § Batch B16 — the hardening rationale and pre/post diff
  • security.md § token validation — system-token verification path
  • multi-tenant.md — why the per-deployment model still keeps a tenant_id discriminator on these audit tables (the helper bridges across tenants; the audit log must too)