Commit graph

8 commits

Author SHA1 Message Date
Sipke Schoorstra 5862bb84e3
fix(build): make ConfigureAwait.Fody weaving actually take effect (#7983)
* fix(build): make ConfigureAwait.Fody weaving actually take effect

ConfigureAwait.Fody only rewrites awaits when it is handed an explicit
ContinueOnCapturedContext value. A bare <ConfigureAwait /> element parses
cleanly, emits no warning, and weaves nothing.

Of the 98 FodyWeavers.xml files under src/, only 22 set the attribute. The
other 76 carried a bare element, so those projects compiled with no weaving
at all while looking correctly configured. Verified on Debug net10.0 builds:
Elsa.Secrets (attribute set) referenced ConfiguredTaskAwaitable, while
Elsa.Alterations (bare element) did not.

Elsa ships as a library and can be hosted where a SynchronizationContext
exists, so weave everywhere rather than dropping the packages.

Fody reads the WeaverConfiguration MSBuild property in preference to any
FodyWeavers.xml, so the directive now lives in a single file, src/Fody.props,
alongside the package references it belongs with. All 98 per-project XML files
are deleted; they would otherwise be dead and misleading.

src/apps has its own props root that does not chain up to
src/Directory.Build.props, so it imports src/Fody.props directly instead of
redeclaring the Fody package references. This second gap was found by the
guard below, not by inspection.

Guard: Directory.Build.targets fails the build for any project that references
ConfigureAwait.Fody without an effective directive (ELSA0001) or that
reintroduces a FodyWeavers.xml alongside it (ELSA0002). Both were verified to
fire, including on the exact original bug shape.

The 22 already-weaving projects are unaffected: their effective directive is
identical before and after, and that set is disjoint from the four projects
holding explicit .ConfigureAwait( calls. All 25 such calls pass false, matching
what the weaver now applies, so they become redundant rather than contradictory
and are left in place.

Also repoints two security-assessment claims that cited the presence of
FodyWeavers.xml as evidence of weaving — the inference that masked this bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(build): drop FodyWeavers.xml from the new UserTasks modules

Merging main brought in eight new projects. Elsa.UserTasks carried a bare
<ConfigureAwait /> — the same latent no-op this branch removes elsewhere, added
while the fix was in review. Its seven persistence siblings set the attribute.

The guard caught it: ELSA0002 failed CI on the PR merge commit for all three
TFMs, on a file that never existed in the branch's own worktree.

All eight are redundant now that src/Fody.props supplies the directive.
Verified Elsa.UserTasks resolves it and its net10.0 build references
ConfiguredTaskAwaitable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 01:37:23 +02:00
Sipke Schoorstra 74123110d5
feat(auth)!: structured authorization model, phases 1-6 (#7980)
* feat(auth): add the permission model and evaluator (Phase 1)

Additive only. Nothing changes behavior: no endpoint declares against this
yet, and no existing enforcement path routes through it.

A permission is {resource}:{verb}, both axes open and string-keyed. A
trailing wildcard on the resource axis matches the named node and every
descendant at any depth, so workflows/definitions/* covers
workflows/definitions itself; * on the verb axis matches any verb.
Wildcards are the only construct with forward reach.

A bare * parses to *:* at parse time rather than being special-cased in
the evaluator, so superuser stays an ordinary grant and a stored or seeded
* keeps authorizing across the vocabulary migration without a lock-out
window.

Adds:
- Permission, with parsing that rejects a value containing a comma, since
  the persistence converter joins collections with one
- CoreVerbs, the recommended set modules should reuse; a convention rather
  than a closed vocabulary
- PermissionMatcher, one matching rule shape on both axes
- IPermissionEvaluator, the single place permission decisions are made,
  skipping malformed claims so one bad stored grant cannot deny a principal
- PermissionRequirement and PermissionAuthorizationHandler
- The descriptor catalog in core: PermissionDescriptor now carries the
  verbs a resource supports and marks non-core ones, and the registry can
  report what a wildcard covers today

External Authentication keeps its own descriptor types for now; it moves to
the core catalog with the other modules in Phase 2, which keeps this change
purely additive.

55 unit tests cover the matcher table, wildcard forward reach, the
counterpart that concrete grants stay frozen, absence-is-denial, and the
seeded * case.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(auth): contribute the permission catalog from every module (Phase 2)

Still additive. Existing endpoints keep their legacy declarations; nothing
changes behavior for them.

Every module exposing protected endpoints now declares its resources and
the verbs each accepts, following the pattern already proven in External
Authentication -- constants and descriptors colocated -- refined to one
constant per resource, with the verb supplied separately. 47 resources
across 15 modules, matching the settled vocabulary.

Descriptors are discovered from the same assemblies as a module's
endpoints, in AddFastEndpointsFromModule. Registering them per module
would let the catalog and the endpoints drift, which is the failure this
model exists to remove; tying them to one registration makes the catalog
necessarily describe the endpoints that exist.

Adds:
- GET /identity/permissions, the catalog a role editor renders from, so
  no client hard-codes permission strings
- GET /identity/permissions/reach, reporting what a wildcard covers today.
  This is the mitigation for forward reach on the resource axis: a
  wildcard is useful precisely because it covers things that do not exist
  yet, so an author needs to see what it reaches now
- GET /identity/me/permissions, resolving wildcards to concrete verbs so a
  client needs no matching logic, and listing denied resources with an
  empty verb list so "denied" is distinguishable from "unknown"
- IPermissionGrantValidator, wired into Roles/Create and Roles/Update,
  which previously persisted request.Permissions after only the
  caller-subset check. Concrete segments validate against the catalog;
  wildcards validate structurally and are accepted even when they match
  nothing today, since installing a module later is what gives such a
  grant meaning
- RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the
  endpoint base classes, with the six copy-pasted ConfigurePermissions
  bodies collapsed into one implementation

New endpoints require new-format grants, so during the transition they
authorize only for holders of *, which parses to *:*. Phase 3 migrates the
rest and closes that gap.

70 unit tests, including the wildcard-accepting validator cases.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(auth)!: cut every endpoint over to the permission model (Phase 3)

BREAKING: legacy permission strings no longer authorize. A permanent alias
layer would keep two vocabularies valid forever, so the break is
deliberate and reported rather than absorbed. `*` survives unchanged --
it parses to `*:*` -- so an administrator cannot be locked out while
roles are re-authored.

All 168 declaration call sites across 151 files now use
RequirePermission(resource, verb) with the constants their module
declares, so a typo is a compile error rather than an unreachable
endpoint.

Enforcement consolidated onto IPermissionEvaluator:
- RoleAuthorizationService evaluates containment through the evaluator
  rather than by set membership. This matters: a caller holding
  workflows/*:view can now delegate workflows/definitions:view, which set
  membership got wrong and which would otherwise force administrators to
  hold every concrete grant they wish to delegate.
- The two Broker/Logout.cs endpoints declare explicitly. Logout is
  authenticated-only; ContinueLogout is anonymous, matching every other
  broker callback -- the route handle carries the authority and a
  top-level browser navigation sends no Authorization header.

Removes the C#/Python expression permissions (#7975). They conflated an
incoherent execution-side gate -- a workflow runs under the server's
authority, not the caller's, so the check never constrained what a script
could do -- with a meaningful authoring-side one. The host switch
(AllowHostCodeExecution) becomes the single control. This is a deliberate
reduction in control: where host code is enabled, any author who may write
definitions may use C# and Python.

Adds the fail-closed gate. Omitting a declaration previously inherited the
FastEndpoints default with no Elsa-level fallback, so an endpoint could
ship ungated unnoticed. EndpointCoverage asserts every endpoint declares
exactly one of RequirePermission, RequireAuthenticatedOnly or
AllowAnonymous, with no exemption list. Its canary assertion earned its
keep immediately by catching that the gate was scanning an assembly
containing no endpoints.

EndpointPermissionRegistry records what each endpoint declares. The
requirement is attached as an inline policy and is not readable back from
the definition, so this keeps the declaration introspectable -- and lets
tests assert a specific requirement rather than merely that one exists.

Two behavior notes worth calling out:
- The runtime status endpoint previously accepted either the read or the
  manage permission. It now requires workflows/runtime:view alone, which
  is least privilege; a role holding only control must also be granted
  view to read status.
- BPMN interchange repeats the workflow-definitions path locally rather
  than taking a dependency on Elsa.Workflows.Api for one constant. It
  contributes no descriptor: the resource is owned and described by
  Workflows.Api, and the registry keeps one entry per resource.

Also adds a startup validator that logs every stored role permission that
no longer resolves, identified by role, so an upgrade is loud.

188 unit tests pass across Api.Common, Workflows.Api and Identity.

Refs #7974, #7975, #7976

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(auth): revocation bound and role audit notifications (Phase 4)

Default access-token lifetime drops from 1 hour to 15 minutes. This is
the revocation bound: permission claims are issued at sign-in and refresh
re-reads the user's roles, so removing a role takes effect at most one
access-token lifetime later. Refresh already rotates both tokens, so no
client change is required and the refresh lifetime is unchanged.

Adds an optional permission stamp for deployments needing a tighter
bound. The stamp is derived from the user's roles and their permissions
rather than stored as a counter on the user. That avoids changing the
Identity schema, which would have required migrations across all five EF
providers and made this milestone depend on the tenancy work. It also
means every node computes the same value from the same store with no
cross-node cache invalidation, which matters because Elsa has none.

The stamp is issued unconditionally and only validated when enabled, so
turning it on does not invalidate tokens already in flight; an absent
stamp is not treated as a mismatch for the same reason. It changes when a
role is added to or removed from the user and when a held role's
permissions change, but not when an unrelated role changes.

Role create and update now publish typed security notifications per ADR
0007, carrying the resulting grants so a reviewer can reconstruct what a
role conferred at a point in time without replaying every prior event.
This module owns no audit store: a future audit module subscribes and
sets its own retention.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(auth): tenancy hardening for identity (Phase 5)

Closes the gaps that made "roles are configurable per tenant" untrue
however the rest of the stack behaved.

Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and
Application.ClientId carried globally unique indexes, so two tenants could
not both hold a role named Admin. Migrations for all five EF providers
drop the global indexes and create composite ones on (TenantId, Name).

The in-memory user and role stores now scope to the ambient tenant.
Isolation previously existed only on the Entity Framework path, and only
when multitenancy was enabled, so a deployment running the default stores
had none at all. The tenant-agnostic sentinel is honored, matching the EF
query filter, so a shared platform role stays visible from every tenant.

RoleFilter gains TenantId, matching UserFilter, and the role and user list
endpoints pass it explicitly rather than relying on an ambient filter that
only exists on one persistence path.

UserManager.CreateUserAsync sets TenantId explicitly instead of relying on
the EF saving handler, which does not run in memory and left users
unassigned there.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: migration guide, ADR, and security wiki for the authorization model

Adds docs/migrations/authorization-model.md, following the shape of the
external-authentication persistence guide. It leads with the three things
that are not a simple rename, because each silently produces a wrong
result if treated as one:

- The migration expands where new sub-resources are finer-grained than
  what they replace, so a one-for-one substitution narrows roles.
- read:* and exec:* become materially more powerful. They are literal
  claim values today, authorizing twelve of roughly forty read endpoints;
  their replacements work as the names always implied. Any role holding
  them needs review by hand, not an automated rewrite.
- The C#/Python expression permissions are removed rather than
  translated, which is a deliberate reduction in control where host code
  is enabled.

It also states plainly that `*` keeps working, and says to do that first,
since it is what stops an instance locking itself out mid-migration.

ADR 0012 records the model and, more usefully, why a closed verb
enumeration was drafted and rejected: it was justified on implication, but
aggregates were already excluded and no verb implies another, so the
bitwise check was expressing set containment all along.

The security wiki's API Authorization section replaces its Secrets-only
route table with the catalog endpoint as the authoritative source, and
states why read-only mode is a separate axis rather than a permission.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(auth): restore the suites after the tenancy and evaluator changes

The whole solution builds with zero errors and every affected suite
passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External
Authentication unit, 133 External Authentication integration.

Most breakage was test call sites constructing the tenant-aware stores
and the evaluator-backed RoleAuthorizationService directly. Adds
TestTenantAccessor to Elsa.Testing.Shared rather than giving the
production constructors an optional accessor, which would have let a
missing registration silently disable isolation.

Several External Authentication tests created fixtures in tenant-a while
running under the default tenant, so the newly isolating store correctly
stopped finding them. They are now scoped to the tenant their own
fixtures use; JustInTimeProvisioningTests, which genuinely spans two
tenants, is scoped per case.

One production fix came out of it: IdentityFeature now ensures an
ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a
host that never enables multitenancy would otherwise fail to construct
them -- which is what the DI registration tests were reporting. TryAdd
leaves MultitenancyFeature's own registration untouched.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auth): register the identity services on the classic feature path

Found by running Elsa.Server.Web, not by the test suites: the app failed
at startup with "Unable to resolve service for type RoleSecurityNotifier
while attempting to activate Roles.Update".

RoleSecurityNotifier, the permission stamp services, the memory cache and
the stored-permission validator were registered only in the CShells shell
feature. Elsa.Server.Web uses the classic UseIdentity() path, whose
IdentityFeature registered none of them, so every host on that path
crashed while mapping endpoints. Unit tests did not catch it because they
construct services directly rather than through either feature.

Verified end to end against the running server:

- The seeded admin role stores "*". It parsed to *:* and resolved to
  concrete verbs across all 27 registered resources, which is the
  bare-wildcard parse rule working on real data rather than in a test.
- GET /identity/permissions returns the catalog for the modules this app
  installs -- 27 resources, 0 unverified, categories Dashboard, Identity,
  Resilience and Workflows -- rather than all 47, which is correct: the
  catalog describes what is installed.
- GET /identity/permissions/reach?resource=workflows/* reports 19 covered
  resources.
- A role holding only dashboard:view gets 200 on /dashboard/overview and
  403 on /identity/roles, /identity/users, /workflow-definitions and
  /identity/permissions, while /identity/me/permissions returns 200
  because it declares RequireAuthenticatedOnly -- confirming FR-019's
  third declaration state behaves as designed.
- That same principal's /me/permissions lists all 27 resources with 26
  carrying an empty verb list, so "denied" stays distinguishable from
  "unknown to this server".
- The startup validator logged no unresolvable permissions, as expected
  for a seed holding only "*".

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auth): discover permission descriptors on the shell host path

Found by running Elsa.ModularServer.Web. The shell host started cleanly
and authorized correctly, but GET /identity/permissions returned zero
resources and /identity/me/permissions returned no grants.

Descriptor discovery was wired into AddFastEndpointsFromModule, which only
the classic module path calls. CShells discovers endpoints from features
implementing its own marker interface, so on a shell host no provider was
ever registered. Authorization still worked, because the evaluator reads
claims and needs no descriptors -- which is exactly why nothing failed
loudly. What silently broke was everything built on the catalog: role
authoring would have rejected every concrete grant as an unknown
resource, introspection returned nothing for clients to render, and the
stored-permission validator would have reported every concrete stored
permission as unresolvable.

ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa
assemblies, bounded to those and run once per shell.

Verified on the modular host, which installs far more modules than
Elsa.Server.Web:

- 47 resources registered, 0 unverified, across all 12 categories, with
  all 17 module-specific verbs present. That is the entire published
  vocabulary confirmed against a running server rather than a document.
- Reach reports workflows/* covering 20, external-authentication/*
  covering 8, and * covering 47.
- Creating a role with dashboard:view and workflows/*:view succeeds,
  confirming a wildcard grant survives authoring validation.
- Creating one with invented/resource:view and secrets:publish is
  rejected with 400.

Also makes those rejections actionable. The permission was reported
without the reason, so an operator learned which entry was wrong but not
why; both parts are now in the message, including the supported verbs for
the resource.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* wip: bpmn test vocabulary

* fix(auth): make enforcement DI-independent and finish the hub cutover

CI on #7980 was red. Running the full suite locally rather than the
subset I had been checking surfaced 24 failures across four projects,
in three distinct classes.

Enforcement no longer depends on a DI registration. RequirePermission
attached a PermissionRequirement evaluated by a registered handler, so a
host that had not called AddElsaAuthorization got 403 on every endpoint
with nothing to indicate why. Several test hosts wire FastEndpoints
directly and did exactly that. The requirement is now evaluated inline
against a shared stateless evaluator, with a host-registered
IPermissionEvaluator still taking precedence. Registration remains
worthwhile for the catalog and the validator; authorization can no longer
silently fail closed because of a missing one.

Registration also moved from AddFastEndpointsFromModule to
AddFastEndpointsAssembly. Registering an endpoint assembly is what should
guarantee its permissions work, and a host may never call the former.

Finishes T039. The four SignalR hubs still matched hard-coded legacy
permission strings, which no longer exist, so every hub denied access.
They now route through the evaluator like every other enforcement path.

Test fixtures granting legacy strings were updated to the new vocabulary.
Two categories were deliberately left alone: naming tests asserting the
legacy constants still hold their old values, which is true and worth
keeping, and the workflow script authorization tests, which asserted a
MissingPermission outcome that D21 removed -- those now assert the host
switch is the only control.

One test previously pinned that the hub honors a FastEndpoints-configured
permissions claim type. It now asserts the opposite, and says why: Elsa is
the only authority that expands roles into permission claims (ADR 0009),
and this model no longer uses the FastEndpoints permission mechanism, so
its separately configurable claim type is not consulted. That property is
also unreadable outside reflection.

Whole solution builds with 0 errors and every test project passes.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auth): scope the permission-stamp cache to the tenant

Greptile found and reproduced a cross-tenant authorization bug, and it was
mine: Phase 5 made user names unique per tenant rather than globally, but
PermissionStampValidator kept caching by user name alone. Tenant A's
lookup could therefore populate the cache with its own stamp and satisfy a
revoked token belonging to a same-named user in tenant B, without ever
resolving tenant B's user.

Both the cache key and the user lookup are now tenant-scoped. Added
PermissionStampValidatorTests, including the cross-tenant case; verified it
fails without the fix and passes with it.

Also from review:
- Removed the legacy permission constants left unused in the three hubs
  after they moved to the evaluator, so no stale vocabulary lingers.
- Narrowed two generic catch clauses. The IL scanner now catches only the
  exceptions an unresolvable metadata token actually throws, and the
  startup validator rethrows cancellation while still refusing to stop the
  host for anything else -- an unreachable or half-migrated store is
  exactly when an operator most needs the host up.

Whole solution builds with 0 errors and every test project passes.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(docs): correct path in log message for authorization model migration link

Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output.

* fix(auth): update permissions method to use new syntax

* docs: consolidate docs/ into doc/

The repository had two documentation roots. Merge docs/ into doc/ and
remove the empty docs/ tree.

The two adr/ folders both numbered from 0001, so the identity and
authorization series is renumbered to continue the core series rather
than collide with it:

  docs/adr/0001-0012 -> doc/adr/0014-0025

Every reference is updated to match: the Status cross-links between the
renumbered ADRs, the ADR and path links in specs/012-external-authentication
and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md.

doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out
to 25; it had been stale since ADR 10 and now also carries the partial
supersession edges declared by the ADRs themselves.

docs/codebase/ and docs/migrations/ move across unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(auth): simplify syntax in PermissionEvaluator and related classes

Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability.

* ci(bounty): point the footer step at the file's real path

The bounty workflow read docs/bounty-footer.md, the path the file had
when the workflow was added in b421b00e1. The file later moved to
doc/bounty/bounty-footer.md and the workflow was never updated, so the
read step has been resolving nothing and the appended comment was empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:44:55 +02:00
Sipke Schoorstra b24beadca9
feat(bpmn): adopt Bpmn.* 0.2.0, and start shipping the two BPMN modules (#7970)
Bpmn.Interchange, Bpmn.Model and Bpmn.Semantics move from 0.1.1-preview.19 to
0.2.0. The three changes below are one unit: the bump is what makes the other
two true.

Retire the private feed. All three packages are on nuget.org at 0.2.0 --
including Bpmn.Semantics, which had no stable release under 0.1.x. The
bpmn-feedz source and its Bpmn.* packageSourceMapping entry both go, which
removes a setup step for every consumer. This is not merely cleanup: the feedz
feed does not carry 0.2.0 stable, so with the mapping left in place the bump
would not restore at all. Restore now resolves Bpmn.* from nuget.org via the
existing `*` mapping.

Lift IsPackable=false. The comment on the flag named its own removal condition
-- the Bpmn.* packages reaching nuget.org -- and that condition is now met, so
Elsa.Bpmn and Elsa.Bpmn.Interchange begin shipping. Both pack with every
dependency publicly restorable, and both emit a package manifest carrying
runtimeKinds ["elsa.server"], so neither is silently excluded from the catalog.
They were the last two IsPackable=false projects under src/.

Flip the compensation pin to the fixed behaviour. 0.2.0 contains the fix for
valence-works/bpmn#13 (filed from here as #7959), so
CompensationRunCancelledMidReplay went red on the bump exactly as it was built
to. Upstream took the wide fix: every token a cancelled transaction abandons now
gets a real teardown. The head handler still starts twice -- that is the release
being real -- but the first run is now torn down, so the scope is left holding
one live record for the slot instead of two. The applier is deliberately
unpatched; the assertions moved to describe the fix, not to accommodate it.

Note on persisted state: 0.2.0 freezes the payload format at 1.0.0 and state
persisted by 0.1.x no longer deserializes. Elsa persists the library's
BpmnExecutionState into workflow state, so an in-flight BPMN instance does not
survive this bump. Neither module has ever been published, so no released
consumer can be holding such state -- which is why this is the moment to take
the break.

BpmnRuntimeCapabilitiesTests stays green: 0.2.0 defines no capability flag Elsa
does not already declare. The new cancel-end-event requirement is
SubtreeCancellation, which BpmnRuntimeCapabilities.Declared already carries.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:44:07 +02:00
Sipke Schoorstra 457e2a6185
feat(bpmn): publish-time validation gate for unbound tasks (#7958)
* feat(bpmn): refuse to publish a definition with an unbound BPMN task

BpmnWorkBinder already refuses an UnboundTask with no elsa:activityBinding
at import - that is the first net. A definition can be edited after
import, through Elsa's own designer rather than the BPMN document, and
that edit can remove the activity a task was bound to without touching
the document snapshot the scope still carries. ValidateBpmnProcessBindings
is the second net: a WorkflowDefinitionValidating handler that walks the
materialized workflow graph (not the inert BpmnProcessDefinition snapshot
or the stored BPMN source, both of which a graph-only edit leaves
untouched) and fails publication for any task-family element whose
binding no longer resolves to an activity in the graph, naming the
offending element id.

Import-time Dropped/Degraded findings from BpmnImportAnalysis are not
persisted anywhere a publish-time handler can reach, and BpmnImportIssue
carries no field distinguishing a Dropped finding that changes executable
meaning from one that does not; WorkflowValidationError has no severity
concept either. Extending this gate to those findings would mean guessing
at a classification the library does not expose, so it is left alone -
see the delivery notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): refuse to publish a BPMN task bound to a missing activity type

The publish gate only checked that a binding pointed at some activity id;
it did not check the activity actually resolved, so a binding left
pointing at an uninstalled type passed the gate and failed at run time
instead. Report that case distinctly from "not bound at all", name the
containing BpmnProcess node (not the BPMN element id) as the error's
ActivityId to match how the rest of the codebase reports it, and prove
the BpmnProcess-inside-Flowchart graph-walk with a dedicated test. Also
de-duplicate the publish-gate test fixture's binding helpers by deriving
from BpmnBindingTestBase instead of re-declaring them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): filter the publish gate's loops explicitly

Replace the implicit-filter foreach/continue pattern in
ValidateBpmnProcessBindings with .OfType/.Where so each loop only
iterates the elements it acts on, without changing behaviour, error
messages, or error ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:25:31 +02:00
Sipke Schoorstra 8955f9ad34
feat(bpmn): interchange endpoints (analyze, import, export) (#7954)
* feat(bpmn): add Analyze/Import/Export endpoints to Elsa.Bpmn.Interchange

Thin FastEndpoints wrappers over Bpmn.Interchange, sharing one
BpmnInterchangeDocumentService so Analyze and Import can never disagree
about what a document costs. Import surfaces capability refusal
(BpmnCapabilityRequirements.Analyze, walked into nested processes) with
the missing capability and offending element ids, and reuses
BpmnWorkBinder to bind the root BpmnProcess scope. Export re-reads the
original XML persisted alongside the workflow definition and re-runs it
through BpmnXmlWriter, so retained extension elements, foreign
attributes and BPMN DI layout survive the round trip without being
reconstructed from the reduced Elsa activity graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): make a stale BPMN export refuse instead of mislead

Export now refuses with 422 (naming the reason) when a workflow definition's
BPMN source is missing or no longer matches the definition's version, rather
than exporting stale or absent content while reporting success. Import
records the definition's version alongside the source XML so Export can
detect drift caused by a later save replacing custom properties wholesale.

Also: the interchange package now consumes the runtime host's declared
capability set from a new public Elsa.Bpmn.Hosting.BpmnRuntimeCapabilities
instead of restating it (one value, one home); the Import endpoint's
capability-refusal message no longer misattributes driving elements across
capabilities; the three BPMN REST endpoints get HTTP-level test coverage
(multipart validation, exception-to-status-code mapping, permission gating);
and the wiki documents the endpoints and Export's known limitation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): alert when the library defines a capability Elsa does not declare

Restores the deleted comparison between BpmnRuntimeCapabilities.Declared (ours)
and BpmnHostCapabilities.Full (the library's) — these are two different
constants, not the tautology the earlier deletion assumed. The pinned
Bpmn.Semantics 0.1.1-preview.19 currently defines exactly the four flags Elsa
declares, so capability refusal at import/build is wired but unreachable; this
test is what will say the moment a library bump changes that, and its failure
message names the decision (implement and declare, or leave undeclared on
purpose) rather than just failing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): return 400 for a malformed export version and clarify a partial-import refusal

- Export/Endpoint.cs: a non-numeric or out-of-range VersionOptions query value now returns a
  400 naming the offending value instead of throwing through FromString and bubbling into a 500.
- BpmnInterchangeDocumentService: the message shown when a definition carries BPMN source but not
  its version marker (a second save that never completed after ImportAsync's first) now says so
  explicitly, distinct from "never imported" and "stale".
- BpmnInterchangeDocumentService: replace the implicit filter in EnsureCapabilitiesSatisfied's
  foreach with an explicit .Where(...), same behaviour.
- Test projects: extract the duplicated ReadAsset/Path.Combine helper in
  BpmnInterchangeTestBase and BpmnInterchangeEndpointTests into a single BpmnAssetReader, guarded
  against a rooted or nested file name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): write both BPMN import markers in one save

Move the BPMN source XML off the pre-import model and onto the same
explicit save that already records the definition's version, so a
failed or cancelled post-import save leaves neither custom property
behind instead of a partial, undiagnosable state. Update
BpmnAssetReader to use Path.Join instead of Path.Combine so its
rooted/nested-name guard is defence-in-depth rather than the only
thing standing between the code and a wrong path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:20:42 +02:00
Sipke Schoorstra e447eb2826
feat(bpmn): triggers and correlation for message, signal and timer starts (#7956)
* feat(bpmn): let a BPMN process start from outside via message, signal, and recurring timer starts

BpmnProcess now implements ITrigger: it walks its own event-defined start events
and emits an EventStimulus per resolved message/signal name (matching what
Event/PublishEvent already key bookmarks on) and a TimerTriggerPayload/CronTriggerPayload
per recurring timer start (matching Elsa.Scheduling's own Timer/Cron path), so
correlation is on name for message/signal and reuses the existing scheduling
mechanism for timers, unmodified.

BpmnWorkBinder.Bind now marks the one scope it returns directly as the workflow's
root scope; every nested scope it produces stays off. Because that flag alone
cannot see composition that happens after it is set (e.g. nesting through an
intermediate Flowchart, the gap left open by #7926's applier-level refusal),
BpmnProcess re-derives entry-point status from the whole workflow graph at
trigger-indexing time and refuses to register regardless of what the flag says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): give each BPMN start trigger its own stimulus name, dedup, and refuse per malformed timer

BpmnProcess.GetTriggerPayloadsAsync now wraps every start-event payload in a
NamedTriggerPayload (the per-payload stimulus naming TriggerIndexer gained in #7950)
instead of the shared TriggerIndexingContext.TriggerName, so a process with both a
message/signal start and a recurring timer start no longer has the last kind processed
claim the name -- and therefore the hash -- for every row.

Two start events (or two event definitions) that resolve to the same stimulus name and
value now collapse to one payload, so StimulusSender no longer starts the workflow twice
for one inbound stimulus. A malformed <timeCycle> interval is refused for its own start
event only, named in the warning like the work binder's own malformed-duration message;
every other valid start event on the process still registers, since letting the exception
propagate would just be swallowed whole by TriggerIndexer's catch-all around
GetTriggerPayloadsAsync, discarding every other start again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): refuse a non-positive BPMN timer interval

XmlConvert.ToTimeSpan accepts PT0S and negative durations; either
registered as a recurring timer trigger turns into a hot loop, since
the scheduler substitutes a ~1ms delay whenever the next execution
time is non-positive. Refuse it through the same per-start-event
refusal path already used for a malformed interval, so the process's
other start events still register.

Also address two small static-analysis findings in the same method:
filter the start-event loop explicitly with .Where(...) instead of an
implicit continue, and combine two genuinely-simple nested if pairs
(message/signal name resolution, and the cron branch) with &&. The
timer interval's own nested if/try-catch is left alone: combining it
would only get harder to read once the non-positive check joins it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): refuse a BPMN timer interval below the scheduler's resolution

A positive but sub-resolution interval (e.g. PT0.0000001S) passed the
existing non-positive refusal unchanged and rearmed in the same hot loop
that guard was meant to close, one step down: Elsa.Scheduling's
ScheduledRecurringTask.SetupTimer substitutes a 1ms delay for any
non-positive delay it computes, and SchedulingOptions.MinimumPastDueScheduleDelay
defaults to that same 1ms, so 1ms is the scheduler's own resolution floor,
not a guessed constant. Refuse an interval below it through the same
per-start-event path the malformed and non-positive cases already use, so
the offending start event is skipped and the process's other start events
still register.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:15:35 +02:00
Sipke Schoorstra 6a3d65ff7e
feat(bpmn): the work binder and the elsa: binding format (#7946)
* feat(bpmn): bind BPMN work declarations to Elsa activities

Turns the reader's BpmnWorkBinding declarations into the activity nodes a
BpmnProcess scope runs. Six of the seven kinds bind automatically: TimerWait to
Delay, MessageWait/SignalWait to Event, MessagePublish to PublishEvent,
CallProcess to DispatchWorkflow, NestedProcess to a nested BpmnProcess. The
seventh, UnboundTask, is an authoring decision and is read from a new elsa:
vendor extension inside the document, so an exported .bpmn is self-contained.

Every binding for a scope is bound whatever its slot, so a ScopeListener needs
no special case. Each binding gets its own freshly built activity with a
scope-qualified id: ActivityVisitor skips an activity it has already collected,
so one instance shared between two scopes would leave the second scope with no
child in Elsa's identity graph.

The binder lives in Elsa.Bpmn.Interchange because BpmnWorkBinding is a
Bpmn.Interchange type; binding it in Elsa.Bpmn would pull the interchange
library into the execution module's closure, which is the split D12 draws.

Every ambiguity resolves loudly: an unbound task, a dead binding declaration, a
malformed ISO-8601 duration, a call activity with nothing to call, and an
activity type nothing registered all refuse at bind time rather than producing a
process that runs to completion doing none of what the document says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): declare document variables on the bound scope, refuse duplicate input names

BpmnWorkBinder.BindScope never copied BpmnProcessDefinition.Variables onto the
produced BpmnProcess's Elsa Variables, so a document-declared collection variable
was Absent to IBpmnVariableReader and a collection-mode multi-instance over it
faulted the element instead of running once per item. BindScope now declares an
Elsa Variable for each document variable, seeding the declared default as the
JsonElement it already is.

BpmnActivityBindingFormat.Read silently let a second <elsa:input name="..."> with
a duplicate name overwrite the first rather than refusing it, unlike every other
malformed-document case this binder already refuses. It now throws
BpmnBindingException naming the binding and the duplicated input, and the XML doc
now states that rule plus the (verified) XML text-node escaping that already
applies to input JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): carry every declared activity input through the binding format

BpmnActivityBindingFormat.Write only found properties whose CLR type derives
from Input, silently dropping attribute-declared inputs like Switch.Cases from
an export. Read accepted any <elsa:input name="..."> without checking the
activity declares it, so a mistyped or stale name imported silently with the
configuration missing since Elsa's deserializer ignores unknown members. Both
now go through IActivityDescriber.GetInputProperties, the same enumeration
ActivityDescriptor.Inputs is built from, so Write and Read agree on what an
activity's inputs are and Read refuses a name that enumeration does not
report.

Also makes BpmnWorkBinder.RefuseUnusedDeclarations filter its loop explicitly
with .Where(...) instead of an implicit if, per static analysis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): filter undeclared input names explicitly

Express the undeclared-input-name check as an explicit Where filter
instead of an implicit filter inside the loop body, and report every
undeclared name at once rather than only the first. Also fix the
refusal message, which previously named the activity type twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(bpmn): describe the input payload shape accurately

The XML doc on BpmnActivityBindingFormat claimed every <elsa:input> is the
{"typeName":...,"expression":...} wrapper a stored workflow definition uses.
That only holds for Input<T>-typed properties: an [Input]-attributed
plain-typed property such as Switch.Cases is serialized as its own JSON
shape (an array), not the wrapper, which Write already does correctly and
the round-trip test already covers. Correct the doc to describe the payload
as the configured activity serializer's output for that input, dependent on
how the activity declares it, and add a second short example showing the
attribute-declared shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:38:03 +02:00
Sipke Schoorstra 735e953dc5
feat(bpmn): scaffold Elsa.Bpmn and Elsa.Bpmn.Interchange modules (#7938)
Adds the valence-works/bpmn package feed and pins Bpmn.Model,
Bpmn.Semantics and Bpmn.Interchange at 0.1.1-preview.19, then wires
two new module projects consuming those libraries without
reimplementing anything they provide (D12): Elsa.Bpmn for BPMN
execution and Elsa.Bpmn.Interchange for XML import/export, kept as a
separate package so hosts that only execute BPMN don't take the XML
reader. Both are marked IsPackable=false until the Bpmn.* packages
are published to nuget.org. Test projects are added for both, unit
and integration, and all four are wired into Elsa.sln so PR CI
discovers and runs them. This unblocks #7925 and the rest of the
BPMN runtime work in #7909.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 01:17:46 +02:00