Skip to content

Identity and access

This page explains how people and programs sign in, and how you decide what each of them may do.

  • People sign in to the console with your organisation’s identity provider over OIDC (single sign-on). For air-gapped and quickstart installs they can sign in as a local user instead. Either way the engine keeps a browser session; the console never holds a token.
  • Programs (job workers, scripts, SDKs, connector runtimes) each get an API client, created in the console’s Access area or through the administration API, and fetch short-lived tokens from the engine itself with the client-credentials token endpoint. Static tokens from configuration are for bootstrap and automated tests.

What someone may do is decided by permissions. A permission is an action on a kind of resource, written like instance:create (start a process instance) or task:complete (complete a user task). You give permissions to users, groups, roles, API clients and mapping rules, through roles or through grants (described under Permissions). You manage them in the console’s Access area, through the administration API, or from a configuration file at startup.

Static machine credentials (CONDUCTOR_AUTH_CREDENTIALS_JSON, for bootstrap and tests), the first-boot token, token rotation and the network exposure rules are described in Bundled mode and in the container image guide. Who may see and act on which user task is described in Task authorization.

The engine checks a request’s credentials in this order:

  1. an Authorization: Bearer JSON Web Token: first one the engine issued itself, then one from the identity provider;
  2. a static token;
  3. the session cookie: __Host-hb_session, or hb_session when the engine runs over plain HTTP for local development (CONDUCTOR_SESSION_INSECURE_COOKIE).

The __Host- prefix tells the browser to accept the cookie only from this exact host over HTTPS, for the whole site and with no Domain, so no other site, not even another subdomain of yours, can set or replace it. The short cookie that carries a single sign-on in progress (__Host-hb_login) works the same way.

A request that carries a bearer header is judged on that header alone. A request signed in by cookie that changes something (POST, PUT, PATCH, DELETE) must carry an X-CSRF-Token header equal to the session’s token, or it is refused with 403.

With no OIDC configured, Bundled mode enables local users by default. On the first boot it creates one administrator (username CONDUCTOR_BOOTSTRAP_ADMIN_USERNAME, default admin, role admin), stores only its argon2id hash in the engine’s own database, and writes its password once to the first-boot secrets file, never to the log. The file is in the data directory (CONDUCTOR_DATA_DIR; in the Bundled image /var/lib/postgresql/data/tinyconductor/first-boot-secrets), readable only by the engine’s user, and the log says where it is:

CONDUCTOR_BOOTSTRAP_ADMIN_PASSWORD 3f0c…

Read it once, keep it in your password manager and delete the file.

Further local users come from configuration (CONDUCTOR_LOCAL_USERS_JSON), with argon2id hashes and roles. Produce a hash without the password touching a log or a shell history:

Terminal window
printf '%s' 'the password' | docker exec -i tinyconductor tinyconductor hash-password
[{"username":"olivia","passwordHash":"$argon2id$v=19$m=19456,t=2,p=1$…","roles":["operator"],"groups":["ops"],"name":"Olivia"}]

A wrong password and an unknown username get the same answer. Five failures for one username, or fifty from one client address, within 15 minutes pause further attempts (429) until the window moves on.

Sign in with your identity provider (OIDC)

Section titled “Sign in with your identity provider (OIDC)”

People normally sign in with the identity provider your organisation already runs — Keycloak, Microsoft Entra ID, Okta and so on. TinyConductor is an OIDC client (Authorization Code with PKCE) for the console, and an OIDC resource server for the API: a worker can send the provider’s access token as Authorization: Bearer <jwt> instead of a static token.

Configuring OIDC turns local users off unless you also set CONDUCTOR_LOCAL_USERS_ENABLED=1, so keep one of them — the bootstrap admin, or an administrator mapped from your provider — before you rely on it.

Register TinyConductor as a client of your provider with redirect URI https://<engine>/auth/callback (Authorization Code with PKCE). Everything the provider must provide is listed, with the setup for Keycloak, Microsoft Entra ID, Okta and Auth0, on Connecting your identity provider. Then:

