Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tenant configuration document

A tenant’s configuration can be exported as one JSON document and applied back, to the same tenant or another one. The document is deterministic (sorted keys and sorted collections), keyed by natural identifiers rather than database ids, and free of secrets, so it can live in version control and be reviewed like code. The idea and the workflow are in Configuration as code; this page is the format reference. The implementation is api/src/services/tenant_config.rs.

OperationAPICLIPermission
ExportGET /admin/tenants/{slug}/exportridm tenant export [slug] [-o FILE]ridm:tenants:export
Plan (dry run)POST /admin/tenants/{slug}/import?dry_run=true[&prune=true]ridm tenant diff [slug] -f FILE [--prune] [--exit-code]ridm:tenants:import
ApplyPOST /admin/tenants/{slug}/import[?prune=true]ridm tenant import [slug] -f FILE [--prune] [--yes]ridm:tenants:import

Of the built-in roles only ridm:owner holds ridm:tenants:import; ridm:admin may export but not import.

Top level

{
  "format": "ridm.tenant/1",
  "tenant": { "slug": "acme", "display_name": "Acme", "settings": { } },
  "profile_schema": { "attributes": [], "allow_undeclared": false },
  "resource_servers": [],
  "scopes": [],
  "clients": [],
  "roles": [],
  "groups": [],
  "claim_mappers": [],
  "message_templates": [],
  "webhooks": [],
  "ip_rules": [],
  "mtls_trust_anchors": [],
  "identity_providers": [],
  "saml_service_providers": []
}
FieldRequiredMeaning
formatyesmust be ridm.tenant/1; anything else is refused
tenantyesname and settings (below)
profile_schemanothe user profile schema; defaults to an empty schema
resource_servers … saml_service_providersnocollections, each defaulting to empty

Unknown fields are refused at the top level and in every section, so a misspelt key fails the import rather than being dropped. The one exception is inside tenant.settings, which, like settings everywhere, ignores keys it does not know (they are not stored).

What the document leaves out

The document is configuration, not data or credentials. It never contains:

  • Secrets: client secrets, webhook signing secrets, identity providers’ client secrets, registration access tokens.
  • Users and everything attached to them: credentials, sessions, trusted devices, consents, personal access tokens, linked identities, role assignments to users and group memberships. Move users with the bulk user import and export.
  • Provider credentials: the email and SMS delivery settings and the CAPTCHA provider, which hold secrets.
  • Signing keys (each tenant has its own), SCIM provisioning tokens, dynamic registration initial access tokens, invitations and the audit log.
  • Built-in objects: the ridm-admin-console and ridm-account-console clients, the built-in ridm:* roles, and the built-in resource servers urn:ridm:admin and urn:ridm:account. Exports leave them out, an import naming a built-in client is refused, and pruning never deletes them. The standard scopes (openid, profile, email, phone, address, offline_access, features) are exported so their descriptions can be tuned, but pruning never deletes them either.

Sections

tenant

FieldMeaning
sluginformational only: an import applies to the tenant named in the URL, so one document can configure several tenants
display_namethe tenant’s name
settingsthe complete tenant settings document

The tenant’s status (active or disabled) is not part of the document.

settings is applied as a whole, not merged: a section or field the document omits takes its default, not the tenant’s current value. An export always writes every field, so round-tripping is safe; a hand-written document that omits settings resets the tenant’s settings to defaults, and the plan shows it. custom_domain is part of the settings and must be unique across tenants, so clear it before applying a production export to another tenant.

