Skip to main content

GET /export

GET /admin/tenants/{tenant}/export requires manage-tenant and manage-clients — the second because the document carries every client, which every other route reads with manage-clients — and answers the tenant's configuration as one document of media type application/vnd.odudu.tenant+json, carrying "version": 1: its settings, its authentication flow, its clients, its roles and their composites, its groups and their roles, its scopes with their role mappings, claim mapper bindings and client assignments, its registration policy, and its SMTP host, port, sender, username and STARTTLS. Every reference inside it is by name — a role as its name and the client_id it belongs to, null for a tenant role; a group by its path; a scope by its name; a client by its client_id — and no row id, tenant id or timestamp appears anywhere, so the document means the same thing in whichever tenant it is imported into. registration_allowed, verify_email and client_registration_policy travel under registration_policy; every other tenant setting is under settings.

No secret is in it: no client secret, SMTP password, password hash, TOTP seed, passkey, recovery code or private signing key, and no session, consent, grant, registration token or audit row. Each secret a reader would expect is named under omitted by its JSON path instead — clients[<i>].secret for each confidential client, smtp.password when one is set and subjects[<i>].credentials for each exported subject that has any — so the gap is visible in the file rather than silent. A client's jwks is exported with any private member (d, p, q, dp, dq, qi, k) removed from each key, and each key that lost one is named under omitted as clients[<i>].jwks.keys[<j>]. Registration and POST/PATCH /clients refuse such a key (the request paths shows the refusal), so only a row stored before that check can hold one; none in this tenant does, so no stripped key appears below.

What a new tenant provisions for itself is marked rather than left out: each role on the built-in odudu-admin client and each default scope (openid and the rest) carries "builtin": true, so an import can merge onto its own copies. The odudu-admin client itself is not among clients, and no scope lists an assignment to it: an import provisions its own. A role on it is still referenced as {"name": …, "client": "odudu-admin"}, because a tenant role may carry the same name as a capability, and a reference that dropped the client would not tell the two apart.

?include=subjects adds subjects — each user with a sign-in, its profile claims and verification flags, its direct roles, its groups by path and its required actions — and additionally requires view-users (which manage-users also reaches), refused with 403 otherwise. Above 10,000 such subjects it is refused with 413 and "type": "about:blank#export-too-large", naming P7, whose inbound provisioning is the tool for moving users in bulk; nothing here holds that many, so that refusal is not shown — an integration test covers it. Any other include is refused with 400. Every export writes a tenant.export row into the tenant's trail whose detail says whether subjects were included.

As a new admin subject ada-export2 in the system tenant, reusing the export-demo tenant an earlier run created: its confidential client billing-app, tenant role billing-reader, group finance, subject grace who belongs to it, and SMTP relay whose password is relay-password-shown-nowhere, are all still there. tenant-operator, also still there, was issued a fresh one-time password through POST /subjects/:id/password and signed back in through the tenant's own odudu-admin client, whose token is $OPERATOR_TOKEN below. RUN_START=$(date -u +%FT%T.000Z) is set first, so every trail read below is scoped to from=$RUN_START rather than walking the whole (and, on a reused tenant, already long) history of exports and refusals against export-demo.

The export, without subjects:

RUN_START=$(date -u +%FT%T.000Z)
curl -sS -D - \
-H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/export-demo/export

In a tenant also named export-demo set up the same way through the endpoints below — the confidential client billing-app, the role billing-reader, the group finance mapping it with grace in it, and the same SMTP relay — so its ids and timestamps are that run's, not those of the blocks around it:

