Skip to content
Open with AI

Provisioning

This document describes Issuerd’s declarative provisioning: a YAML, TOML, or JSON file (“provision file”) that seeds realms, roles, groups, clients, users, identity providers, and authentication flow configs into a fresh — or partially seeded — deployment. It is written for system operators who install and maintain Issuerd. For the server configuration file that points at a provision file, see configuration.md; for a guided first boot, see getting-started.md.

Provisioning applies a declarative seed file to Issuerd’s storage exactly once. A durable marker recorded in storage tracks whether a given file has already been applied, so restarts and concurrent first boots never apply it twice, and administrator changes made afterwards are never overwritten by the file.

Use provisioning for:

  • First-boot seeding — create the master realm, an admin user, and the console clients when bringing up a new environment (mandatory on PostgreSQL; see Bootstrap interactions).
  • GitOps-friendly realm baselines — keep the initial shape of application realms (roles, groups, clients, service users) in version control next to the deployment.

Use the Admin API (see administration.md) for everything after the first boot: provisioning never updates, deletes, or drifts — it only creates entities that do not exist yet, and only while its marker is unclaimed.

What provisioning deliberately does not cover (use the Admin API instead): password policies, brute-force settings, events configuration, client roles, protocol mappers, and any change to an entity that already exists. Per-realm SMTP overrides are the one exception: they are realm attributes (smtpServer.* keys), so they can be seeded through a realm’s free-form attributes map.

The implementation lives in crates/issuerd-server/src/provisioner.rs; the file schema is defined by the Provision* structs in crates/issuerd-core/src/provision.rs. Reference files shipped in the repository:

  • examples/provision.example.yaml — fully populated example (generated by issuerd example provision-config, kept in sync by a unit test).
  • examples/provision.federation.yaml — multi-realm federation lab (LDAP/Kerberos).
  • examples/provision.demo.yaml — the local demo stack’s seed file.
  • cluster/provision.yaml — the two-node cluster demo’s seed file.

There are two entry points. Both load the file through the same code path (Provisioner::from_file + apply_once) and share identical marker semantics.

Point the server configuration at a provision file:

issuerd.toml
provision = "provision.yaml" # relative paths resolve against the working directory

During startup (in ServerState::from_config, crates/issuerd-server/src/state.rs), after storage initialization and after the automatic master-realm bootstrap (for the in-memory/JSON backends), the daemon loads and applies the file once. On subsequent restarts the marker is already present and the file is ignored entirely — leaving the provision key in place is harmless.

Warning: Provisioning problems are not fatal at daemon startup. A missing or unparsable file logs failed to load provision config at ERROR level; a failure during application logs provision failed — both carry the error as a structured field, not interpolated into the message. Boot then continues. Always check the startup log after a first boot, especially on PostgreSQL where a failed provision can leave you without a master realm.

Terminal window
issuerd provision -c issuerd.toml --file provision.yaml

The command reads the server configuration only to learn which storage to connect to (-c defaults to issuerd.toml in the working directory), then:

  1. Connects to the configured storage — for PostgreSQL it also runs pending schema migrations first (src/main.rs).
  2. Applies the provision file exactly once (same marker mechanism as the daemon).
  3. Exits. Unlike the daemon, errors propagate: the process exits non-zero, so it is safe to gate deployments on it in scripts and CI.

Use the CLI when you want to seed the database before the daemon ever starts (e.g. a deployment pipeline step), or to apply an additional baseline to an existing database under a new marker without changing the daemon’s configuration (see Re-runs and idempotency). Use the config key when the file should travel with the environment and be picked up automatically on first boot — this is how the Docker demo stack mounts examples/provision.demo.yaml at /etc/issuerd/provision.yaml (docker-compose.yml).

The file format is detected by extension: .yaml/.yml → YAML, .json → JSON, anything else → TOML. YAML is the canonical form used throughout the repository. Generate a fully populated starting point with:

Terminal window
issuerd example provision-config -o provision.yaml

Note: issuerd example defaults its output to examples/issuerd.example.toml for both example kinds — always pass -o explicitly when generating a provision file.

The top level consists of an optional marker key plus one list per entity type. Every list is optional and defaults to empty:

marker: my-deployment-v1 # optional; defaults to "default"
realms: [ ... ]
roles: [ ... ]
groups: [ ... ]
clients: [ ... ]
users: [ ... ]
identity_providers: [ ... ]
flow_configs: [ ... ]