Settings sectionFields (default)
passwordmin_length (12), max_length (128), require_uppercase, require_lowercase, require_digit, require_symbol (all false), history (5), max_age_days (null, never), check_breached (false)
sessionidle_timeout_secs (1800), absolute_timeout_secs (43200), max_concurrent (0, unlimited), remember_device_days (30), access_token_ttl_secs (300), refresh_token_ttl_secs (2592000), id_token_ttl_secs (300)
mfa{"mode": "off"}; also optional, required, required_for_admins, {"mode": "required_for_roles", "roles": [...]}
mfa_methodstotp (true), email_otp (false), sms_otp (false)
riskenabled (false), weights (new_device 20, new_country 50, impossible_travel 60, velocity 40), step_up_at (50), block_at (100), impossible_travel_kmh (900), velocity_window_minutes (15), velocity_max_failures (10)
impersonationenabled (false), max_minutes (60, 1–480)
authfirst-factor methods: password (true), magic_link, email_otp, sms_otp, passkey (all false)
registrationenabled (false), require_email_verification (true), require_terms (false), terms_url, privacy_url (null), allowed_email_domains ([], any). A leftover captcha key from older exports is ignored; the setting is captcha.on_registration
localedefault ("en"), supported (["en"])
brandinglogo_url, favicon_url, primary_color, contrast_color, background_color, support_url, headline, tagline, custom_css (all null), links ([] of {label, url}), skin ("signal-v2"; also signal, vault, registry), mode ("system"; also light, dark), allow_theme_switch (true)
keysdefault_alg ("RS256"; also RS384, RS512, ES256, EdDSA), rsa_bits ("B2048"; also B3072, B4096), rotation_interval_days (90, 0 = never), retire_overlap_hours (24)
discoveryemail_domains ([]): WebFinger acct: domains that resolve to this tenant
dcrmode ("disabled"; also open, initial_access_token), allowed_grants ([]), require_pkce (true)
lockoutmax_failures (10), lock_minutes (15), ip_max_failures (100), ip_window_minutes (15)
captchaafter_failures (3, 0 = never), on_registration (true)
notificationsnew_device, password_changed, mfa_changed, email_changed (all true)
auditretention_days (365, 0 = forever)
accountself_deletion (true), deletion_retention_days (30), personal_tokens (true), personal_token_max_days (365, 0 = no limit)
rate_limitsenabled (true), window_secs (60), token_per_ip (600), token_per_client (1200), authorize_per_ip (300), flows_per_ip (600), tenant_total (0, off)
custom_domainnull, or a host name
features{}: flag name to {enabled, description, organizations} (a bare boolean is accepted); see Feature flags

What each setting does is described in Tenants and tenant settings.

profile_schema

{"attributes": [...], "allow_undeclared": false}. Each attribute:

FieldMeaning
nameattribute name, stored under the user’s attributes
typestring, number, boolean, email, url, phone, date (YYYY-MM-DD), enum, json
label, descriptionshown in forms
required, multivaluedbooleans, default false
editable_byuser (default), admin, none
visible_inany of id_token, userinfo, access_token: the attribute appears there as a claim of its name (see Token claims)
validationmin_length, max_length, pattern (anchored regular expression), min, max, values (for enum)
orderposition in forms

The schema is replaced as a whole when it differs.

resource_servers

Keyed by identifier.

FieldDefaultMeaning
identifierthe audience URI; immutable
namedisplay name
token_ttl_secsnullcaps the lifetime of access tokens for this audience
signing_algnullRS256, RS384, RS512, ES256 or EdDSA: access tokens for this audience are signed with the tenant’s key of that algorithm (created on save if missing); null uses the tenant’s keys.default_alg
allow_offline_accesstruefalse drops offline_access from any grant whose audience includes this server
permissions[][{name, description?}]

permissions is authoritative: when a resource server is created or updated, permissions missing from its list are deleted, with or without prune.

scopes

Keyed by name: name, description, claims ([]: the claims the scope releases at /userinfo, and in ID tokens of clients with id_token_scope_claims), is_default (false: granted when a request names no scope), and resource_server (the identifier of the resource server the scope is bound to, or null; requesting a bound scope targets that server). See Token claims.

clients

Keyed by client_id. Besides client_id, a client document carries:

FieldDefaultMeaning
statusactiveor disabled
service_accountfalsewhether the client has a service-account user for client_credentials (created or removed to match)

and every metadata field POST /admin/tenants/{slug}/clients accepts: name (required), client_type (spa, web, native, machine, device), description, logo_uri, client_uri, tos_uri, policy_uri, token_endpoint_auth_method, jwks, jwks_uri, redirect_uris, post_logout_redirect_uris, allowed_grants, allowed_scopes, allowed_audiences, access_token_ttl_secs, refresh_token_ttl_secs, id_token_ttl_secs, access_token_format, id_token_encryption ({alg, enc}), subject_type, sector_identifier_uri, require_pkce, require_consent, id_token_scope_claims, cors_origins, initiate_login_uri, backchannel_logout_uri, frontchannel_logout_uri, dpop_bound_access_tokens, require_pushed_authorization_requests, security_profile (none, fapi2), backchannel_token_delivery_mode (poll, ping), backchannel_client_notification_endpoint. See Registering clients.

Before comparing, rIDM fills omitted fields with the defaults the client type implies, exactly as creating the client would, so a minimal document does not read as a change against the export of the client it created.

roles

Keyed by name, or client_id/name for a client role.

FieldMeaning
namerole name
clientclient_id of the client the role belongs to; absent for a realm role
description
compositesrole references: name for a realm role, client_id/name for a client role
permissionsresource-server-identifier#permission-name, e.g. https://orders.example#orders:read

composites and permissions are authoritative for each role in the document: links not listed are removed.

groups

Keyed by path.

FieldMeaning
pathnames from the root down, e.g. ["staff", "engineering"]; the last is the group’s own name
description
attributesa JSON object ({} by default)
rolesrole references the group grants its members; authoritative