HTTP/1.1 200 OK
x-request-id: 01a109b5-23bc-7377-9e8f-45413735b988
cache-control: no-store
content-type: application/vnd.odudu.tenant+json; charset=utf-8
content-length: 6599
Date: Mon, 05 Oct 2026 01:37:08 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"version":1,"settings":{"display_name":null,"enabled":true,"reset_password_allowed":false,"sso_session_idle_seconds":1800,"sso_session_max_seconds":36000,"password_min_length":15,"password_require_digit":false,"password_require_uppercase":false,"password_require_lowercase":false,"password_require_special":false,"password_not_username":true,"password_not_email":true,"password_history_depth":0,"password_max_age_days":0,"otp_required":false,"brute_force_max_failures":5,"brute_force_lockout_seconds":60,"brute_force_max_lockout_seconds":900,"brute_force_failure_reset_seconds":43200,"max_clients":200,"max_sessions_per_browser":25,"remember_me_allowed":false,"remember_me_idle_seconds":604800,"remember_me_max_seconds":2592000,"audit_retention_days":90,"username_editable":false,"access_token_ttl_seconds":300,"id_token_ttl_seconds":300,"refresh_token_ttl_seconds":1209600,"authorization_code_ttl_seconds":60,"login_ttl_seconds":1800,"verify_email_ttl_seconds":43200,"reset_password_ttl_seconds":300,"login_with_email":false,"audit_event_types":["admin_mutation","admin_access","authentication","session","token","credential"]},"flow":[{"authenticator":"passkey","requirement":"alternative"},{"authenticator":"password","requirement":"alternative"},{"authenticator":"otp","requirement":"conditional"},{"authenticator":"recovery-code","requirement":"conditional"}],"clients":[{"client_id":"billing-app","name":"Billing","description":null,"type":"confidential","enabled":true,"full_scope_allowed":false,"registration_origin":"operator","redirect_uris":["https://billing.example/callback"],"grant_types":["authorization_code"],"token_endpoint_auth_method":"client_secret_basic","audiences":[],"access_token_ttl_seconds":null,"id_token_ttl_seconds":null,"refresh_token_ttl_seconds":null,"client_credentials_scopes":[],"web_origins":[],"post_logout_redirect_uris":[],"jwks":null,"jwks_uri":null,"frontchannel_logout_uri":null,"backchannel_logout_uri":null,"frontchannel_logout_session_required":false,"backchannel_logout_session_required":false,"consent_required":false,"token_exchange_impersonation_allowed":false,"userinfo_signed_response_alg":null,"userinfo_encrypted_response_alg":null,"userinfo_encrypted_response_enc":null,"tls_client_auth_subject_dn":null,"client_uri":null,"policy_uri":null,"tos_uri":null,"id_token_signed_response_alg":null,"default_max_age":null,"require_auth_time":false,"service_account_roles":[]}],"roles":[{"name":"billing-reader","client":null,"description":null,"default_for_new_subjects":false,"builtin":false,"composites":[]},{"name":"manage-clients","client":"odudu-admin","description":null,"default_for_new_subjects":false,"builtin":true,"composites":[]},{"name":"manage-keys","client":"odudu-admin","description":null,"default_for_new_subjects":false,"builtin":true,"composites":[]},{"name":"manage-sessions","client":"odudu-admin","description":null,"default_for_new_subjects":false,"builtin":true,"composites":[]},{"name":"manage-tenant","client":"odudu-admin","description":null,"default_for_new_subjects":false,"builtin":true,"composites":[]},{"name":"manage-users","client":"odudu-admin","description":null,"default_for_new_subjects":false,"builtin":true,"composites":[{"name":"view-users","client":"odudu-admin"}]},{"name":"tenant-admin","client":"odudu-admin","description":null,"default_for_new_subjects":false,"builtin":true,"composites":[{"name":"manage-clients","client":"odudu-admin"},{"name":"manage-keys","client":"odudu-admin"},{"name":"manage-sessions","client":"odudu-admin"},{"name":"manage-tenant","client":"odudu-admin"},{"name":"manage-users","client":"odudu-admin"},{"name":"view-audit","client":"odudu-admin"},{"name":"view-users","client":"odudu-admin"}]},{"name":"view-audit","client":"odudu-admin","description":null,"default_for_new_subjects":false,"builtin":true,"composites":[]},{"name":"view-users","client":"odudu-admin","description":null,"default_for_new_subjects":false,"builtin":true,"composites":[]}],"groups":[{"path":"/finance","description":null,"default_for_new_subjects":false,"roles":[{"name":"billing-reader","client":null}]}],"scopes":[{"name":"address","description":null,"include_in_id_token":true,"include_in_access_token":false,"default_client_assignment":"default","consent_text":null,"display_order":0,"builtin":true,"roles":[],"mappers":[],"clients":[{"client_id":"billing-app","assignment":"default"}]},{"name":"email","description":null,"include_in_id_token":true,"include_in_access_token":false,"default_client_assignment":"default","consent_text":null,"display_order":0,"builtin":true,"roles":[],"mappers":[],"clients":[{"client_id":"billing-app","assignment":"default"}]},{"name":"groups","description":null,"include_in_id_token":false,"include_in_access_token":true,"default_client_assignment":"default","consent_text":null,"display_order":0,"builtin":true,"roles":[],"mappers":[],"clients":[{"client_id":"billing-app","assignment":"default"}]},{"name":"offline_access","description":null,"include_in_id_token":false,"include_in_access_token":false,"default_client_assignment":"optional","consent_text":null,"display_order":0,"builtin":true,"roles":[],"mappers":[],"clients":[{"client_id":"billing-app","assignment":"optional"}]},{"name":"openid","description":null,"include_in_id_token":true,"include_in_access_token":false,"default_client_assignment":"default","consent_text":null,"display_order":0,"builtin":true,"roles":[],"mappers":[],"clients":[{"client_id":"billing-app","assignment":"default"}]},{"name":"phone","description":null,"include_in_id_token":true,"include_in_access_token":false,"default_client_assignment":"default","consent_text":null,"display_order":0,"builtin":true,"roles":[],"mappers":[],"clients":[{"client_id":"billing-app","assignment":"default"}]},{"name":"profile","description":null,"include_in_id_token":true,"include_in_access_token":false,"default_client_assignment":"default","consent_text":null,"display_order":0,"builtin":true,"roles":[],"mappers":[],"clients":[{"client_id":"billing-app","assignment":"default"}]},{"name":"roles","description":null,"include_in_id_token":false,"include_in_access_token":true,"default_client_assignment":"default","consent_text":null,"display_order":0,"builtin":true,"roles":[],"mappers":[],"clients":[{"client_id":"billing-app","assignment":"default"}]}],"registration_policy":{"registration_allowed":false,"verify_email":false,"client_registration_policy":"disabled"},"smtp":{"host":"smtp.gmail.com","port":587,"from_address":"noreply@example.com","username":"mailer","starttls":true},"omitted":["clients[0].secret","smtp.password"]}

