Skip to content

Access control

This page shows how to decide who may start, view, operate and audit workflows. You describe access in one authorization.ts file, the host compiles it when it starts, and the host configuration says whether to activate it. Until an access configuration is active, IdentityFlow denies all configurable access: the host runs and the workflows are deployed, but the workflow catalog is empty.

Access control does not decide who may answer a dialog. That is the dialog’s assignees, compared with the account’s current principals. The terms are explained on Identity and access and in Concepts.

An access function is one of four kinds of access to a workflow family:

Access functionWhat it allowsWhere it shows up
starterStart a workflow of this family.Workflow catalog; GraphQL startCatalog, startWorkflow
viewerSee an instance and the business data it discloses.Instances; GraphQL business
operatorPause, resume, terminate, reassign a pending dialog, replay failed work.Operations, Failed work; GraphQL operations, deadLetters and the matching mutations
auditorRead the recorded events and the authority behind each one.An instance’s Audit tab; GraphQL audit

A workflow family is every version of one Workflow Definition name on one release channel, for example vacation-approval on latest.

Every signed-in account has Assigned to me and My work without any access entry. An assignee can answer a dialog without any access function; to see the rest of that instance under Instances, the account also needs viewer for the family.

defineAuthorization(entries) takes a list of entries and returns plain data. It runs no code when someone asks for access. Import the builders from @identity-flow/sdk/authorization and default-export the result:

import {
allWorkflowFamilies,
allow,
defineAuthorization,
deny,
principal,
principalRelation,
workflowFamily,
} from '@identity-flow/sdk/authorization';
const vacation = workflowFamily('vacation-approval', 'latest');
const staff = principal('acme', 'group:staff');
const contractors = principal('acme', 'group:contractors');
const hr = principal('acme', 'group:hr');
const operations = principal('acme', 'group:workflow-operations');
const audit = principal('acme', 'group:internal-audit');
export default defineAuthorization([
allow.starter(staff, vacation, { name: 'staff-request-vacation' }),
allow.viewer(hr, vacation, { name: 'hr-views-vacation' }),
// Registered by the hooks file: the manager of each person named as a recipient.
allow.viewer(principalRelation('manager-of-recipient'), vacation, {
name: 'managers-view-their-reports',
}),
deny.viewer(contractors, vacation, { name: 'contractors-never-view-vacation' }),
allow.operator(operations, allWorkflowFamilies(), { name: 'operations-run-everything' }),
allow.auditor(audit, allWorkflowFamilies(), { name: 'audit-reads-everything' }),
]);

Each entry is allow or deny, then the access function, then (target, scope, { name }). The optional name appears in error messages about that entry. In TypeScript, [ … ] is an array literal, like a Go slice literal.

TargetMatches
principal(providerId, subject)An account whose provider currently reports this principal (subject compared without regard to case). * is refused; there are no wildcards.
principalRelation(name)An account that holds a principal the named relation projects for an instance. See Principal relations.
account(accountId)One IdentityFlow account, by its 22-character account ID. In 0.3.0 the ID appears only in an instance’s Audit tab and in GraphQL audit, not on My access, so prefer principals.
machine(machineId)A non-human caller. 0.3.0 has no machine sign-in, so activation refuses every machine entry.
  • workflowFamily(name, releaseChannel) selects one family. The family must be deployed when the configuration is activated.
  • allWorkflowFamilies() selects every family, including ones deployed later.

Nothing is allowed unless an entry allows it. An account gets an access function for a family when at least one allow entry matches it and no deny entry does: a matching deny always wins. When IdentityFlow cannot verify the principals or relations an entry depends on, it denies.

For the same target and access function, the only overlap you may write is an allWorkflowFamilies() allow together with single-family denies. Everything else that overlaps is refused when the configuration is activated:

Refused at activationError
The same entry twiceAccess Entry <n> duplicates an earlier entry
allow and deny for the same target, function and scope, or another overlapAccess Entries <m> and <n> shadow one another
A principal whose provider ID no hooks file registersAccess Entry <n> uses an unregistered provider
A relation no hooks file registersAccess Entry <n> uses an unregistered Principal Relation
A machine targetAccess Entry <n> uses a Machine without an available authenticator
A relation target with starterAccess Entry <n> cannot compile to an authorization SQL scope
A family that is not deployedWorkflow Family "<name>" on release channel "<channel>" does not exist

<n> is the entry’s position in the list, counting from 0.

A configuration holds at most 4,096 entries and 1 MiB. Family names are at most 512 characters, release channels and relation names at most 128, and entry names at most 256. defineAuthorization throws a ValidationError for an entry that breaks these rules, so a plain Vitest test that imports the file catches it:

import {
allow,
defineAuthorization,
principal,
workflowFamily,
} from '@identity-flow/sdk/authorization';
import { expect, test } from 'vitest';
import authorization from './w2-authorization';
test('compiles to closed data that names every entry', () => {
expect(authorization.schemaVersion).toBe(1);
expect(
authorization.entries.map(({ effect, accessFunction, source }) => [
effect,
accessFunction,
source.name,
]),
).toEqual([
['allow', 'starter', 'staff-request-vacation'],
['allow', 'viewer', 'hr-views-vacation'],
['allow', 'viewer', 'managers-view-their-reports'],
['deny', 'viewer', 'contractors-never-view-vacation'],
['allow', 'operator', 'operations-run-everything'],
['allow', 'auditor', 'audit-reads-everything'],
]);
expect(authorization.entries[2]?.target).toEqual({
kind: 'principalRelation',
relation: 'manager-of-recipient',
});
});
test('refuses a wildcard where an exact principal is required', () => {
expect(() =>
defineAuthorization([
allow.viewer(principal('acme', 'group:*'), workflowFamily('vacation-approval', 'latest')),
]),
).toThrow();
});