VariableMeaning
CONDUCTOR_OIDC_ISSUERIssuer URL, exactly as the provider publishes it (https://…; plain http only for loopback). Discovery is <issuer>/.well-known/openid-configuration
CONDUCTOR_OIDC_CLIENT_IDClient id. Required with the issuer
CONDUCTOR_OIDC_CLIENT_SECRETClient secret; omit for a public client
CONDUCTOR_OIDC_CLIENT_SECRET_FILEA file holding the client secret instead, such as a mounted secret. Setting both refuses to start
CONDUCTOR_OIDC_AUDIENCEThe aud API access tokens must carry. Defaults to the client id
CONDUCTOR_OIDC_REDIRECT_URIhttps://<engine>/auth/callback. Without it only API bearer tokens are accepted, and the console offers no single sign-on button
CONDUCTOR_OIDC_SCOPESComma-separated, default openid,profile,email; must include openid
CONDUCTOR_OIDC_DISPLAY_NAMELabel of the sign-in button
CONDUCTOR_OIDC_CLOCK_SKEW_SECONDSTolerance for exp and nbf, default 60
CONDUCTOR_OIDC_USERNAME_CLAIMThe claim that holds a person’s username: a name, or a dotted path such as extension.login. Unset: preferred_username, then email, then sub. Set: that claim only, and a sign-in or token without it is refused (see Who a person is)
CONDUCTOR_OIDC_DISPLAY_NAME_CLAIMThe claim that holds a person’s display name for the console, default name. Without it the console shows the username
CONDUCTOR_OIDC_ALIASShort name of this provider in usernames such as alice@corp-ad, default sso: lowercase letters, digits and -, not local (see Identity providers and qualified usernames)
CONDUCTOR_OIDC_TENANTSComma-separated tenants this provider’s people and programs may act in. Unset: any tenant a rule, record or membership gives them (see Several identity providers)
CONDUCTOR_OIDC_PROVIDERSComma-separated aliases of further providers (see Several identity providers)
CONDUCTOR_IDENTITY_DEFAULT_PROVIDERThe provider whose people a plain username such as alice names: local or an OIDC alias. Required when more than one provider is on (local users count as one); otherwise the one configured
CONDUCTOR_AUTH_MAPPING_RULES_JSONOrdered rules from claims to groups and roles (below)
CONDUCTOR_AUTH_ROLES_JSONOverrides or adds roles (below)
CONDUCTOR_LOCAL_USERS_ENABLED1/true or 0/false. Default: on in Bundled mode without OIDC, off otherwise
CONDUCTOR_LOCAL_USERS_JSONConfigured local users (above). Refused when local users are off
CONDUCTOR_LOCAL_USERS_TENANT_IDCluster mode: the tenant that configured local users and the bootstrap admin belong to. Default: the starting tenant (see Tenants). It must be a tenant the engine serves
CONDUCTOR_BOOTSTRAP_ADMIN_USERNAMEDefault admin
CONDUCTOR_SESSION_IDLE_TIMEOUT_S / CONDUCTOR_SESSION_ABSOLUTE_TIMEOUT_SDefault 1800 and 43200 seconds
CONDUCTOR_SESSION_INSECURE_COOKIE1 drops Secure from the cookies and with it the __Host- prefix, which browsers accept only on Secure cookies: the session cookie is then hb_session. Loopback development over plain HTTP only

Tokens are verified against the provider’s published keys (RS256 or ES256 only); an unknown key id triggers one refetch, so a key rollover needs no restart. iss, aud, exp and nbf are checked with the configured skew.

TinyConductor can trust more than one OIDC provider, for example your staff directory and a partner’s. Providers are told apart by their issuer: a token names its issuer, and that provider’s keys, settings, mapping rules and tenants apply to it. One issuer is one provider.

The variables above configure the first provider, under the names every TinyBlox component uses. Further providers each get an alias, listed in CONDUCTOR_OIDC_PROVIDERS, and the same settings under CONDUCTOR_OIDC_<ALIAS>_<SETTING>: the alias in capitals, with _ for -. For a provider partner-idp:

VariableMeaning
CONDUCTOR_OIDC_PROVIDERSpartner-idp (several: partner-idp,contractors)
CONDUCTOR_OIDC_PARTNER_IDP_ISSUERIts issuer. Required, and not the issuer of another provider
CONDUCTOR_OIDC_PARTNER_IDP_CLIENT_IDIts client id. Required
CONDUCTOR_OIDC_PARTNER_IDP_CLIENT_SECRET or CONDUCTOR_OIDC_PARTNER_IDP_CLIENT_SECRET_FILEIts client secret, or a file holding it
CONDUCTOR_OIDC_PARTNER_IDP_AUDIENCE, CONDUCTOR_OIDC_PARTNER_IDP_REDIRECT_URI, CONDUCTOR_OIDC_PARTNER_IDP_SCOPES, CONDUCTOR_OIDC_PARTNER_IDP_DISPLAY_NAME, CONDUCTOR_OIDC_PARTNER_IDP_CLOCK_SKEW_SECONDS, CONDUCTOR_OIDC_PARTNER_IDP_USERNAME_CLAIM, CONDUCTOR_OIDC_PARTNER_IDP_DISPLAY_NAME_CLAIM, CONDUCTOR_OIDC_PARTNER_IDP_TENANTSAs for the first provider

With more than one provider:

  • the console shows one sign-in button per provider that has a redirect URI, the first provider’s on top. /auth/login?provider=<alias> starts a sign-in with one of them; without provider, with the first;
  • each mapping rule names the provider whose tokens it reads with "provider": "<alias>". A rule without one refuses to start, so a group name from one provider never grants through a rule written for another;
  • CONDUCTOR_IDENTITY_DEFAULT_PROVIDER names whose people a plain username means. The others are written alice@<alias>;
  • <ALIAS>_TENANTS keeps a provider’s people and programs to the tenants it lists, whatever a rule, a user record or a tenant membership says. Set it when providers belong to different customers, so one customer’s provider can never reach another customer’s tenant.
Terminal window
-e CONDUCTOR_OIDC_ISSUER=https://login.example.com/realms/staff \
-e CONDUCTOR_OIDC_CLIENT_ID=tinyconductor \
-e CONDUCTOR_OIDC_CLIENT_SECRET_FILE=/run/secrets/staff-client-secret \
-e CONDUCTOR_OIDC_REDIRECT_URI=https://bpm.example.com/auth/callback \
-e CONDUCTOR_OIDC_ALIAS=staff \
-e CONDUCTOR_OIDC_DISPLAY_NAME='Staff' \
-e CONDUCTOR_OIDC_PROVIDERS=partner \
-e CONDUCTOR_OIDC_PARTNER_ISSUER=https://partner.example.net \
-e CONDUCTOR_OIDC_PARTNER_CLIENT_ID=tinyconductor \
-e CONDUCTOR_OIDC_PARTNER_CLIENT_SECRET_FILE=/run/secrets/partner-client-secret \
-e CONDUCTOR_OIDC_PARTNER_REDIRECT_URI=https://bpm.example.com/auth/callback \
-e CONDUCTOR_OIDC_PARTNER_DISPLAY_NAME='Partner' \
-e CONDUCTOR_OIDC_PARTNER_TENANTS=partner \
-e CONDUCTOR_IDENTITY_DEFAULT_PROVIDER=staff \
-e 'CONDUCTOR_AUTH_MAPPING_RULES_JSON=[{"claim":"groups","equals":"bpm-admins","roles":["admin"],"provider":"staff"},{"claim":"groups","equals":"approvers","tenantId":"partner","roles":["task-worker"],"provider":"partner"}]'

Staff sign in as alice; the partner’s people are bob@partner and act only in the tenant partner.

A person is the identity provider they sign in with and their id there: the provider’s issuer and the sub of their sign-in, or, for a local user, local and their username. Their username is only what they are called: the name the console shows, the assignee when they claim a task, what candidateUsers and assignee in a model and in API requests name, and the name of their user record in the Access area. Permissions, group, role and tenant memberships, sessions and saved task filters belong to the person, not to the username.

The username comes from a claim of the sign-in (and of the access tokens workers send on a person’s behalf). Many providers put an opaque id in sub — Dex sends something like CiQwYTFiMmMzZC0wMDAw…, Microsoft Entra ID an object id — so by default the username is preferred_username, then email, then sub, whichever comes first. Set CONDUCTOR_OIDC_USERNAME_CLAIM to choose one claim yourself; then only that claim counts, and a sign-in or token that lacks it is refused with a message naming the claim.

Identity providers and qualified usernames

Section titled “Identity providers and qualified usernames”

Each identity provider has a short alias: local for local users, for the first OIDC provider the value of CONDUCTOR_OIDC_ALIAS (default sso; lowercase letters, digits and -, such as corp-ad or github), and for further providers the alias CONDUCTOR_OIDC_PROVIDERS lists. One provider is the default: the only one configured, or the one CONDUCTOR_IDENTITY_DEFAULT_PROVIDER names, which is required when more than one is on.

  • alice names alice of the default provider.
  • alice@corp-ad names alice of the provider corp-ad.
  • Answers name a person of the default provider plainly (alice) and anyone else with their provider’s alias (alice@corp-ad); the session (GET /auth/session) answers with the person’s name, provider and id.
  • A username that contains @, such as wanda@contoso.com, is a plain username: contoso.com is no alias (aliases have no dot). The same person at another provider is wanda@contoso.com@corp-ad.

So alice of the default provider and alice@corp-ad are two people: neither sees or claims the other’s tasks, and neither receives the other’s permissions, groups, roles or tenants. A model or API request meant for the second one names her alice@corp-ad.

When the identity provider renames someone

Section titled “When the identity provider renames someone”

The same person (same issuer and id) arriving with a new username keeps their user record, permissions and memberships; the record shows the new username. Their earlier username stays theirs: tasks assigned to it or offering it as a candidate are still theirs, and nobody else can sign in with it. The Access area lists it under Earlier usernames. To free it, delete the user by that earlier username (DELETE /v2/users/carol@corp-ad): only the earlier username is released, the person keeps everything else.

A different person (another id at the same provider) arriving with a username that is, or was, someone else’s in the tenant is refused with 403 — at sign-in and on every API request — and the engine logs auth.username_reused on the bpm::audit target. The earlier person keeps their record, permissions, memberships and tasks; nothing passes to the newcomer. Either give the newcomer a username of their own in the identity provider, or release the username: delete the earlier person’s user record (Access area, or DELETE /v2/users/bob@corp-ad). That removes their permissions and memberships with it. Tasks name people by username, so reassign the earlier person’s open tasks before you release the username: afterwards the name refers to the newcomer.

An administrator (or the initialization document) can create a user, grant it permissions and add it to groups before the person ever signs in: bob@corp-ad names bob of corp-ad. The first sign-in with that username binds the record to that person’s id; from then on it is theirs alone. A local user’s record is bound from the start.

User records that an engine finds in its database at start without a subject are read the same way: a local user’s record is local with the username, and any other record is a user of the default provider that the next sign-in with that username binds to the person signing in.

CONDUCTOR_OIDC_DISPLAY_NAME_CLAIM (default name) is only for display: the console shows it next to the username, or the username when the claim is missing.

Programs that sign in as themselves rather than as a person — tokens a mapping rule marks with "kind": "service" — keep sub as their id and need no username claim. The engine’s own client tokens and static tokens are unaffected.

Examples:

ProviderSettingUsername
Keycloakleave unsetpreferred_username, the Keycloak username (olivia)
Dex, built-in password databaseCONDUCTOR_OIDC_USERNAME_CLAIM=nameDex puts the username in name and sends no preferred_username; unset, the username would be the email
Dex with LDAP or another upstreamleave unsetpreferred_username when the upstream provides it, otherwise the email
Microsoft Entra IDleave unset, or CONDUCTOR_OIDC_USERNAME_CLAIM=preferred_usernamethe sign-in name, such as wanda@contoso.com. Entra’s sub and oid are opaque ids and make poor usernames
A custom claimCONDUCTOR_OIDC_USERNAME_CLAIM=extension.loginthe value at that path

With Dex’s built-in password database, for example:

Terminal window
docker run -d --name tinyconductor \
-p 127.0.0.1:8080:8080 \
-v tinyconductor-pgdata:/var/lib/postgresql/data \
-e CONDUCTOR_OIDC_ISSUER=https://dex.example.com/dex \
-e CONDUCTOR_OIDC_CLIENT_ID=tinyconductor \
-e CONDUCTOR_OIDC_CLIENT_SECRET=change-me \
-e CONDUCTOR_OIDC_REDIRECT_URI=https://bpm.example.com/auth/callback \
-e CONDUCTOR_OIDC_DISPLAY_NAME='Sign in with Dex' \
-e CONDUCTOR_OIDC_USERNAME_CLAIM=name \
-e 'CONDUCTOR_AUTH_MAPPING_RULES_JSON=[{"claim":"email","equals":"wanda@example.com","roles":["task-worker"]}]' \
registry.tinyfactory.ai/tinyblox/tinyconductor-bundled:0.1.0

Wanda signs in and is wanda: a model with candidateUsers="wanda" offers her its task, and a task she claims shows wanda as its assignee. With local users switched on as well and CONDUCTOR_IDENTITY_DEFAULT_PROVIDER=local, she would be wanda@sso, and a model would name her that way.

With the Helm chart the settings are oidc.usernameClaim, oidc.displayNameClaim, oidc.alias and auth.defaultProvider.

Mapping rules turn claims into groups and roles. Each rule matches when the claim (a name, or a dotted path such as realm_access.roles) equals the value, or contains it when the claim is an array. A person gets the union of every matching rule. If no rule matches, the person is signed in with no permissions: every route answers 403 with a reason naming the mapping rules.

[{"claim":"groups","equals":"bpm-operators","groups":["ops"],"roles":["operator"]},
{"claim":"groups","equals":"bpm-approvers","roles":["task-worker"]},
{"claim":"azp","equals":"prometheus-scraper","roles":["operator"],"kind":"service"}]

kind is optional (user by default); set service for a machine’s client-credentials token. Scraping /metrics needs no token: it is served on the metrics port without a credential. provider names the provider whose tokens a rule reads; it is required with several identity providers and optional with one.

A rule can name a tenant with tenantId. A rule without tenantId always applies to the starting tenant (default, or the one CONDUCTOR_INITIAL_TENANT named on the first start), however many tenants you add later. A person whose rules match in more than one tenant can act in each of them, with that tenant’s groups and roles, and chooses the tenant per request (see Several tenants).

A rule may name a tenant you have not created yet. The engine still starts; it logs a warning and leaves that rule out. Once the tenant exists, restart the engine (one node at a time in a cluster) and the rule applies.

Mapping rules can also be stored in the engine instead of the environment (Access → Mapping rules, or /v2/mapping-rules). A stored rule has an id, a claim and a value; it grants nothing by itself, but it can be a member of groups and roles and can own grants, and a token that matches it may act in its tenant. Changes to stored rules reach a browser session at its next sign-in.

Roles are named sets of permissions. The built-ins are admin (*; tenant administration only in the starting tenant, see Who administers tenants), operator (operations:read, operations:health, history:read, audit:read, metrics:read, decision:evaluate, cluster-variable:read: operators see cell health, Workflows, Analytics and instance diagrams, and the audit log), task-worker (task:read, task:claim, task:complete), viewer (history:read, operations:read, operations:health, task:read, cluster-variable:read) and tenant-operator (tenant:admin: manages tenants and nothing else, and only when held in the starting tenant; see Automating tenant management). CONDUCTOR_AUTH_ROLES_JSON replaces a built-in of the same name or adds a role; an entry may be * or a family such as task:*. An unknown permission stops the engine from starting:

{"operator":["operations:*","history:read","audit:read","metrics:read","instance:cancel","variable:set","incident:resolve"],
"approver":["task:*"]}

Roles named in configuration and mapping rules apply only to people signed in through OIDC or as local users. A static token keeps the exact actions its credential lists, so a person whose username happens to equal a token’s subject gains nothing from it. Anyone — a user, a group, a client, a mapping rule — can also be made a member of a role in the Access area; the built-in roles are listed there and take members, but cannot be changed.

A grant gives an owner some permissions on a resource:

FieldValues
Owner typeUSER (a person, named by username: alice or alice@corp-ad, see Who a person is), GROUP, ROLE, CLIENT (an API client or a static token’s subject), MAPPING_RULE
Resource typePROCESS_DEFINITION (BPMN process id), PROCESS_INSTANCE (instance key), USER_TASK (task key), DECISION_DEFINITION (decision id), RESOURCE (resource name), MESSAGE, SIGNAL (name), JOB (job type), TENANT (tenant id), CLUSTER_VARIABLE (name), and AUDIT_LOG, IDENTITY, SYSTEM, which take only *
Resource idOne resource, or * for every resource of the type
PermissionsThe permissions this page and Task authorization use, such as instance:create, history:read or task:complete, limited to those that make sense for the type; * means all of them

A person or client may do something when a static credential, a role it holds or a grant it owns (directly, through a group, a role or a mapping rule) allows it. There are no deny rules, and nothing is allowed by default.

Some examples:

To let…Grant
the finance group start and follow invoices onlyGROUP finance · PROCESS_DEFINITION invoice · instance:create, history:read
a worker client handle one job typeCLIENT billing-worker · JOB send-invoice · job:activate, job:complete, job:fail
a partner client publish one messageCLIENT partner · MESSAGE order-received · message:publish
an auditor read the audit logUSER carol · AUDIT_LOG * · audit:read
a team lead manage users and grantsUSER dana · IDENTITY * · identity:read, identity:write
a deployment client change one configuration valueCLIENT deployer · CLUSTER_VARIABLE PAYMENT_ENDPOINT · cluster-variable:read, cluster-variable:update

A grant on named resources takes effect where the engine can tell which resource a request is about: starting an instance (the process is in the body), cancelling, modifying, migrating and setting variables on an instance (its process is looked up), user-task actions (the task’s process), evaluating a decision, publishing a message, reading one instance or definition, and every cluster-variable operation (by the variable’s name). Searches of instances, element instances, variables, incidents, jobs, decisions and message subscriptions, and the history searches, answer only what you may read: the instances of the processes your grants name and the instances they name. Pages are full and totals count only those items. A report that spans every process needs history:read on *. Every other route needs the permission on *.

Who may see and act on each user task — its assignee, candidate users and candidate groups — is user-task property authorization, described in Task authorization. Group memberships you store in the engine count as candidate groups too.

A tenant is a separate, isolated space for one team or customer, with its own processes, data and permissions. Bundled and Cluster mode start with one tenant and you add more while the engine runs (see Tenants). One person or client can act in several tenants, in any of these ways:

  • mapping rules that match in each of them;
  • membership of a tenant, which a tenant operator gives (the console’s Tenants area, or PUT /v2/tenants/{tenantId}/users/{username});
  • a user or client record in that tenant, which the tenant’s own administrator creates in the Access area. A user record counts only for the person it belongs to (see Who a person is): the same username at another provider, or a username given to someone new, reaches no tenant through it.

Such a caller must name the tenant of every request in the X-Tenant-Id header:

  • without the header the request is refused with 400 and the reason lists the tenants to choose from;
  • a tenant the caller may not act in is refused with 403.

A caller of one tenant needs no header; if it sends one, it must name its tenant. Grants and roles are per tenant: what a static token or a local user may do in its own tenant does not follow it into another. GET /auth/session lists the caller’s tenants, and the console shows a tenant switcher in its top bar when there is more than one.

A suspended tenant (More actions → Suspend tenant… on the tenant’s page in the console, or POST /v2/tenants/{tenantId}/suspension) refuses every request except tenant administration until it is reactivated.

Nothing in TinyConductor records which customer, team or group of your identity provider a tenant belongs to: the link is the mapping rule that names the tenant. Keep it simple by using one name for both: for a customer acme, the identity provider has a group acme (sent in the groups claim as acme), and TinyConductor has the tenant acme with a mapping rule that names it:

[{"claim": "groups", "equals": "acme", "tenantId": "acme", "roles": ["operator"]}]

Each rule names its tenant as a fixed value; no rule takes the tenant from a claim. Write the list of customers, with their group and tenant id, in the source you provision from (for example the script or pipeline that creates tenants), so you can check that every tenant has its rule and its group. Create the tenant before its rule: a rule that names a tenant that does not exist yet is left out until the engine restarts.

An imported tenant keeps the id you import it under, so a tenant restored under its own id needs no change to its rule or group; one imported under another id needs a rule that names the new id.

When a customer leaves, remove things in this order:

  1. purge the tenant (export it first if you may need it again; see Exporting, importing and purging a tenant);
  2. remove the mapping rule that names it;
  3. remove the group in your identity provider.

The purge also removes the binding of any reporting tool’s database login to the tenant (see Analytics), so such a login reads nothing of a new tenant given the same id. A database administrator may drop the login itself afterwards.

After the purge the tenant id can be used again, for example when the customer comes back.

Seeding users, groups, roles, grants and clients

Section titled “Seeding users, groups, roles, grants and clients”

CONDUCTOR_AUTH_INIT_JSON (a JSON document) or CONDUCTOR_AUTH_INIT_FILE (the path of one) seeds the permission model at every startup. Seeding creates or updates what the document names and never deletes anything, so what you add later in the console survives a restart. The document is checked completely first; a mistake refuses to start.

{"tenants": [{
"tenantId": "default",
"users": [{"username": "carol", "name": "Carol", "email": "carol@example.com"}],
"groups": [{"groupId": "finance", "name": "Finance",
"members": [{"type": "USER", "id": "carol"}]}],
"roles": [{"roleId": "operator", "members": [{"type": "GROUP", "id": "finance"}]},
{"roleId": "invoice-clerk", "name": "Invoice clerk"}],
"mappingRules": [{"mappingRuleId": "finance-claim", "claimName": "groups", "claimValue": "finance"}],
"clients": [{"clientId": "billing-worker", "name": "Billing worker",
"secretHash": "$argon2id$v=19$m=19456,t=2,p=1$…"}],
"tenantMembers": [],
"authorizations": [
{"ownerType": "GROUP", "ownerId": "finance", "resourceType": "PROCESS_DEFINITION",
"resourceId": "invoice", "permissionTypes": ["instance:create", "history:read"]},
{"ownerType": "CLIENT", "ownerId": "billing-worker", "resourceType": "JOB",
"resourceId": "send-invoice", "permissionTypes": ["job:activate", "job:complete", "job:fail"]}
]
}]}

A role id that is built in or configured (operator above) only gets members; another role id creates a stored role. Users, user members and user grant owners are named like everywhere else (carol for the default identity provider, carol@corp-ad for another); a user who has not signed in yet is bound at their first sign-in (see Users created before they first sign in). Clients are seeded with the argon2id hash of their secret, never the secret: produce it with tinyconductor hash-password, as shown for local users. Each section must name an existing tenant; a section for any other tenant is refused. Create tenants in the console or through the tenant API first (see Tenants); a section may give an existing tenant a name.

With CONDUCTOR_OAUTH_ISSUER set, the engine is its own OAuth2 issuer for the client-credentials grant, which is what compatible client SDKs, command-line tools and connector runtimes use:

VariableMeaning
CONDUCTOR_OAUTH_ISSUERThe engine’s public base URL, for example https://bpm.example.com (https except for loopback). It is the tokens’ iss, and the endpoint is <issuer>/oauth/token
CONDUCTOR_OAUTH_AUDIENCEThe tokens’ aud, default tinyconductor-api
CONDUCTOR_OAUTH_TOKEN_TTL_SToken lifetime, default 600 seconds (60–3600)
CONDUCTOR_OAUTH_KEY_ROTATION_SHow long a signing key signs, default one week
CONDUCTOR_OAUTH_KEY_OVERLAP_SHow long a replaced key is still published and accepted, default twice the token lifetime and never less than one
CONDUCTOR_SIGNING_KEY_ENCRYPTION_KEY32 random bytes, base64-encoded (openssl rand -base64 32), that encrypt the signing keys stored in PostgreSQL. Required in Cluster mode, the same on every node. In Bundled mode, when it is unset, the engine generates one on its first start into its data directory. Keep it with your backups: without it, the stored keys cannot be read and the engine does not start
CONDUCTOR_SIGNING_KEY_ENCRYPTION_KEY_FILEA file holding that key instead, for example a mounted secret

Create a client in Access → Clients (or POST /v2/clients). Its secret is shown once; the engine keeps only an argon2id hash. Give the client what it needs with grants or role memberships, then point the SDK’s or the connector runtime’s OAuth settings at the engine: the token URL https://bpm.example.com/oauth/token, the client id, the secret, and — if the client asks for one — the audience tinyconductor-api. Or ask for a token yourself:

Terminal window
curl -s -u billing-worker:hbs_… -d grant_type=client_credentials \
https://bpm.example.com/oauth/token
# {"access_token":"eyJ…","token_type":"Bearer","expires_in":600}

The token endpoint accepts the client id and secret as HTTP Basic credentials or as client_id and client_secret form (or JSON) fields; any audience or scope parameter is ignored. The keys are published at /.well-known/jwks.json and the endpoint at /.well-known/openid-configuration. Signing keys rotate on their own: the newest key signs, and the previous one keeps verifying for the overlap window, so no live token is cut off. In Bundled and Cluster the keys, clients and grants are in the engine’s PostgreSQL, so every replica issues and accepts the same tokens. The private half of each signing key is stored encrypted (AES-256-GCM) with the key encryption key above, which never reaches the database, so a copy or backup of the database alone cannot mint tokens. The engine decrypts the keys in memory only. An engine started with a different key encryption key than the one the keys were stored with refuses to start and says so. Embedded mode keeps its signing keys in memory only.

Rotate a secret with Access → Clients → Rotate secret… (or POST /v2/clients/{clientId}/secret): the new secret is shown once and the old one stops working at once. Revoke a client to stop it everywhere, its live tokens included. A wrong secret and an unknown client get the same invalid_client answer; repeated failures for one client id are slowed down and every failure is in the audit log.

Client ids are unique across the whole installation, not per tenant: the token endpoint knows a client by its id alone, so two tenants cannot both have a client billing-worker. A client id is a name, not a secret; the secret is what protects the client. The administration API never tells one tenant whether another tenant has a client of some id: reading, changing, rotating, revoking or deleting a client of another tenant is answered exactly as for an id nobody has (404), and a client can be named in a group, a role or a permission before it exists (as a user can), so naming one answers the same whether or not the id exists anywhere. Creating a client whose id is taken, in your tenant or in another, is refused with the same 409 “client id … is not available” either way; choose another id, for example one that starts with your tenant’s name.

Everything in the Access area is also a v2-shaped API, documented in the engine’s OpenAPI document (/v2/users, /v2/groups, /v2/roles, /v2/mapping-rules, /v2/authorizations, /v2/clients, /v2/tenants, with …/search for lists and PUT/DELETE on /v2/groups/{groupId}/users/{username} and its siblings for memberships). It works on the caller’s (selected) tenant and needs:

PermissionFor
identity:readreading and searching users, groups, roles, mapping rules, grants and clients
identity:writecreating, changing and deleting them, memberships, secret rotation and revocation
tenant:admintenants: list, create, change name, limits and history retention, suspend, reactivate, move, and tenant members and administrators. Held only in the starting tenant (see below). Creating a tenant needs it on every tenant (TENANT *), which the built-in roles admin and tenant-operator give there

In the starting tenant the built-in admin role has all three. In every other tenant it has everything except tenant administration.

In a cluster every node answers with the same records. Once a change is answered, through whichever node, the next request to any node sees it: a new user, group, role, mapping rule, grant or API client can be read and used there, a new client gets a token there, and a deleted or revoked client, a deleted user and a removed grant are refused there. A script behind a load balancer does not need to wait or retry between steps. If a node cannot read the identity records from PostgreSQL, it answers 503 instead of deciding from records that may be out of date.

Tenants are run by the operators of the whole installation: the administrators of the starting tenant (the tenant created on the very first start, default unless CONDUCTOR_INITIAL_TENANT named another; the Bundled bootstrap admin and first-boot token belong to it) and the tenant operators they name there. Tenant administration is held only in the starting tenant:

  • The administrator of any other tenant runs that tenant’s own users, groups, roles, mapping rules, API clients and grants, and nothing about tenants. It cannot list tenants, read, change, suspend, reactivate, move or delete any tenant (its own included), name administrators or members of one, or create one. Such a request answers 403, whichever tenant it names, so it does not tell which tenants exist.
  • Nobody grants tenant administration without holding it on every tenant: a grant on TENANT, membership of the admin or tenant-operator role in the starting tenant (or of a group that holds one), a change to a mapping rule that holds one, and a new secret for an API client that holds one are refused with 403 for anyone else, even with identity:write. Outside the starting tenant grants on TENANT and the tenant-operator role are refused for everyone.
  • A static token that should administer tenants names the starting tenant as its tenantId.

The console follows the same rules: the Tenants area appears only while you work in the starting tenant with tenant administration, and the grant form offers the TENANT resource type only then. Embedded mode serves one fixed tenant: creating a tenant or changing its limits there answers 501. A tenant is removed with a purge, not with DELETE /v2/tenants/{tenantId}, which answers 409 (see Exporting, importing and purging a tenant).

A program that creates and changes tenants for you, such as a provisioning script or a pipeline, gets its own API client with the built-in tenant-operator role. That role may list, create, change, suspend, reactivate, export, import and purge tenants, and nothing else: it cannot start, read or change processes, instances, tasks or data through the API, and cannot manage users or grants. An export holds all of a tenant’s data, so keep such a client’s secret as safely as the database itself.

The token endpoint must be on (CONDUCTOR_OAUTH_ISSUER, see Tokens for SDKs and connectors). An administrator of the starting tenant sets the client up once, in the starting tenant (the role gives nothing in any other tenant); $ADMIN_TOKEN is their token, for example the first-boot token of a Bundled engine:

Terminal window
BPM=https://bpm.example.com
# 1. Create the client. The answer shows its secret once: keep it safe.
curl -s -X POST $BPM/v2/clients \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"clientId": "tenant-bot", "name": "Tenant automation"}'
# {"clientId":"tenant-bot","clientSecret":"hbs_…",…}
# 2. Give it the tenant-operator role.
curl -s -X PUT $BPM/v2/roles/tenant-operator/clients/tenant-bot \
-H "Authorization: Bearer $ADMIN_TOKEN"