The SMTP password appears nowhere in it, nor in the export with subjects:

curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3000/admin/tenants/export-demo/export | grep -c relay-password-shown-nowhere
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3000/admin/tenants/export-demo/export?include=subjects" | grep -c relay-password-shown-nowhere
0
0

By the same rule the ceiling-removal block below shows, tenant-operator holding manage-tenant alone is refused manage-clients for either form of the export — ?include=subjects and the export without it both answer 403:

curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \
http://localhost:3000/admin/tenants/export-demo/whoami
curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \
"http://localhost:3000/admin/tenants/export-demo/export?include=subjects"
curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
http://localhost:3000/admin/tenants/export-demo/export
{"subjectId":"01a0e4c3-4083-7293-8831-26a05de70216","issuerTenantId":"01a0e4c2-de55-724f-8fb7-a92b9af1da21","capabilities":["manage-tenant"],"crossTenant":false}
{"type":"about:blank","title":"Forbidden","status":403,"instance":"01a0e5d9-ee19-720b-a216-800817e3c760"}
403 application/problem+json; charset=utf-8

An include other than subjects is refused by the generated schema, naming the parameter, in a tenant also named export-demo, created there for it:

curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3080/admin/tenants/export-demo/export?include=sessions"
{"type":"about:blank","title":"Error","status":400,"detail":"querystring/include must be equal to constant","errors":[{"path":"include","message":"must be equal to constant"}],"instance":"01a0ea4f-9898-797d-8d56-525b96b6f6ca"}