A principal relation grants access per instance, based on the people the instance is about. Those people are the instance’s recipients: subjects entered in the start form, passed as recipients to the GraphQL startWorkflow mutation, or set for a child workflow.

Your hooks file registers the relation. For each recipient, it returns the principals that belong to that recipient:

hooks.authorization.principalRelation('manager-of-recipient', {
project: (anchor) => [managerOf(anchor.recipient)],
});

With the entry allow.viewer(principalRelation('manager-of-recipient'), vacation) from the example above, an instance started for recipient p-2041 is visible to every account whose provider reports manager-of:p-2041. When the manager changes, only the provider’s answer changes; the instance and the configuration stay as they are. IdentityFlow projects the relation in a background worker after the instance starts; until that has run, the instance is not visible through the relation.

The callback gets { kind: 'workflowInstanceRecipient', instanceId, recipient } and nothing else: no database, no actor. It must answer within 3 seconds. A relation that fails, or that is not registered, denies access. Relations work with viewer, operator and auditor; starter has no instance to relate to.

The authorization block of the host configuration tells the host where authorization.ts is and whether to activate it:

authorization {
path = ["."]
"include" = ["authorization.ts"]
activate {
# Copy the digest from the startup log after you have reviewed authorization.ts.
digest = "l4oCgbhXU4MG5GIzl6T5YzCv1XPQhS8vXj5wF4bGmS0"
reason = "Access for the vacation approval rollout, reviewed in change CHG-2291"
subject = "deploy-bot"
name = "Deployment"
email = "platform-team@example.com"
}
}
KeyRequiredMeaning
path, includeyesWhere to look and which file names to match. Exactly one file must match.
cwd, excludenoBase directory (default: the configuration file’s directory) and patterns to skip.
activatenoActivate the compiled file when the host starts.
activate.reasonwith activateWhy this access is being activated. Recorded with the activation.
activate.subject, activate.name, activate.emailwith activateThe account the activation is recorded under, at the configured auth.oidc.origin.
activate.digestnoThe digest you reviewed. A different digest stops the start.

If no file or more than one file matches, the start stops with Expected exactly one authorization module, discovered <n>.

The host activates after it has deployed the workflows, so a family scope can name a workflow deployed in the same start. Activation compares against the configuration that is currently active and records a new revision only when the file grants something different.

Each start ends in one of these outcomes. The digest covers the compiled file, including the parts of the IdentityFlow SDK it uses. Editing authorization.ts changes it, and so can upgrading IdentityFlow packages; after an upgrade, compare the logged digest, review, and update activate.digest.

ConfigurationOutcomeLog line or start-up error
No activateCompiled, not activatedAccess Configuration Candidate <digest> compiled and left inert; set authorization.activate to make it the active Access Configuration
activate, same access as the active configurationUnchangedAccess Configuration Candidate <digest> is already active
activate without digestActivatedAccess Configuration Candidate <digest> activated without a reviewed digest; set authorization.activate.digest = "<digest>" to refuse unreviewed changes
activate with the matching digestActivatedAccess Configuration Candidate <digest> activated
activate with a different digestStart refusedAccess Configuration Candidate <digest> does not match the reviewed digest <reviewed>; review the module and update authorization.activate.digest

Without activate, or without an authorization block, nothing is activated. An access configuration activated by an earlier start stays active; on a new database there is none, so all configurable access is denied.

Review before you activate:

  1. Start once without activate. The log line Access Configuration Candidate <digest> compiled and left inert names the digest.
  2. Review authorization.ts, then add activate with digest set to that value, and restart.
  3. From then on, any change to the compiled file stops the start until someone reviews it and updates the digest.

activate without digest activates whatever the file contains, unreviewed. Use it only as a shortcut on a local development host.

0.3.0 has no UI or API to change access while the host runs. To change access, edit authorization.ts and restart.

My access (/access) is available to every signed-in account. It shows:

  • the account: name, email, login origin and subject;
  • the principals IdentityFlow last read from the provider, and when;
  • a table of workflow families against the four access functions.

The table is not read from the configuration. IdentityFlow runs each view under the account’s own authority and lists the families that come back, so it cannot show access the account does not have. A denied family is absent rather than marked as denied. Under viewer, operator and auditor a family appears only once it has an instance the account can see. Each view is sampled up to 100 items, and the page says so when a view had more.

If the table is empty, either no access configuration is active or none of its entries names a principal the account holds. Compare the listed principals with your entries.

  • Navigation offers only the sections the account can reach.
  • Lists are filtered before sorting and paging, so an account never sees gaps, counts or pages shaped by items it may not see. An empty list is a valid answer, not a sign that a check was skipped.
  • A single item it may not see answers exactly like one that does not exist. In GraphQL both are NOT_FOUND with the message Resource not found.
  • A refused command changes nothing. In 0.3.0, GraphQL reports it as INTERNAL_SERVER_ERROR with Unexpected error.; see GraphQL API.

IdentityFlow also writes an authorization receipt for each permitted command and each audit read. In 0.3.0 the Audit tab and GraphQL audit show, for a dialog answer, the principal that matched; the full receipts are not exposed. A refused command writes none. What the audit view shows is described in Audit trail.