The program then gets a short-lived token and uses it:

Terminal window
TOKEN=$(curl -s -u tenant-bot:hbs_… -d grant_type=client_credentials \
$BPM/oauth/token | jq -r .access_token)
# Create a tenant with a few limits and 7 days of history.
curl -s -X POST $BPM/v2/tenants \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"tenantId": "acme", "name": "Acme",
"quotas": {"activeInstanceLimit": 10000, "jobsPerSec": 1000},
"retention": {"recordRetentionMicros": 604800000000}}'
# Change a limit and a size limit, suspend, reactivate, list.
curl -s -X PUT $BPM/v2/tenants/acme -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"quotas": {"jobsPerSec": 2000}, "limits": {"maxVariablesBytes": 1048576}}'
curl -s -X POST $BPM/v2/tenants/acme/suspension -H "Authorization: Bearer $TOKEN"
curl -s -X POST $BPM/v2/tenants/acme/activation -H "Authorization: Bearer $TOKEN"
curl -s -X POST $BPM/v2/tenants/search -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{}'

The same steps work in the console: Access → Clients creates the client and shows its secret once; Access → Roles → tenant-operator adds it as a member. In automated tests, a static token with "actions": ["tenant:admin"] and the starting tenant as its tenantId in CONDUCTOR_AUTH_CREDENTIALS_JSON can do the same.

