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.
How a request is authenticated
Section titled “How a request is authenticated”The engine checks a request’s credentials in this order:
- an
Authorization: BearerJSON Web Token: first one the engine issued itself, then one from the identity provider; - a static token;
- the session cookie:
__Host-hb_session, orhb_sessionwhen 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.
Local users and the bootstrap admin
Section titled “Local users and the bootstrap admin”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:
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:
| Variable | Meaning |
|---|---|
CONDUCTOR_OIDC_ISSUER | Issuer URL, exactly as the provider publishes it (https://…; plain http only for loopback). Discovery is <issuer>/.well-known/openid-configuration |
CONDUCTOR_OIDC_CLIENT_ID | Client id. Required with the issuer |
CONDUCTOR_OIDC_CLIENT_SECRET | Client secret; omit for a public client |
CONDUCTOR_OIDC_CLIENT_SECRET_FILE | A file holding the client secret instead, such as a mounted secret. Setting both refuses to start |
CONDUCTOR_OIDC_AUDIENCE | The aud API access tokens must carry. Defaults to the client id |
CONDUCTOR_OIDC_REDIRECT_URI | https://<engine>/auth/callback. Without it only API bearer tokens are accepted, and the console offers no single sign-on button |
CONDUCTOR_OIDC_SCOPES | Comma-separated, default openid,profile,email; must include openid |
CONDUCTOR_OIDC_DISPLAY_NAME | Label of the sign-in button |
CONDUCTOR_OIDC_CLOCK_SKEW_SECONDS | Tolerance for exp and nbf, default 60 |
CONDUCTOR_OIDC_USERNAME_CLAIM | The 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_CLAIM | The claim that holds a person’s display name for the console, default name. Without it the console shows the username |
CONDUCTOR_OIDC_ALIAS | Short 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_TENANTS | Comma-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_PROVIDERS | Comma-separated aliases of further providers (see Several identity providers) |
CONDUCTOR_IDENTITY_DEFAULT_PROVIDER | The 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_JSON | Ordered rules from claims to groups and roles (below) |
CONDUCTOR_AUTH_ROLES_JSON | Overrides or adds roles (below) |
CONDUCTOR_LOCAL_USERS_ENABLED | 1/true or 0/false. Default: on in Bundled mode without OIDC, off otherwise |
CONDUCTOR_LOCAL_USERS_JSON | Configured local users (above). Refused when local users are off |
CONDUCTOR_LOCAL_USERS_TENANT_ID | Cluster 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_USERNAME | Default admin |
CONDUCTOR_SESSION_IDLE_TIMEOUT_S / CONDUCTOR_SESSION_ABSOLUTE_TIMEOUT_S | Default 1800 and 43200 seconds |
CONDUCTOR_SESSION_INSECURE_COOKIE | 1 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.
Several identity providers
Section titled “Several identity providers”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:
| Variable | Meaning |
|---|---|
CONDUCTOR_OIDC_PROVIDERS | partner-idp (several: partner-idp,contractors) |
CONDUCTOR_OIDC_PARTNER_IDP_ISSUER | Its issuer. Required, and not the issuer of another provider |
CONDUCTOR_OIDC_PARTNER_IDP_CLIENT_ID | Its client id. Required |
CONDUCTOR_OIDC_PARTNER_IDP_CLIENT_SECRET or CONDUCTOR_OIDC_PARTNER_IDP_CLIENT_SECRET_FILE | Its 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_TENANTS | As 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; withoutprovider, 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_PROVIDERnames whose people a plain username means. The others are writtenalice@<alias>;<ALIAS>_TENANTSkeeps 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.
-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.
Who a person is
Section titled “Who a person is”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.
alicenames alice of the default provider.alice@corp-adnames alice of the providercorp-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 aswanda@contoso.com, is a plain username:contoso.comis no alias (aliases have no dot). The same person at another provider iswanda@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.
When a username is given to someone new
Section titled “When a username is given to someone new”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.
Users created before they first sign in
Section titled “Users created before they first sign in”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:
| Provider | Setting | Username |
|---|---|---|
| Keycloak | leave unset | preferred_username, the Keycloak username (olivia) |
| Dex, built-in password database | CONDUCTOR_OIDC_USERNAME_CLAIM=name | Dex puts the username in name and sends no preferred_username; unset, the username would be the email |
| Dex with LDAP or another upstream | leave unset | preferred_username when the upstream provides it, otherwise the email |
| Microsoft Entra ID | leave unset, or CONDUCTOR_OIDC_USERNAME_CLAIM=preferred_username | the sign-in name, such as wanda@contoso.com. Entra’s sub and oid are opaque ids and make poor usernames |
| A custom claim | CONDUCTOR_OIDC_USERNAME_CLAIM=extension.login | the value at that path |
With Dex’s built-in password database, for example:
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.0Wanda 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
Section titled “Mapping rules”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.
Permissions
Section titled “Permissions”A grant gives an owner some permissions on a resource:
| Field | Values |
|---|---|
| Owner type | USER (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 type | PROCESS_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 id | One resource, or * for every resource of the type |
| Permissions | The 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 only | GROUP finance · PROCESS_DEFINITION invoice · instance:create, history:read |
| a worker client handle one job type | CLIENT billing-worker · JOB send-invoice · job:activate, job:complete, job:fail |
| a partner client publish one message | CLIENT partner · MESSAGE order-received · message:publish |
| an auditor read the audit log | USER carol · AUDIT_LOG * · audit:read |
| a team lead manage users and grants | USER dana · IDENTITY * · identity:read, identity:write |
| a deployment client change one configuration value | CLIENT 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.
Several tenants
Section titled “Several tenants”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
400and 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.
Tenant ids and identity provider groups
Section titled “Tenant ids and identity provider groups”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:
- purge the tenant (export it first if you may need it again; see Exporting, importing and purging a tenant);
- remove the mapping rule that names it;
- 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.
Tokens for SDKs and connectors
Section titled “Tokens for SDKs and connectors”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:
| Variable | Meaning |
|---|---|
CONDUCTOR_OAUTH_ISSUER | The 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_AUDIENCE | The tokens’ aud, default tinyconductor-api |
CONDUCTOR_OAUTH_TOKEN_TTL_S | Token lifetime, default 600 seconds (60–3600) |
CONDUCTOR_OAUTH_KEY_ROTATION_S | How long a signing key signs, default one week |
CONDUCTOR_OAUTH_KEY_OVERLAP_S | How long a replaced key is still published and accepted, default twice the token lifetime and never less than one |
CONDUCTOR_SIGNING_KEY_ENCRYPTION_KEY | 32 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_FILE | A 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:
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.
The administration API
Section titled “The administration API”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:
| Permission | For |
|---|---|
identity:read | reading and searching users, groups, roles, mapping rules, grants and clients |
identity:write | creating, changing and deleting them, memberships, secret rotation and revocation |
tenant:admin | tenants: 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.
Who administers tenants
Section titled “Who administers tenants”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 theadminortenant-operatorrole 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 with403for anyone else, even withidentity:write. Outside the starting tenant grants onTENANTand thetenant-operatorrole 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).
Automating tenant management
Section titled “Automating tenant management”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:
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:
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.
Giving a new tenant its administrator
Section titled “Giving a new tenant its administrator”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:
# 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:
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.bpmnA 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.
The Access area of the console
Section titled “The Access area of the console”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.
A local Keycloak, end to end
Section titled “A local Keycloak, end to end”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:
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.0Then Keycloak beside it:
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=18180Create a realm, a confidential client with PKCE, a groups claim and an
audience entry for the API, one group and one user:
kc() { docker exec -i keycloak /opt/keycloak/bin/kcadm.sh "$@"; }kc config credentials --server http://localhost:18180 --realm master \ --user admin --password adminkc create realms -s realm=tinyconductor -s enabled=truekc 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"}} ]}JSONkc create groups -r tinyconductor -s name=bpm-operatorskc 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 oliviaOpen 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.
Other providers
Section titled “Other providers”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
Section titled “Sessions”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
Section titled “The /auth/* endpoints”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.
Refusals and response headers
Section titled “Refusals and response headers”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).