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:
| Column | Purpose |
|---|---|
idempotency_key | UUID supplied by the caller |
service | crm, warehouse, etc. — keys are scoped per service |
response_status | HTTP status to replay |
response_body | Serialized JSON body to replay |
expires_at | now() + 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):
- Skip the table if it doesn't exist on the target schema.
- Skip the table if it has no
tenant_idcolumn (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 keepstenant_idbecause it spans tenants by design). - Read source rows in 500-row chunks.
- For each row:
- Mint a new UUID
id, recordoldId → newIdin$idMap[$table]. - For every
_id-suffixed column, look up the prior$idMapto 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).
- Mint a new UUID
DB::table(...)->insertOrIgnore($chunk)— duplicates (from a resumed clone) are silently skipped.- 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:
| Column | Value |
|---|---|
idempotency_key | as supplied |
service | crm |
actor | system-token caller identifier |
from_tenant_id | source UUID |
to_tenant_id | target UUID |
table_name | one row per cloned table |
rows_copied | count for that table |
started_at / completed_at | timestamps |
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-fromagainst 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
| Failure | Behavior |
|---|---|
| Source = target | 422, no DB writes |
| Idempotency key duplicate within 24h | Cached response replayed (200), no work re-done |
| Table missing on target | Skipped silently, audit row still written |
Table exists but no tenant_id column | Skipped silently (probably a pivot / lookup that's not multi-tenant) |
| FK target not yet cloned | Best-effort — the column stays as-is; downstream readers may see a dangling reference |
| Chunk insert fails mid-table | Per-chunk transaction rolls back; helper logs + bubbles a 500 |
Files
| File | Role |
|---|---|
app/Support/TenantClone.php | The helper itself (300+ lines) |
app/Http/Controllers/SystemController.php (cloneFrom) | HTTP entry-point + validation |
database/migrations/2026_05_13_000001_create_tenant_clone_tables.php | Both 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_iddiscriminator on these audit tables (the helper bridges across tenants; the audit log must too)