A new tenant is empty. The program that creates it names who runs it, in the same request or later, with admins. Each entry is a user (a local username or an identity-provider username; it need not have signed in yet) or an API client. It becomes a member of the tenant and holds the built-in admin role there, so it may do everything in that tenant at once, with no restart:

Terminal window
# Create the tenant and name its administrator in one request.
curl -s -X POST $BPM/v2/tenants \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"tenantId": "acme", "name": "Acme",
"admins": [{"memberType": "USER", "memberId": "dana@acme.example"},
{"memberType": "CLIENT", "memberId": "acme-deployer"}]}'
# Or add an administrator later.
curl -s -X PUT $BPM/v2/tenants/acme -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"admins": [{"memberType": "USER", "memberId": "erik@acme.example"}]}'

A tenant and the administrators named when it is created are saved together, so they work on every node from the first request. An administrator added later works on every node of a cluster as soon as the request that added them is answered. If a create request fails or its answer is lost, send the same request again: it finishes the tenant and answers 201; the same tenant id with other values answers 409.

GET /v2/tenants/acme lists them under admins. The tenant operator itself gains nothing: it cannot name itself, and it still cannot read or change anything in the tenant.

The administrator then works in the new tenant by naming it in the X-Tenant-Id header (it now belongs to two tenants, so every request names one). A client gets its token as usual:

Terminal window
ADMIN=$(curl -s -u acme-deployer:hbs_… -d grant_type=client_credentials \
$BPM/oauth/token | jq -r .access_token)
curl -s -X POST $BPM/v2/deployments -H "Authorization: Bearer $ADMIN" \
-H 'X-Tenant-Id: acme' -F resources=@invoice.bpmn

A person signs in to the console as usual and picks the tenant in the tenant switcher. From there the administrator manages the tenant’s own users, groups, clients and grants in the Access area; the tenant’s settings, members and administrators stay with the tenant operators. The client named as administrator must already exist: an administrator of the starting tenant creates it (POST /v2/clients), because the tenant operator does not manage clients. A user needs nothing beforehand.

The tenant’s administrator gives other people and programs access by creating their user or client record in the tenant and their roles there. A tenant operator can also add members in the console’s Tenants area or with PUT /v2/tenants/{tenantId}/users/{username} and its siblings, as described under Several tenants.

Signed-in people who hold identity:read see Access in the console, with tabs for users, groups, roles, clients, mapping rules and grants. Tenants, their members and administrators are managed in the Tenants area (with tenant:admin). Each tab is a full-width list, like Jobs, Decisions and Tenants; clicking a row (or moving to it with the arrow keys and pressing Enter) opens it on a page of its own, with all its details, its members and its actions, at its own address (#access/<tab>/<id>). ← Back at the top of the item’s page, the browser’s Back, and Access in the navigation all return to the list as you left it: its filters, sort and page, with the row you opened focused. Lists are read-only unless you also hold identity:write; then New …, in the list page’s header, opens the form for a new item, and the item opens on its own page once it is created. Editing is the item page’s main action; deleting it (or, for a client, revoking it) is last under More actions. A new or rotated client secret is shown once, on the client’s own page, with a copy button until you dismiss it or leave the page; it is never stored in the browser. The grant form offers only the permissions that fit the resource type you pick. The Users tab shows each person’s identity provider and their id there (Not signed in yet for a user created ahead of the first sign-in) and the earlier usernames they keep; usernames of a provider other than the default carry its alias, such as alice@corp-ad.

The issuer URL has to mean the same server to your browser and to the engine. On a single machine the simplest way to get that is to run Keycloak inside the engine’s network namespace, so http://localhost:18180 works from both sides. Plain http is accepted only for loopback issuers like this one; any real deployment uses https.

Start the engine with both ports published and OIDC configured:

Terminal window
docker run -d --name tinyconductor \
-p 127.0.0.1:8080:8080 -p 127.0.0.1:18180:18180 \
-v tinyconductor-pgdata:/var/lib/postgresql/data \
-e CONDUCTOR_OIDC_ISSUER=http://localhost:18180/realms/tinyconductor \
-e CONDUCTOR_OIDC_CLIENT_ID=tinyconductor \
-e CONDUCTOR_OIDC_CLIENT_SECRET=change-me \
-e CONDUCTOR_OIDC_REDIRECT_URI=http://localhost:8080/auth/callback \
-e CONDUCTOR_OIDC_DISPLAY_NAME='Sign in with Keycloak' \
-e 'CONDUCTOR_AUTH_MAPPING_RULES_JSON=[{"claim":"groups","equals":"bpm-admins","roles":["admin"]},{"claim":"groups","equals":"bpm-operators","groups":["ops"],"roles":["operator"]},{"claim":"groups","equals":"bpm-workers","groups":["floor"],"roles":["task-worker"]}]' \
-e CONDUCTOR_LOCAL_USERS_ENABLED=1 \
registry.tinyfactory.ai/tinyblox/tinyconductor-bundled:0.1.0

Then Keycloak beside it:

Terminal window
docker run -d --name keycloak --network container:tinyconductor \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.0 start-dev --http-port=18180

Create a realm, a confidential client with PKCE, a groups claim and an audience entry for the API, one group and one user:

Terminal window
kc() { docker exec -i keycloak /opt/keycloak/bin/kcadm.sh "$@"; }
kc config credentials --server http://localhost:18180 --realm master \
--user admin --password admin
kc create realms -s realm=tinyconductor -s enabled=true
kc create clients -r tinyconductor -f - <<'JSON'
{
"clientId": "tinyconductor",
"secret": "change-me",
"publicClient": false,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": ["http://localhost:8080/auth/callback"],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8080/"
},
"protocolMappers": [
{"name": "groups", "protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"config": {"claim.name": "groups", "full.path": "false",
"id.token.claim": "true", "access.token.claim": "true",
"userinfo.token.claim": "true"}},
{"name": "api-audience", "protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {"included.client.audience": "tinyconductor",
"access.token.claim": "true", "id.token.claim": "false"}}
]
}
JSON
kc create groups -r tinyconductor -s name=bpm-operators
kc create users -r tinyconductor -s username=olivia -s enabled=true \
-s email=olivia@example.com -s emailVerified=true \
-s firstName=Olivia -s lastName=Operator -s 'groups=["bpm-operators"]'
kc set-password -r tinyconductor --username olivia --new-password olivia