After parsing, ${VAR} placeholders in string values are expanded from the process environment — including client secret, user password, and identity-provider config values such as bindCredential. If any referenced variable is unset, the file fails to load entirely (environment substitution failed: ${VAR}, ...).

clients:
- realm: myrealm
client_id: billing-api
secret: ${BILLING_API_CLIENT_SECRET} # keeps secrets out of version control

This applies equally to daemon startup provisioning and the CLI, and is the recommended way to keep credentials out of the checked-in file.

Entities are applied in a fixed order regardless of how the lists are arranged in the file (crates/issuerd-server/src/provisioner.rs):

realms → roles → clients → groups → users → identity_providers → flow_configs

Groups are applied after clients so that group client_roles mappings can resolve the clients they reference.

Every entity is skipped with a log warning if it already exists — realms by name, roles and groups by name within the realm, clients by client_id within the realm, users by username, identity providers by alias, flow configs by alias. Provisioning creates missing entities only; it never updates existing ones.

Every provisioned realm automatically receives two built-in public clients (the same ones the Admin API creates on realm creation), with redirect URIs derived from the server’s issuer_url:

  • admin-cli — redirects {issuer_url}/, {issuer_url}/admin/console/callback, {issuer_url}/swagger-ui/oauth2-redirect.html.
  • account-console — redirects {issuer_url}/, {issuer_url}/realms/{realm}/account.

Because these are created while the realm itself is provisioned, explicit clients: entries named admin-cli or account-console are skipped as duplicates. If you need different redirect URIs on them, adjust them afterwards via the Admin API. Realm creation also seeds the built-in browser/registration authentication flows and the built-in client scopes via the storage layer.

All cross-references between entities are by realm name (never the internal UUID) and resolve against realms that exist when the entity is applied — either declared earlier in the same file or already present in storage. A reference to a nonexistent realm aborts the run with realm '<name>' not found.

