External Vendor Auto-Provision Workflow¶
A step-by-step guide to building a workflow that auto-provisions (or
reuses) a Floh user for an external vendor contact and grants a
time-bound role with no approval gate. Access is bounded by the
workflow:provision_users permission check inside user_create and by
durable role expiry (role_grant.expiresAt + the role-expiry-check
scheduler), not by a manual sign-off.
This matches the External Vendor Auto-Provision starter in the New Workflow gallery. You can instantiate that starter and skip to Step 6 — Publish, or rebuild it by hand below.
Prerequisites¶
- A vendor role the contact should receive for the access window, created under People → Roles.
- The
workflow:create,workflow:update,workflow:publish, andworkflow:readpermissions on your admin account (the Workflows tab andGET /api/workflowsare gated onworkflow:read), andtemplate:readif you instantiate the starter from the gallery. The account that runs this workflow also needsworkflow:startandworkflow:provision_users—user_createrefuses to insert an unconfirmed user without the latter, and every outcome is captured in aworkflow.user_auto_provisionedaudit row.
Security model — read this before publishing¶
- Do not wait out the access window with the
delayconnector.delayis an in-processsetTimeout. A day-scale wait keeps the runrunninguntil stuck-run recovery (default 30 minutes) fails it — leaving the grant in place with no revoke. Userole_grant.expiresAtand let therole-expiry-checkscheduler expire the assignment. See the Time-Bound Group Access starter for the same pattern. - Fail closed on duplicate grants.
role_revokeand the expiry job key assignments by user+role, not by "the assignment this run created". DefaultonDuplicate: "skip"would let a second run succeed and a later expiry would tear down a pre-existing assignment this workflow never owned. SetonDuplicate: "error". - Fail closed on partial entitlement provision.
findExpired()only selectsactiveassignments. DefaultfailOnPartial: falseleaves apartially_provisionedrow whose surviving entitlements never expire. SetfailOnPartial: true. - Validate duration, email, and display name before provisioning. A
zero/negative
expiresInDays, a boolean/array/objectexpiresInDays(Number(true) === 1andNumber([365]) === 365would otherwise provision), a non-stringvendorEmail, or a provided non-stringvendorDisplayName(start payloads type variables asunknown; an array or object JSON-stringifies into a malformed account /display_name) must not create a Floh user. Put that check in a transform that runs beforeuser_create. WhenvendorDisplayNameis omitted, the transform must write it as an empty string so interpolation does not persist the literal{{vendorDisplayName}}token;user_createthen stores the email asdisplay_name. Workflows already instantiated from an older copy of this starter keep the previous transform until you re-instantiate from the gallery. - The access duration is fixed per deployed workflow.
expiresInDaysis a parameter of the transform step (config.params.expiresInDays), not a workflow variable. Instantiation fills it from the template parameter, and the script reads it asfloh.params.expiresInDays. Template substitution still never touchesscriptitself (see.cursor/rules/server/sandbox-security.mdc). Because step config is part of the stored definition, nobody starting a run can change it; anexpiresInDayssent in a start request is ignored. To change the duration, re-instantiate the template or edit the step'sparams(workflow export/import or MCPupdate_workflow); the designer does not editparamsyet. - Existing workflows are migrated. Migration
20260925150000_move_vendor_expires_in_days_to_step_paramsmoves the duration into the step'sparamsand removes the variable, for the template and for every workflow instantiated from it (including later versions). Only workflows still in the starter's shape are rewritten: after the starter'svars.expiresInDaysread line is replaced, nothing in the steps, input form, or trigger may mentionexpiresInDaysapart from the starter's own error message. Any other mention (a customized read, an{{expiresInDays}}token, a form field, even a comment) leaves the workflow unchanged and still reading the variable; update those by hand.
Overview¶
flowchart TD
Start[Start]
Compute["Validate email and compute expiry"]
Provision["Provision or reuse vendor account"]
Grant["Grant vendor role with expiresAt"]
NotifyOk[Notify sponsor — granted]
NotifyFail[Notify sponsor — failed]
Success[End: Success]
Failure[End: Failure]
Start --> Compute
Compute -->|success| Provision
Compute -->|error| NotifyFail
Provision -->|success| Grant
Provision -->|error| NotifyFail
Grant -->|success| NotifyOk
Grant -->|error| NotifyFail
NotifyOk --> Success
NotifyFail --> Failure
Step 1 — Create the workflow¶
Navigate to Design in the sidebar (opens /workflows), open the
Workflows tab, and click New Workflow:
| Field | Value |
|---|---|
| Name | External Vendor Auto-Provision |
| Description | Provision a vendor user and grant a time-bound role with no approval gate |
| Category | general |
| Error Strategy | stop |
| Trigger | manual |
Leave Subject Variable unset. general workflows do not take a
subject.
Step 2 — Variables¶
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
vendorEmail |
string | yes | — | Vendor contact to provision or reuse. |
vendorDisplayName |
string | no | — | Used only when a new Floh user must be created. |
The access duration is not a variable: it lives in the transform step's
params (see Step 3).
Step 3 — Build the graph¶
- Validate input and compute access expiry (
transform) — reject a non-stringvendorEmail, a provided non-stringvendorDisplayName, a boolean/array/objectexpiresInDays, and a non-positiveexpiresInDays. Numeric strings such as"7"are accepted. Write omittedvendorDisplayNameas"", then writeaccessExpiresAtas an ISO timestamp (Date.parse(now()) + days * 24 * 60 * 60 * 1000). Read the duration fromfloh.params.expiresInDays(the step'sconfig.params, filled at instantiation) and the vendor fields fromvars.vendorEmailandvars.vendorDisplayName; do not interpolate any of them into the script source.error→ failure notification. - Provision or reuse vendor account (
user_create) —email: "{{vendorEmail}}",ifExists: "reuse". ExposesuserCreateUserId/userCreateEmailon success.error→ failure notification. Reuse requires an active Floh user; an inactive match fails closed and follows this error edge without reactivating the account. - Grant vendor role (
role_grant) —
| Config key | Value |
|---|---|
roleDefinitionId |
the vendor role |
userId |
{{userCreateUserId}} |
expiresAt |
{{accessExpiresAt}} |
onDuplicate |
error |
failOnPartial |
true |
There is no later role_revoke and no delay step. error →
failure notification.
- Notify sponsor that access is active (
notification) — internal recipient. The body should include{{accessExpiresAt}}so the sponsor knows when the assignment will expire. - Notify sponsor of provisioning failure (
notification) — same recipient; include{{lastStepError.stepId}}and{{lastStepError.message}}. - Two end nodes:
successandfailure.
Step 4 — Parameters (if you save this as a template)¶
| Parameter | Required | Purpose |
|---|---|---|
sponsorUserId |
yes | Notification contact on grant and on failure. Not an approver. |
vendorRoleId |
yes | Role granted for the access window. |
expiresInDays |
yes | Days until expiry. Substituted into the expiresInDays variable default. |
Step 5 — Connectors¶
This graph uses no connectors. Expiry is the role-assignment scheduler,
not the built-in delay connector.
Step 6 — Publish¶
Save, then Publish. Start a run with a test email, confirm a Floh
user is created (or reused), confirm the role assignment has an
expires_at timestamp, and confirm a second start for the same vendor
- role fails closed (
onDuplicate: "error") rather than silently skipping.