Open http://localhost:8080/console, choose Sign in with Keycloak, and sign in as olivia/olivia. GET /auth/session shows who you are and what you may do:

{"authenticated": true, "subject": "olivia", "name": "Olivia Operator",
"tenantId": "default", "authMethod": "oidc", "groups": ["ops"],
"roles": ["operator"],
"actions": ["audit:read", "cluster-variable:read", "decision:evaluate", "history:read",
"metrics:read", "operations:health", "operations:read"],
"csrfToken": "…", "expiresAt": "…"}

Every rule is checked when the engine starts: a rule naming a role that does not exist, a mapping without CONDUCTOR_OIDC_ISSUER, or a non-https issuer that is not loopback refuses to start. Somebody who signs in but matches no rule is signed in with no permissions: the console tells them their account has no access yet, and the API answers 403 with a reason that points at CONDUCTOR_AUTH_MAPPING_RULES_JSON.

When a sign-in with the identity provider does not work — the provider refuses it, the person cancels, the sign-in took too long or was started in another browser, or the provider cannot be reached — the browser comes back to the console’s sign-in page, which says what happened. The full reason is in the audit log.

For another provider the steps are the same: register a confidential web client with redirect URI https://<engine>/auth/callback, put group or role membership in a claim, make access tokens carry an audience you set as CONDUCTOR_OIDC_AUDIENCE (it defaults to the client id), and write mapping rules for that claim. Check which claim carries a readable, unique username and set CONDUCTOR_OIDC_USERNAME_CLAIM if it is not preferred_username (see Who a person is). Connecting your identity provider shows the setup for Keycloak, Microsoft Entra ID, Okta and Auth0, and what TinyConductor needs from any provider.