Field Type Default Description
name string required Realm name. Embedded verbatim in URLs (/realms/{name}/...) and the issuer, so it must be ASCII, ≤ 255 chars, with no whitespace, control, or URL-reserved characters (/ \ ? # % + & =).
display_name string none Human-readable name shown on login pages.
enabled bool true Disabled realms reject logins.
ssl_required string "external" Only "none" and "all" are recognized; any other value (including a typo) maps to external.
login_theme string none Login theme name (from the [themes] dir directory).
email_theme string none Email theme name.
admin_theme string none Admin console theme name.
default_role string none Name of a realm role automatically assigned to users on self-registration and first broker login. Stored verbatim; not validated at provision time — declare the role in roles:.
access_token_lifespan seconds 300 Must be non-zero; 0 is rejected with an error.
refresh_token_lifespan seconds 1800 Must be non-zero.
sso_session_idle_timeout seconds 1800 Must be non-zero.
sso_session_max_lifespan seconds 36000 Must be non-zero.
offline_session_idle_timeout seconds 2592000 (30 days) Must be non-zero.
registration_enabled bool false Allow user self-registration.
verify_email_enabled bool false Require email verification.
reset_password_allowed bool false Allow users to reset their password.
remember_me_enabled bool false Show “remember me” on the login page.
login_with_email_allowed bool true Allow the email address as login username.
browser_flow string system default Alias of the realm’s browser login flow binding. Stored verbatim; not validated at provision time.
registration_flow string system default Alias of the registration flow binding.
attributes map<string, string> {} Free-form realm attributes (some features read per-realm toggles from attributes).

Not settable here: password policy, brute-force detection, events configuration — all take realm defaults and are managed via the Admin API. Per-realm SMTP overrides are realm attributes (smtpServer.* keys) and can be seeded through the free-form attributes map above.

realms:
- name: myrealm
display_name: My Realm
ssl_required: external
default_role: user
registration_enabled: true
reset_password_allowed: true
access_token_lifespan: 300
sso_session_max_lifespan: 36000
attributes:
custom_attr: value

Creates realm roles only — provisioning never creates client roles.

Field Type Default Description
realm string required Owning realm name.
name string required Role name.
description string none Free-text description.
roles:
- realm: myrealm
name: admin
description: Administrator role
- realm: myrealm
name: user
description: Standard user role
Field Type Default Description
realm string required Owning realm name.
name string required Group name.
path string /{name} Group path.
attributes map<string, list<string>> {} Multi-valued group attributes.
realm_roles list<string> [] Realm role names assigned to the group; members inherit them. Unknown roles are skipped with a warning.
client_roles map<string, list<string>> {} Client role names keyed by client_id. Unknown clients and unknown client roles are skipped with a warning.

Groups are applied after roles, clients, and before users, so realm_roles can reference roles declared in the same file and client_roles can reference clients declared in the same file. Client roles themselves cannot be provisioned — they must already exist (create them via the Admin API first), otherwise the mapping is silently skipped (warning in the log).

groups:
- realm: myrealm
name: developers
path: /developers
realm_roles:
- user
client_roles:
my-app: # client_id
- editor # must already exist as a client role on my-app
Field Type Default Description
realm string required Owning realm name.
client_id string required The OAuth2/OIDC client_id.
name string none Display name.
description string none Free-text description.
enabled bool true Disabled clients cannot authenticate.
public_client bool false true = public client (no secret; use PKCE).
bearer_only bool false true = the client only validates bearer tokens, never initiates logins.
secret string generated Explicit client secret. If omitted: confidential clients get a randomly generated secret; public clients get none.
redirect_uris list<string> [] Valid redirect URIs. Entries that fail validation are dropped with a warning.
web_origins list<string> [] Allowed CORS origins for the client. Invalid entries are dropped with a warning.
default_scopes list<string> [openid, profile] Scopes granted when the request has no scope parameter. Only when the list is non-empty does it override the default.
optional_scopes list<string> [] Scopes the client may request optionally.
consent_required bool false Require a user consent screen.
full_scope_allowed bool true When true, tokens contain the user’s full role set; when false, only scope mappings apply.

The protocol is always openid-connect. Not settable here: service accounts, protocol mappers, client roles, client-scope assignments beyond the two scope lists — use the Admin API.

clients:
- realm: myrealm
client_id: my-app
name: My Application
public_client: false
secret: ${MY_APP_CLIENT_SECRET} # or omit to have one generated
redirect_uris:
- https://app.example.com/callback
web_origins:
- https://app.example.com
default_scopes: [openid, profile]
optional_scopes: [email]
consent_required: false
full_scope_allowed: true

Note: A confidential client provisioned without secret ends up with a random secret that appears nowhere in your configuration — retrieve or rotate it via the Admin API, or set the secret explicitly (ideally through ${ENV} substitution).

Field Type Default Description
realm string required Owning realm name.
username string required Login username.
email string none Email address.
email_verified bool false Mark the address as verified.
first_name string none Given name.
last_name string none Family name.
enabled bool true Disabled users cannot log in.
password string none Plain-text password, hashed with Argon2id at apply time and never stored as-is. Omitted = user has no password credential (e.g. federation-only accounts).
realm_roles list<string> [] Realm role names to assign. Unknown roles are skipped with a warning.
groups list<string> [] Group names to join. Unknown groups are skipped with a warning.
attributes map<string, list<string>> {} Multi-valued user attributes.
users:
- realm: myrealm
username: alice
email: alice@example.com
email_verified: true
first_name: Alice
last_name: Anderson
password: ${ALICE_INITIAL_PASSWORD}
realm_roles: [admin, user]
groups: [developers]
attributes:
department: [engineering]

Registers an identity broker or user-federation provider on a realm. The config map is passed through to the provider verbatim — all values are strings, so quote numbers and booleans (pagination: "true", batchSize: "5000").

Field Type Default Description
realm string required Owning realm name.
alias string required Provider alias; appears in broker URLs (/realms/{realm}/broker/{alias}/...).
provider_id string required ldap, kerberos, oidc, saml, or social; any other value registers a custom provider id.
enabled bool true Disabled providers are hidden and inactive.
config map<string, string> {} Provider-specific settings.

For ldap providers the config keys follow Keycloak’s LDAP mapper vocabulary — connectionUrl, bindDn, bindCredential, usersDn, baseDn, usernameLdapAttribute, rdnLdapAttribute, uuidLdapAttribute, userObjectClasses, vendor (ACTIVE_DIRECTORY, SAMBA, GENERIC, …), searchScope, editMode, pagination, batchSize, priority, plus groupsDn (and friends) for LDAP group synchronization. kerberos providers take kerberosRealm, serverPrincipal, keyTab, allowKerberosAuthentication, allowPasswordAuthentication, updateProfileFirstLogin. See examples/provision.federation.yaml for working AD/Samba/OpenLDAP/Kerberos examples, user-federation.md for the full key reference and sync semantics, and identity-brokering.md for external OIDC/social providers.

identity_providers:
- realm: myrealm
alias: corporate-ldap
provider_id: ldap
enabled: true
config:
connectionUrl: ldap://ldap.example.com:389
bindDn: cn=admin,dc=example,dc=com
bindCredential: ${LDAP_BIND_CREDENTIAL}
usersDn: ou=users,dc=example,dc=com
usernameLdapAttribute: uid
uuidLdapAttribute: entryUUID
userObjectClasses: inetOrgPerson,organizationalPerson
vendor: GENERIC
editMode: READ_ONLY
priority: "1"

Registers a custom authentication flow. Built-in flows (browser, registration) are seeded automatically per realm by the storage layer; this section is for additional custom flows.

Field Type Default Description
realm string required Owning realm name.
alias string required Flow alias; referenced by the realm’s browser_flow/registration_flow bindings.
provider_id string "basic-flow" Flow provider type.
top_level bool true true = top-level flow; false = sub-flow referenced by a stage’s sub_flow_alias.
built_in bool false Marks the flow as built-in (read-only in the Admin API).
stages list [] Execution stages (below).

Each stage:

Field Type Default Description
id string required Stage identifier (unique within the flow).
requirement string required required, alternative, optional, disabled, or conditional. An unknown value aborts the entire provision run with an error.
authenticator string required Authenticator alias (e.g. auth-cookie, auth-username-password).
priority int 0 Ordering weight within the flow.
sub_flow_alias string none References a sub-flow for form-style nested executions.
flow_configs:
- realm: myrealm
alias: custom-browser
provider_id: basic-flow
top_level: true
stages:
- id: cookie
requirement: alternative
authenticator: auth-cookie
priority: 10
- id: username-password
requirement: required
authenticator: auth-username-password
priority: 20

Flow configs are applied last, and the realm’s browser_flow binding is stored unvalidated, so a realm in the same file may bind to a custom flow alias declared in flow_configs:. For deeper flow surgery (executions, per-execution authenticator config) use the Admin API.

The automatic master-realm bootstrap (bootstrap_master_realm in crates/issuerd-server/src/state.rs) runs at daemon startup only when the storage backend is in-memory or JSON-file and no realms exist yet. It creates:

  • the master realm with user admin / password admin,
  • the realm-management roles (manage-realm, view-realm, manage-users, view-users, manage-clients, view-clients, impersonation) assigned to admin,
  • the built-in admin-cli and account-console clients.

It runs before the provisioner, so with the in-memory/JSON backends a provision file that also defines master finds the realm (and possibly the admin user) already present and skips those entries, while still applying any remaining roles, clients, and users from the file.

Warning: On PostgreSQL there is no automatic bootstrap. A fresh deployment whose provision file does not define the master realm has no admin user and no way to log into the admin console. Always define master, the realm-management roles, and an admin user in the provision file — as examples/provision.demo.yaml (demo stack) and cluster/provision.yaml (two-node cluster, all URLs pointing at the load balancer; see CLUSTERING.md) do.

If the daemon is already running against such a database, fix it out-of-band with issuerd provision -c issuerd.toml --file master-provision.yaml.

The marker: key (default "default") names a record in storage that tracks whether the file has been applied. With PostgreSQL the marker lives in the provision_markers table (crates/issuerd-storage/migrations/004_provision_markers.sql); with JSON-file storage it is part of the snapshot; with in-memory storage it lives and dies with the process.

  • Marker claimed before applying. The provisioner atomically claims the marker first, then applies the entities. This makes concurrent first boots safe (two cluster nodes starting against an empty database cannot both provision) — but it also means a failed run leaves the marker claimed. Re-running with the same marker after a failure is a no-op; you must fix the file and either bump the marker (e.g. marker: my-deployment-v2) or wipe the storage and start over.
  • Daemon restart: with a durable backend the marker exists, so the provision file is skipped entirely — admin changes are never overwritten. With the in-memory backend the marker vanishes on restart, so provisioning re-applies on every boot; this is harmless because existing entities are skipped per-entity.
  • Provisioning twice with a different marker: the file is applied again, but every entity that already exists is skipped (warning in the log); only missing entities are created. Existing entities are never updated — a changed password or redirect URI in the file has no effect on an entity that already exists. Use this to layer an additional baseline onto a live database; use the Admin API for modifications.
  • Deleting entities is likewise never performed; provisioning is additive only.

To force a complete re-provision of a disposable environment, reset the storage: for the Docker demo stack, docker compose down -v drops the PostgreSQL volume (markers included) and the next up seeds everything again.

A compact but realistic baseline: one application realm with two roles, one group, a confidential and a public client, and two users. On PostgreSQL, add the master realm block as well (see Bootstrap interactions and examples/provision.demo.yaml).

# provision.yaml — applied exactly once; bump the marker to layer new content.
marker: acme-baseline-v1
realms:
- name: acme
display_name: Acme Corp
ssl_required: external
default_role: user
reset_password_allowed: true
remember_me_enabled: true
access_token_lifespan: 300
sso_session_max_lifespan: 36000
roles:
- realm: acme
name: admin
description: Application administrator
- realm: acme
name: user
description: Standard user
groups:
- realm: acme
name: developers
path: /developers
realm_roles: [user]
clients:
# Confidential server-side web application.
- realm: acme
client_id: acme-portal
name: Acme Portal
secret: ${ACME_PORTAL_CLIENT_SECRET}
redirect_uris: [https://portal.acme.example.com/callback]
web_origins: [https://portal.acme.example.com]
default_scopes: [openid, profile]
optional_scopes: [email]
# Public single-page application (PKCE).
- realm: acme
client_id: acme-spa
name: Acme SPA
public_client: true
redirect_uris: [https://spa.acme.example.com/callback]
web_origins: [https://spa.acme.example.com]
default_scopes: [openid, profile]
users:
- realm: acme
username: alice
email: alice@acme.example.com
email_verified: true
first_name: Alice
last_name: Admin
password: ${ALICE_INITIAL_PASSWORD}
realm_roles: [admin, user]
groups: [developers]
attributes:
department: [engineering]
- realm: acme
username: bob
email: bob@acme.example.com
email_verified: true
first_name: Bob
last_name: Builder
password: ${BOB_INITIAL_PASSWORD}
realm_roles: [user]
groups: [developers]

Apply it with either:

Terminal window
# Out-of-band, before (or without) starting the daemon:
export ACME_PORTAL_CLIENT_SECRET=... ALICE_INITIAL_PASSWORD=... BOB_INITIAL_PASSWORD=...
issuerd provision -c issuerd.toml --file provision.yaml
# …or at first boot, via the server config: provision = "provision.yaml"
issuerd daemon -c issuerd.toml
  • Provisioning “didn’t do anything” on the second attempt. The marker is claimed before entities are applied, so an aborted or failed first run blocks all retries with the same marker. Fix the file, bump marker: (or wipe the storage), and check the startup log for provision failed / failed to load provision config — the daemon logs these at ERROR level and keeps booting, which makes silent half-provisioning easy to miss.
  • Typo in a realm: reference. All cross-references resolve by realm name (never the UUID). A reference to a nonexistent realm aborts the whole run with realm '<name>' not found — and because of the marker behavior above, leaves a partially provisioned database.
  • Group client_roles silently missing. Groups are applied after clients, but the provisioner never creates client roles; a client_roles mapping to a client role that does not exist yet is skipped with only a log warning. Create client roles via the Admin API first (or accept that they cannot be seeded this way). The same skip-with-warning behavior applies to unknown realm_roles on groups and users, and to unknown groups on users.
  • Locked out on PostgreSQL. Without a provisioned master realm there is no admin user and the admin console is unusable. Provision master (realm + realm-management roles + admin user) as in examples/provision.demo.yaml, or repair out-of-band with issuerd provision --file ....
  • Expecting the file to manage drift. Editing the provision file after first boot changes nothing — not even with a new marker, because existing entities are skipped. Provisioning seeds; the Admin API manages. See administration.md.
  • ${VAR} not set in the daemon’s environment. The whole file fails to load (the daemon continues without provisioning). Export the variables into the service’s environment (e.g. systemd EnvironmentFile, container env) — and remember the CLI reads its own shell environment.
  • Auto-generated secrets. A confidential client without secret: gets a random one that exists only in storage. Set secrets explicitly (via ${ENV}) for reproducible deployments.
  • ssl_required typos. Only the exact values none and all are special; anything else — including External or required — silently means external.
  • Zero token lifespans. A lifespan of 0 is rejected with an error; omit the key to get the default instead.
  • Redefining admin-cli / account-console. Every provisioned realm gets these built-in clients automatically (redirect URIs derived from issuer_url); file entries with the same client_id are skipped as duplicates.

For operational diagnosis of a running server (logs, health endpoints, common failure modes), see troubleshooting.md.