The trail, scoped to this run — the exports ($ADMIN_TOKEN's, allowed) and then the two refusals (tenant-operator's, naming manage-clients both times):

The same again, now that each row answers actor_name and actor_origin; the ids are a fresh run's, not those above:

curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/export-demo/audit?action=tenant.export&from=$RUN_START"; echo
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/export-demo/audit?action=capability.refused&from=$RUN_START"; echo
{"items":[{"id":"01a0ee8d-ef61-7b06-9e71-2e3ae736de6d","occurred_at":"2026-09-29T19:04:34.385Z","event_type":"admin_mutation","action":"tenant.export","outcome":"allowed","actor_tenant_id":"0199aa00-0000-7000-8000-000000000001","actor_subject_id":"01a0ee8a-bfb8-763c-ac22-0c8d97b0fada","actor_client_id":"01a0ee8a-bf72-77c9-a423-f61084924d9f","actor_name":null,"actor_origin":"system","resource_type":"tenant","resource_id":"01a0ee8d-dff4-720e-bdb4-15576ad2ebff","request_id":"01a0ee8d-ef48-7511-85ab-1f473209b38b","ip":"172.22.0.1","detail":{"include_subjects":true}},{"id":"01a0ee8d-ef44-7673-b602-460dcbf39665","occurred_at":"2026-09-29T19:04:34.364Z","event_type":"admin_mutation","action":"tenant.export","outcome":"allowed","actor_tenant_id":"0199aa00-0000-7000-8000-000000000001","actor_subject_id":"01a0ee8a-bfb8-763c-ac22-0c8d97b0fada","actor_client_id":"01a0ee8a-bf72-77c9-a423-f61084924d9f","actor_name":null,"actor_origin":"system","resource_type":"tenant","resource_id":"01a0ee8d-dff4-720e-bdb4-15576ad2ebff","request_id":"01a0ee8d-ef34-7c17-9279-d86c8afed9c7","ip":"172.22.0.1","detail":{"include_subjects":false}},{"id":"01a0ee8d-ef2f-7aa6-a892-59e9c27d618e","occurred_at":"2026-09-29T19:04:34.339Z","event_type":"admin_mutation","action":"tenant.export","outcome":"allowed","actor_tenant_id":"0199aa00-0000-7000-8000-000000000001","actor_subject_id":"01a0ee8a-bfb8-763c-ac22-0c8d97b0fada","actor_client_id":"01a0ee8a-bf72-77c9-a423-f61084924d9f","actor_name":null,"actor_origin":"system","resource_type":"tenant","resource_id":"01a0ee8d-dff4-720e-bdb4-15576ad2ebff","request_id":"01a0ee8d-ef14-77be-acb8-f45139b61f0a","ip":"172.22.0.1","detail":{"include_subjects":false}}]}
{"items":[{"id":"01a0ee8d-ef81-7a9a-9f95-bcd6e4e83f9b","occurred_at":"2026-09-29T19:04:34.433Z","event_type":"admin_access","action":"capability.refused","outcome":"refused","actor_tenant_id":"01a0ee8d-dff4-720e-bdb4-15576ad2ebff","actor_subject_id":"01a0ee8d-e7a7-7728-b5d1-5bac0435926e","actor_client_id":"01a0ee8d-e002-7eb7-859e-f2b6810e1c0f","actor_name":"tenant-operator","actor_origin":"tenant","resource_type":null,"resource_id":null,"request_id":"01a0ee8d-ef75-7140-acff-159dd214780b","ip":"172.22.0.1","detail":{"reason":"missing_capability","capability":"manage-clients"}},{"id":"01a0ee8d-ef71-7dc3-8778-ce07da5bd8e6","occurred_at":"2026-09-29T19:04:34.417Z","event_type":"admin_access","action":"capability.refused","outcome":"refused","actor_tenant_id":"01a0ee8d-dff4-720e-bdb4-15576ad2ebff","actor_subject_id":"01a0ee8d-e7a7-7728-b5d1-5bac0435926e","actor_client_id":"01a0ee8d-e002-7eb7-859e-f2b6810e1c0f","actor_name":"tenant-operator","actor_origin":"tenant","resource_type":null,"resource_id":null,"request_id":"01a0ee8d-ef65-7217-bf0f-f0ac27727a7c","ip":"172.22.0.1","detail":{"reason":"missing_capability","capability":"manage-clients"}}]}