Sessions live in the engine’s PostgreSQL (bpm_identity.session, under row-level security), so a restart signs nobody out; the database keeps only a digest of each cookie. The cookie is HttpOnly, SameSite=Lax, Path=/ and Secure. A session ends after the idle timeout without activity, at the absolute timeout regardless, or at POST /auth/logout, which also returns the provider’s end-session URL.

The /auth/* endpoints are public and same-origin: GET /auth/config, GET /auth/login?returnTo=/console/ (with &provider=<alias> to pick one of several identity providers), GET /auth/callback, POST /auth/local/login, POST /auth/logout, GET /auth/session. GET /auth/config lists the sign-in methods and, under oidcProviders, the alias and button label of each identity provider a browser can sign in with. GET /auth/session with an X-Tenant-Id header answers for that one of the caller’s tenants: its roles and permissions there.

GET /.well-known/tinyblox-client-requirements, also public, describes what an identity provider must provide for this engine, with the engine’s own settings (see Connecting your identity provider).

A single sign-on that ends without a session sends the browser back to the page it started from (the console) with ?signin_error= and one of refused, expired, failed, no-access or unavailable.

Every configuration value is checked at startup and a bad one refuses to start. OIDC or local users count as an authentication boundary: they cannot be combined with CONDUCTOR_ALLOW_ANONYMOUS.

Every 401 and 403, and the 400 for a missing tenant selection, is logged on the bpm::audit tracing target, except a 401 for a request that sends no credential at all (no token, no session cookie, or a token request with no client credentials): browsers opening the sign-in page and probes do that all the time, so it is not recorded. The log line has the subject when known, the kind of credential refused (user, client, static, service, or anonymous when it named nobody), the route and the reason, never a credential. Bundled and Cluster mode also store it in their PostgreSQL (bpm_identity.auth_audit, under row-level security) for CONDUCTOR_AUTH_AUDIT_RETENTION_DAYS days (30 by default, 1–3650). Embedded mode keeps the most recent ones in memory. A flood of refusals cannot fill the database or the log: per minute, the same refusal (the same status, subject, tenant, route and reason, such as one wrong token sent again and again) is recorded five times, and at most 300 refusals are recorded one by one in all. The rest are counted, and when the minute is over one entry per tenant and status says how many were not recorded one by one (its route and method are *). The audit log API serves them as ACCESS entries next to the record entries, and category=ACCESS lists only refusals. A refusal with no known tenant is filed under the deployment’s default tenant. API responses carry X-Content-Type-Options: nosniff and Cache-Control: no-store.

Who did what inside the engine — deployments, instance changes, task claims and completions — is served by the audit log API. So is every change made through the administration API (users, groups, roles, permissions, API clients, mapping rules and tenants), made or refused, as IDENTITY entries: who made it, what it changed, the result and a summary without secrets. They are stored and kept like the refusals (bpm_identity.identity_audit) and also logged on the bpm::audit target (event=identity.changed).