Parents are applied before children, and a group whose path changed is a delete (with prune) plus a create. Members are not part of the document.

claim_mappers

Keyed by name, or client_id/name for a client’s mapper: name, client (client_id, or absent for a tenant-wide mapper) and config, the mapper document ({"type": ..., "include_in": [...]}) described in Token claims.

message_templates

Keyed by channel/event/locale. Only tenant overrides are exported; the built-in templates are not.

FieldMeaning
channelemail or sms
eventthe message event, e.g. password_reset
localee.g. en
subjectemail subject (email only)
body_textrequired
body_htmloptional HTML body (email only)

The events and their variables are listed in Email, SMS and templates.

webhooks

Keyed by name: name, url, events (exact names, prefixes such as user.*, or *), enabled (true), headers (an object of extra request headers, {}), max_attempts (8). The signing secret is not in the document; a webhook created by an import gets a fresh one, reported once. See Events, audit and webhooks.

ip_rules

Keyed by cidr, or client_id/cidr for a client’s rule: cidr (normalised, so 10.0.0.1/8 compares equal to 10.0.0.0/8), action (allow or deny, default deny), client (a client_id, or absent for a tenant-wide rule), description.

mtls_trust_anchors

The certificate authorities tls_client_auth clients may present certificates from (Mutual TLS), keyed by the certificate’s SHA-256 thumbprint: name and certificate_pem (one CA certificate). An import refuses a certificate that is not a CA or has expired; a changed name replaces the entry.

identity_providers

Keyed by alias (lower-cased).

FieldDefaultMeaning
aliasthe provider’s name in /broker/{alias}/...
kindoidcor oauth2, saml
display_namebutton label
presetnullgoogle, microsoft, github, apple, gitlab
enabled, hiddentrue, false
issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_urinullupstream endpoints
client_idrIDM’s client id at the provider
token_endpoint_auth_methodclient_secret_basicor client_secret_post, none
scopes[]scopes requested upstream
pkcetrue
link_policyverified_emailor explicit, always_new
trust_emailfalse
mappers{}subject, username, email, email_verified (claim names) and attributes (profile attribute → claim)
sort_order0
samlabsenta saml provider’s settings: entity_id, sso_url, sso_binding, slo_url, slo_binding, signing_certificates, name_id_format, sign_requests, want_assertions_signed, require_encrypted_assertions, force_authn, authn_context_class_refs, allow_unsolicited, unsolicited_client_id, metadata_url (see SAML identity providers); the metadata refresh status is not part of the document. Certificates may be PEM or base64 and compare as stored

A SAML provider has no client id, secret or OAuth endpoints (they export empty) and is never listed under secrets. The client secret is never part of the document. An update keeps the stored secret; a provider created by an import has none, and the report lists its alias under secrets.identity_providers so you can set it (PATCH .../identity-providers/{alias} with client_secret). See Identity brokering.

saml_service_providers

Keyed by client_id: the registration POST /admin/tenants/{slug}/saml/service-providers takes (name, entity_id, acs_urls, slo_url, name_id_format, attributes, certificates, signing and encryption options, …; see SAML identity provider), plus status (default active). Certificates may be PEM or base64 and compare equal either way. SAML clients appear here and never under clients; a clients entry with client_type: saml is refused. The IdP’s own SAML signing keys are not part of the document.

How an import works

  1. Parse and normalise. The document is checked (format, unknown fields, duplicate keys within a collection) and brought into the shape an export would have: client defaults filled in, CIDRs normalised, reference lists sorted and de-duplicated, empty attributes and headers made {}.
  2. Plan. The tenant is exported and compared with the document, collection by collection, on the natural keys above. An item only in the document is a create; an item in both that differs is an update, with the changed fields; an item only in the tenant is a delete, but only with prune. Without prune an import only adds and changes.
  3. Apply (skipped for a dry run). Changes are applied in dependency order: tenant settings, profile schema, resource servers, scopes, clients, roles (rows first, then composites and permissions), groups (parents first), claim mappers, message templates, webhooks, IP rules, identity providers. Deletions run afterwards in reverse order.

Each change is applied independently: a change that fails is reported in errors and the others still go ahead, so an import is not a single transaction. Fix the reported problems and apply again. Applying the same document twice gives an empty plan the second time.

An import cannot hand out admin permissions the importing administrator does not hold. A role or group whose new composites, permissions or roles would grant ridm:* permissions beyond the importer’s own is refused and left unchanged, reported in errors as cannot grant permissions you do not hold: …. A role the same document defines counts with the permissions it will have once imported. A dry run finds these refusals without changing anything and lists them in its errors too, so a plan shows what the apply would refuse; ridm tenant diff fails on them and ridm tenant import prints them before asking.

