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.
Contents
Section titled “Contents”- What provisioning is
- Running the provisioner
- File format
- Entity reference
- Bootstrap interactions
- Re-runs and idempotency
- Complete worked example
- Common pitfalls
What provisioning is
Section titled “What provisioning is”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
masterrealm, 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 byissuerd 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.
Running the provisioner
Section titled “Running the provisioner”There are two entry points. Both load the file through the same code path
(Provisioner::from_file + apply_once) and share identical marker semantics.
At daemon startup: provision = "..."
Section titled “At daemon startup: provision = "..."”Point the server configuration at a provision file:
provision = "provision.yaml" # relative paths resolve against the working directoryDuring 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 configat ERROR level; a failure during application logsprovision 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 amasterrealm.
As a one-shot command: issuerd provision
Section titled “As a one-shot command: issuerd provision”issuerd provision -c issuerd.toml --file provision.yamlThe command reads the server configuration only to learn which storage to connect
to (-c defaults to issuerd.toml in the working directory), then:
- Connects to the configured storage — for PostgreSQL it also runs pending schema
migrations first (
src/main.rs). - Applies the provision file exactly once (same marker mechanism as the daemon).
- 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).
File format
Section titled “File format”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:
issuerd example provision-config -o provision.yamlNote:
issuerd exampledefaults its output toexamples/issuerd.example.tomlfor both example kinds — always pass-oexplicitly 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: [ ... ]Environment variable substitution
Section titled “Environment variable substitution”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 controlThis applies equally to daemon startup provisioning and the CLI, and is the recommended way to keep credentials out of the checked-in file.
Application order and per-entity skips
Section titled “Application order and per-entity skips”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_configsGroups 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.
Built-in clients per realm
Section titled “Built-in clients per realm”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.
Entity reference
Section titled “Entity reference”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.
realms
Section titled “realms”| 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: valueCreates 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 rolegroups
Section titled “groups”| 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-appclients
Section titled “clients”| 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: trueNote: A confidential client provisioned without
secretends 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]identity_providers
Section titled “identity_providers”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"flow_configs
Section titled “flow_configs”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: 20Flow 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.
Bootstrap interactions
Section titled “Bootstrap interactions”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
masterrealm with useradmin/ passwordadmin, - the realm-management roles (
manage-realm,view-realm,manage-users,view-users,manage-clients,view-clients,impersonation) assigned toadmin, - the built-in
admin-cliandaccount-consoleclients.
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
masterrealm has no admin user and no way to log into the admin console. Always definemaster, the realm-management roles, and anadminuser in the provision file — asexamples/provision.demo.yaml(demo stack) andcluster/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.
Re-runs and idempotency
Section titled “Re-runs and idempotency”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
provisionfile 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.
Complete worked example
Section titled “Complete worked example”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:
# 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.tomlCommon pitfalls
Section titled “Common pitfalls”- 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 forprovision 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 withrealm '<name>' not found— and because of the marker behavior above, leaves a partially provisioned database. - Group
client_rolessilently missing. Groups are applied after clients, but the provisioner never creates client roles; aclient_rolesmapping 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 unknownrealm_roleson groups and users, and to unknown groups on users. - Locked out on PostgreSQL. Without a provisioned
masterrealm there is noadminuser and the admin console is unusable. Provisionmaster(realm + realm-management roles + admin user) as inexamples/provision.demo.yaml, or repair out-of-band withissuerd 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. systemdEnvironmentFile, 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_requiredtypos. Only the exact valuesnoneandallare special; anything else — includingExternalorrequired— silently meansexternal.- Zero token lifespans. A lifespan of
0is 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 fromissuer_url); file entries with the sameclient_idare skipped as duplicates.
For operational diagnosis of a running server (logs, health endpoints, common failure modes), see troubleshooting.md.