Migrating a Tenant to Synced Config
This is the procedure for taking one tenant from inherit-on-read (config merged in from the control plane on every request) to synced (the tenant reads only its own database). Do it for a pilot tenant first, then use fleet rollouts for the rest.
Background: per-tenant sync mode, syncing a resolved parent chain and shadow mode.
Before you start
Pick the pilot. A tenant you can afford to look at closely: real traffic, but a tenant whose owner you can reach. Prefer one with a Workers for Platforms worker if that is where you are heading, since that exercises the dispatch transport.
Checklist. Every item must be true before step 1.
- The control plane has the tenant operations adapters (
GET /api/v2/tenants/{id}/operationsanswers) and async-defaultsexecutor. For a tenant with its own worker and database,syncDefaults: createDispatchSyncConfig({...})is passed toinitMultiTenant. If the control plane encrypts at rest, it also passes the layer that returns secrets as stored asstoredDataAdapter; without it step 3's status showsdrifted: truefor a tenant that is in sync. The Workers for Platforms guide sets all of this up. WFP_INTERNAL_SYNC_SECRETis set to the same value on the control plane and the tenant worker. A mismatch answers401.- The tenant worker has
TENANT_IDset (a push for another tenant is refused with403 sync_config_tenant_mismatch). - The tenant worker has
CONTROL_PLANE_ENCRYPTION_KEY, so it can decrypt secrets the control plane encrypted. Without it the push stops with422 sync_config_key_ringand nothing is written. - Shadow mode (
inheritanceShadow) is on for the host that serves the pilot's traffic. It runs where the fallback adapter runs. A pilot served only by its own worker needs it in the worker:createWfpTenantApp({ inheritanceShadow: true, ... }), whosecfgdifflogs land in the tenant's own logs and are read through the same API. Either way, Verify in step 3 is the check that does not depend on traffic. - The tenant is on the parent you intend: check
parent_tenant_id(empty means the control plane). - You hold a control-plane token. Only the control plane can change
config_sync.
Know what is not synced. Look at these before the dry run, because the sync will not change them:
- Email provider and branding have no id to mark a synced copy by, so a row the tenant already has is only replaced when the parent locks it (
override: noneorenforce). Otherwise it stays, and a later edit on the parent does not reach the tenant. Prompt settings follow the same rule, and are applied only when locked. - A connection's
enabled_clientsis not copied (it holds the parent's client ids). When a synced connection is first added, it is enabled on the tenant's clients that list their connections explicitly; clients without a list already use every connection. Later syncs leave the tenant's choice alone, so enable or disable it per client afterwards if you want something else. - A row the tenant already has with the same strategy and name (connections), identifier (resource servers) or name (actions) wins: no synced copy is added next to it, and the dry run lists
skip entity:key (the tenant has its own). Rename or remove one side if you want the parent's row instead. inherit: trueaction stubs only resolve against the control plane while the tenant isinherit-on-read, and client URLs from the control plane's client are no longer merged once it issynced. Copy what the tenant needs into the tenant itself.- Platform-scoped rows are copied so they can run, but are hidden from the tenant's management API.
1. Dry run
POST /api/v2/tenants/{id}/operations
{ "kind": "sync-defaults", "input": { "dry_run": true } }Read result: status (dry-run), per-entity counts (added, changed, unchanged, deleted, errors) and changes, the list of entity:key that would change. It shows which rows change, never their content or secrets, so there is no field-level diff; compare content on the control plane if a row looks surprising.
Expect added for everything the tenant inherited. Investigate:
- any
errors; skip ... (the tenant has its own)lines (a name collision from the list above);- anything you did not expect to be published (check its
scopeon the control plane).
The same preview is in the admin UI on the tenant's Operations page (Preview sync).
2. Sync with inheritance still on
Run the operation without dry_run. The tenant is still inherit-on-read, so nothing changes for its users yet: the synced rows exist, and read-time inheritance still serves requests.
The operation must end succeeded. If it ends failed, read error and the failed event in GET /operations/{id}; the tenant's stored hash is only updated by a fully successful run, and retrying is safe. A partial run leaves the rows it applied in place.
3. Verify
Do all of these before flipping.
Status.
GET /api/v2/tenants/{id}/config-syncshowsdrifted: falseand equalconfig_sync_hashandexpected_hash(the admin UI shows this as In sync). The hash only says the parents have not moved since the last sync, not that the tenant's own rows still match.Verify. The hard check, on demand and read-only:
httpGET /api/v2/tenants/{id}/config-sync/verifyok: true(nodifferencesand noerrors) is the pass criterion (Verify on the tenant's sync status card in the admin UI). A tenant that inherits on read is compared the way shadow mode does, without the rows only the sync model copies. A synced tenant is checked with a sync dry run against its own rows, so a copy someone deleted (missing), edited (changed) or that the parents no longer publish (stale) shows up although the hash matches. For a tenant with its own database the dry run goes through the same transport assync-defaults(syncDefaults), and the call answers502if the worker cannot be reached. A row the tenant owns that wins over the parent's is not a difference. Only ids and field names are returned, never content or secrets.Shadow. Let the pilot take normal traffic, then look for differences between what read-time inheritance would give and what the synced config gives:
httpGET /api/v2/logs?q=type:cfgdiffwith the pilot's
tenant-id. An empty result after a representative period is supporting evidence. Each entry names thekind(missing,extra,changed), entity and key, never content. Shadow is sampled and rate-limited, so absence is evidence over time rather than proof (use Verify above for that), andextraentries for hooks can be noise from the synced copies: confirm them against the hook list.Smoke tests. Log in with each connection the tenant uses, request a token for each resource server, trigger each inherited hook, and confirm each hook runs once.
4. Flip
curl -X PATCH https://auth.example.com/api/v2/tenants/settings \
-H "authorization: Bearer $CONTROL_PLANE_TOKEN" \
-H "tenant-id: $TENANT" -H "content-type: application/json" \
-d '{"config_sync": "synced"}'Or Settings → Advanced in the admin UI. The instance that handles the request drops its remembered mode at once; every other instance notices within the mode cache time: 10 seconds by default, or the configSyncCacheTtlMs you set in initMultiTenant. Wait that time plus a margin before judging the result. If the auth server runs with a cache, logins can take longer: the client bundle (the tenant's client, connections and settings) is kept for 5 minutes and may be served up to 10 minutes more while it refreshes, and purging it on a tenant update is best effort. On such a deployment give the flip up to 15 minutes, or purge the cache, before the smoke tests.
Then repeat the smoke tests from step 3. The tenant now reads only its own database.
5. Watch, and keep the rollback window
For at least a few days (pick the length with the tenant's owner), watch:
GET /logs?q=type:cfgdriftfor the tenant: a drift entry means the parent changed and the tenant has not been brought up to date. With the scheduled handler wired, a rollout fixes it; otherwise run async-defaultsoperation.- The tenant's operations history (
GET /tenants/{id}/operations?kind=sync-defaults) for failed runs. - Login and hook error rates for the tenant, as you would for any release.
Rollback. Set config_sync back to inherit-on-read (same call as the flip). Read-time inheritance resumes. Know what stays behind:
- The synced rows (
syids) remain in the tenant. They are not removed. - An inheritable hook is not run twice: the original is skipped when the tenant holds its synced copy.
- Changes made on the control plane while the tenant was
syncedare not applied to the copies. Runsync-defaultsagain (it still works ininherit-on-read) if you want them current. - The copies stay read-only to the tenant's admin, according to their
overrideandenforce.
Do not delete the synced rows by hand during the window; it makes a later re-sync add them back.
Success criteria
The pilot is migrated when: the sync ended succeeded, drifted is false, verify answers ok, the shadow comparison showed no unexplained difference, the smoke tests pass in synced mode, and the watch period ended with no regression. Record the date, the operation id and what you found, then move on to a rollout for the next tenants, starting with a canary.
Not supported yet
- Re-parenting. Changing
parent_tenant_idof a synced tenant changes the hash, and the next sync adds the new parent's rows and updates shared ones. Rows only the old parent published stay behind until you run a sync withprune(preview it first). To leave a parent for good, detach the tenant instead. - Automatic delete propagation. A row removed or unshared on the parent is removed from a synced tenant by a sync with
prune(Remove unshared on the tenant's Operations page), never by a plain sync. A preview always lists what would go.
These are tracked as follow-up issues of the config inheritance epic.
Detaching a tenant
A synced tenant leaves its parent with a detach tenant operation (Detach on the tenant's Operations page). The tenant has to be config_sync: "synced": detaching keeps what the tenant holds, and a tenant that inherits on read holds none of it, so it would lose what it inherits. The operation refuses such a tenant, and the admin does not offer it. Run Preview detach first, with the same options you will use (Remove inherited for clean): it lists every row as convert, remove or skip and writes nothing.
- Keep (the default). The copies a sync delivered become the tenant's own rows: the content is kept, the ids are normal, and
scope,overrideandenforceare dropped, so nothing locks them. Hooks run the converted code, and the tenant's clients and organizations that used a copied connection use the new row. - Clean (
clean: true). The copies are removed instead, platform rows included, and the clients' connection lists are cleaned up. Connections, resource servers, actions, hooks and hook code are covered; see the next point for what is not. - Email provider, branding and prompt settings have no id to mark a synced copy by, so detach cannot tell a copy from the tenant's own row. Neither mode touches them: a copy a sync delivered stays as it is, with its
scope,overrideandenforce, andcleandoes not remove it. A platform email provider stays hidden from the tenant's admins. - Platform rows (
scope: "platform") run for the tenant but are hidden from its admins. Converting them would expose their code and secrets, so a plain detach keeps them as copies and reportsskip ... (platform: stays a copy). Onlycleanremoves them. - The tenant gets
config_sync: "detached", no parent and no sync state. It no longer inherits, andsync-defaults, rollouts and the drift check leave it alone. To attach it again, setconfig_syncandparent_tenant_idback and run a sync.
Rows are created before the copies they replace are removed, but the operation is not atomic: if it fails part-way the tenant is not marked detached, and a retry can leave a duplicate of a row that was already converted, so review the list afterwards. SCIM configurations that point at a copied connection are not moved. Connection order on a client's login page is not kept for converted connections.
Tenants with their own database
For a tenant with its own worker and database (deployment_type: "wfp"), pass detachTenant: createDispatchDetach({ dispatcher, internalSecret, controlPlaneAdapters }) (from @authhero/cloudflare-adapter/wfp) to initMultiTenant, or use it in a Workflows detach operation (runDetachOperation). The control plane checks that the tenant can be detached from its own tenant row, pushes to the tenant worker's /internal/detach (the same WFP_INTERNAL_SYNC_SECRET and TENANT_ID checks as /internal/sync-config), and marks its own row detached only when the worker reports that every row went through. A preview or a partial result leaves the control-plane row as it was, so a retry can finish the job. The worker converts the rows in its own database and marks its own copy of the tenant row.
Route by deployment_type in the host, as for syncDefaults: a custom detachTenant replaces the in-process detach for every tenant, so shared-database tenants must still go through createInProcessDetach. The worker needs a detach route as well when it is not built with createWfpTenantApp.