Report

A dry run and an apply both answer 200 with the same shape:

{
  "dry_run": false,
  "prune": false,
  "changes": [
    {
      "resource": "client",
      "key": "orders-web",
      "op": "create"
    },
    {
      "resource": "webhook",
      "key": "audit-feed",
      "op": "update",
      "fields": [
        { "field": "max_attempts", "from": 8, "to": 12 }
      ]
    }
  ],
  "summary": { "create": 1, "update": 1, "delete": 0, "unchanged": 11 },
  "applied": 2,
  "errors": [],
  "secrets": {
    "clients": { "orders-web": "k3Jd..." },
    "webhooks": {},
    "identity_providers": []
  }
}
FieldMeaning
changes[].resourcetenant, profile_schema, resource_server, scope, client, role, group, claim_mapper, message_template, webhook, ip_rule, mtls_trust_anchor, identity_provider
changes[].keythe natural key
changes[].opcreate, update, delete
changes[].fieldsfor updates: {field, from, to} for each top-level field that differs
summarycounts; unchanged counts items present on both sides and equal
appliedchanges applied successfully (0 for a dry run)
errors[{resource, key, error}] for changes that failed; in a dry run, the changes the apply would refuse under the rule above
secretspresent only when the import created something with a secret: clients (client_id → secret) and webhooks (name → secret), shown this once; identity_providers lists providers that still need their client secret

The tenant change is the exception to field-level diffs: it compares the name and the settings as one pair, so its single entry has an empty field and carries [display_name, settings] before and after in full; compare the two to see what moved.

ridm tenant diff --exit-code exits 3 when the plan is not empty, so a pipeline can detect drift; ridm tenant import shows the plan and asks before applying unless --yes is given (required when standard input is not a terminal).

Annotated example

examples/demo-tenant.json is the document behind the example relying parties. Abridged:

{
  "format": "ridm.tenant/1",
  "tenant": {
    "slug": "demo",
    "display_name": "Example Orders Co."
  },
  "resource_servers": [
    {
      "identifier": "https://orders.example",
      "name": "Orders API",
      "allow_offline_access": true,
      "permissions": [
        { "name": "orders:read", "description": "See orders" },
        { "name": "orders:write", "description": "Place orders" }
      ]
    }
  ],
  "scopes": [
    { "name": "orders:read", "description": "See your orders", "resource_server": "https://orders.example" },
    { "name": "orders:write", "description": "Place orders on your behalf", "resource_server": "https://orders.example" }
  ],
  "roles": [
    {
      "name": "orders-manager",
      "description": "May see and place orders",
      "permissions": ["https://orders.example#orders:read", "https://orders.example#orders:write"]
    }
  ],
  "clients": [
    {
      "client_id": "orders-web",
      "name": "Orders (server-side web app)",
      "client_type": "web",
      "token_endpoint_auth_method": "client_secret_basic",
      "redirect_uris": ["http://localhost:3200/callback"],
      "post_logout_redirect_uris": ["http://localhost:3200/"],
      "backchannel_logout_uri": "http://localhost:3200/backchannel-logout",
      "allowed_grants": ["authorization_code", "refresh_token"],
      "allowed_scopes": ["openid", "profile", "email", "offline_access", "orders:read", "orders:write"],
      "allowed_audiences": ["https://orders.example"],
      "require_pkce": true,
      "require_consent": false
    }
  ]
}

Reading it top to bottom:

  • tenant has no settings, so applying this document to an existing tenant plans to reset its settings to the defaults. It is meant for a fresh tenant, where that is no change.
  • The resource server https://orders.example is the audience of the Orders API and declares two permissions. Tokens requested for it (the client lists it in allowed_audiences, so it is the default audience) carry the permissions the user’s roles hold on it in the permissions claim.
  • The two custom scopes belong to that resource server. profile, email and the other standard scopes are not listed: they exist in every tenant, and without prune an import never deletes anything.
  • The role orders-manager grants both permissions, referenced as identifier#name. Users are not in the document; assign the role to users or groups afterwards.
  • orders-web is a confidential client (client_secret_basic). The document carries no secret: the first import creates the client and returns its secret once under secrets.clients. Omitted fields (token lifetimes, subject_type, and so on) take the web type’s defaults.

Applying it to a new tenant called demo:

ridm tenant create demo --name "Example Orders Co."
ridm tenant diff demo -f examples/demo-tenant.json      # review the plan
ridm tenant import demo -f examples/demo-tenant.json    # apply; prints the client secret once

or with the API:

curl -s -X POST "https://id.example.com/admin/tenants/demo/import?dry_run=true" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @examples/demo-tenant.json | jq .summary