The subjects and omitted of the export with subjects, selected with jq — grace still owes update-password, and tenant-operator's no longer does, now that the reset above was completed; her password is named rather than carried:

curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3000/admin/tenants/export-demo/export?include=subjects" \
| jq -c '.subjects[], .omitted'
{"username":"grace","email":"grace@example.com","enabled":true,"profile":{"name":null,"given_name":null,"family_name":null,"middle_name":null,"nickname":null,"preferred_username":null,"profile":null,"picture":null,"website":null,"gender":null,"birthdate":null,"zoneinfo":null,"locale":null,"phone_number":null,"phone_number_verified":false,"email_verified":false,"address_formatted":null,"address_street":null,"address_locality":null,"address_region":null,"address_postal_code":null,"address_country":null},"roles":[],"groups":["/finance"],"required_actions":["update-password"]}
{"username":"tenant-operator","email":null,"enabled":true,"profile":{"name":null,"given_name":null,"family_name":null,"middle_name":null,"nickname":null,"preferred_username":null,"profile":null,"picture":null,"website":null,"gender":null,"birthdate":null,"zoneinfo":null,"locale":null,"phone_number":null,"phone_number_verified":false,"email_verified":false,"address_formatted":null,"address_street":null,"address_locality":null,"address_region":null,"address_postal_code":null,"address_country":null},"roles":[{"name":"manage-tenant","client":"odudu-admin"}],"groups":[],"required_actions":[]}
["clients[0].secret","smtp.password","subjects[1].credentials"]

A caller holding manage-tenant without manage-clients is refused with 403, writing the same capability.refused row the router writes for a route's own capability, naming manage-clients. On ceiling-removal from a removal is judged by what it removes, whose $TENANT_TOKEN holds manage-tenant alone, with the row read back as the system administrator:

RUN_START=$(date -u +%FT%T.000Z)
curl -sS -H "Authorization: Bearer $TENANT_TOKEN" \
http://localhost:3000/admin/tenants/ceiling-removal/export
echo
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3000/admin/tenants/ceiling-removal/audit?action=capability.refused&from=$RUN_START" \
| jq -c '.items[] | {action, outcome, actor_subject_id, detail}'
{"type":"about:blank","title":"Forbidden","status":403,"instance":"01a0e5c9-600a-7aa2-91da-e4cb637c903e"}
{"action":"capability.refused","outcome":"refused","actor_subject_id":"01a0e59a-b35c-7fb5-aa5a-78cdc389b30b","detail":{"reason":"missing_capability","capability":"manage-clients"}}

A tenant too large for one document​

The document is built in memory, so a tenant holding more than 20,000 clients, roles or groups, or more than 200,000 rows of any one link table (composites, group roles, scope role mappings, scope assignments, group memberships), is refused rather than read. On a tenant big-demo, given 20,001 clients with insert into clients and insert into client_oidc_config (no origins are needed for the count):

curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" "$B/admin/tenants/big-demo/export"
HTTP/1.1 413 Payload Too Large
x-request-id: 01a10f81-1193-7887-86e7-d45f15a0fdc7
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 218

{"type":"about:blank#export-too-large","title":"Content Too Large","status":413,"detail":"the tenant holds more than 20000 clients, too many to export in one document","instance":"01a10f81-1193-7887-86e7-d45f15a0fdc7"}