Commit graph

1080 commits

Author SHA1 Message Date
Sipke Schoorstra 373f99d163
fix(alterations): honor tenant isolation in Memory alteration stores (#8106)
* fix(alterations): honor tenant isolation in Memory alteration stores

Stamp ambient tenant on Memory plan/job saves and filter find/count/list
with TenantVisibility so default Alterations persistence matches EF
SetTenantIdFilter / ApplyTenantId. Locks Save under MemoryStore.Sync.

Fixes #8093

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(alterations): refuse Memory Save when Id belongs to another tenant

Fail closed on Save/SaveMany if the ID already exists and is not visible
to the ambient tenant, matching TriggerStore collision style. Visible
same-tenant upsert is unchanged.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(alterations): refuse named-tenant overwrite of agnostic Memory rows

* is visible to every tenant, so EnsureIdAvailable now allows replacing
a * plan/job only when the ambient tenant is also *. Named-tenant Save
and SaveMany fail closed and leave the shared row in place.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 14:04:46 +02:00
Sipke Schoorstra 326e886a41
fix(labels): honor tenant isolation in InMemory label stores (#8102)
* fix(labels): honor tenant isolation in InMemory label stores

Stamp ambient TenantId on save and filter find/list/delete/replace
through TenantVisibility so Memory Labels match EF SetTenantIdFilter
and ApplyTenantId. Labels contracts have no TenantAgnostic flag.

Closes #8089

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(labels): account for * visibility when asserting tenant-b leftovers

Tenant B correctly sees tenant-agnostic associations; assert those rows
remain alongside the other tenant's data after delete/replace.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(labels): delete visible Memory rows in one locked step

Find-then-Delete(id) could remove another tenant's same-ID replacement
that landed between the visibility check and the remove. DeleteWhere
under MemoryStore.Sync keeps the check and removal together.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 04:16:09 -07:00
Sipke Schoorstra f4762308a2
fix(persistence): honor tenant isolation in Memory workflow stores (#8100)
* fix(persistence): honor tenant isolation in Memory workflow stores

Apply the EF SetTenantIdFilter admission rule on Memory definition,
trigger, and bookmark query paths so ambient tenant and TenantAgnostic
match IgnoreQueryFilters instead of leaking cross-tenant rows.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(persistence): stop Memory definition delete from wiping other tenants

DeleteAsync collected logical DefinitionIds from tenant-visible rows,
then removed every in-memory row with those IDs. Shared DefinitionIds
across tenants therefore deleted tenant B when tenant A deleted.

Keep the all-versions-of-DefinitionId Memory delete, but apply the same
TenantVisibility rule (or TenantAgnostic bypass) to the final removal.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 02:56:01 -07:00
Sipke Schoorstra d2d3109024
fix(runtime): enforce MemoryTriggerStore logical uniqueness (#8098)
* fix(runtime): enforce MemoryTriggerStore logical uniqueness

MemoryTriggerStore upserted only by Id, so two records with different Ids
but the same (WorkflowDefinitionId, Hash, ActivityId, TenantId) were
accepted in memory and rejected under EF. Mirror EFCoreTriggerStore:
distinct-by-logical-key, skip already-present keys on ReplaceAsync,
reject Save* collisions, and stamp the current tenant when unset.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(runtime): keep FindAsync first-match and use a structural trigger key

FindAsync must return the first matching trigger. SingleOrDefault threw
when a valid filter (for example WorkflowDefinitionId) matched several
distinct logical keys. Restore FirstOrDefault to match ITriggerStore and
EF. Represent the logical key as a record so fields that contain U+001F
cannot collide.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 02:20:28 -07:00
Sipke Schoorstra 0ac7184226
fix(bpmn): make document PUT If-Match and save a compare-and-swap (#8092)
* fix(bpmn): make document PUT If-Match and save a compare-and-swap

The document PUT checked If-Match, reloaded metadata, then saved through
the importer as separate steps. Two writers could both pass If-Match, and
a metadata-only save in that window was silently reverted.

Add IWorkflowDefinitionStore.TryUpdateLatestAsync — load, match, apply,
save as one critical section (memory) or ExecuteUpdate against the loaded
snapshot (EF). ImportDocumentAsync reads metadata inside that swap.
A lost race throws the same 412 the stale If-Match already returns.

Mongo/Dapper/ES stores need the same method before the endpoint is
concurrency-safe on those providers.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): resolve document-service DI and interleave test compile

Drop the cache-manager constructor dependency (only registered when
definition caching is on) and evict via DraftSaving/DraftSaved instead.
Update the export-availability stub construction and the CAS interleave
fixture to parse edited XML through the reader.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): close Greptile P1s on document PUT CAS

Require IsLatest in the EF ExecuteUpdate WHERE so a published-to-draft
loser is Conflict instead of a unique-key failure. Lock Memory CAS on
the shared MemoryStore so scoped wrappers cannot stale-overwrite.
Dispatch WorkflowDefinitionDraftSaving before the CAS persist so a
rejecting handler fails the request before commit.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): reuse published draft identity across DraftSaving and CAS

Allocate the published-to-draft id, version and created-at once before
WorkflowDefinitionDraftSaving. The compare-and-swap still rebuilds from
the just-loaded row so metadata is not frozen from the outer Find, then
reuses that announced identity and keeps handler-added custom properties.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(management): run Memory CAS lock holder off the test thread

The shared-lock test blocked inside TryUpdateLatestAsync on the test
thread, so it never reached the release signal and hung.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(efcore): drop the SQLite CAS harness that cannot match ExecuteUpdate

The in-memory SQLite fixture could not satisfy the store's DateTimeOffset
ORDER BY plus Data snapshot WHERE, so the winner CAS returned Conflict
before the IsLatest loser path ran. Memory already covers that contract.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): persist the prepared DraftSaving draft on document PUT CAS

Prepare the draft, dispatch WorkflowDefinitionDraftSaving so handlers can
mutate or reject, then TryUpdateLatestAsync with If-Match plus the loaded
snapshot and update: _ => draft. A metadata change in the window is 412
instead of overwriting the other write. DraftSaved still fires after CAS.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* docs(bpmn): align document PUT remarks with prepared-draft CAS

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 01:40:43 -07:00
Sipke Schoorstra 1f88a204df
Enforce unique external refresh token hashes (#8095) 2026-09-13 01:10:32 -07:00
Sipke Schoorstra 7f1f1e50e9
Skip HTTP workflow route matching outside base path (#7757)
* Skip HTTP workflow route matching outside base path

* Handle tenant-prefixed HTTP workflow base paths

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Narrow tenant-prefixed HTTP base-path matching

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Fix sibling-prefix HTTP base-path matching

* Limit HTTP base-path precheck depth

* Use resolved tenant paths for HTTP routing

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
2026-09-12 18:12:37 -07:00
Sipke Schoorstra 9cce8d91af
fix(bpmn): keep a top-level call activity's fire-and-forget flag through the document PUT (#8082)
* fix(bpmn): keep a top-level call activity's fire-and-forget flag through the document PUT

A call activity's call options (vw:waitForCompletion) live only on its CallProcess
work binding, which the bpmnDefinitions document cannot carry, so the document PUT
rebuilt every call activity fresh and silently turned a fire-and-forget call into a
waiting one. Reuse the stored options for a top-level call activity whose element id
and calledElement are unchanged, mirroring how a kept subprocess already carries its
own bindings across; a changed calledElement still binds fresh rather than inheriting
options authored for a different process.

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

* refactor(bpmn): read the stored BPMN source once per document PUT

ImportDocumentAsync's two carry-across helpers each independently
looked up SourceXmlCustomPropertyKey and parsed it, so every document
PUT read and parsed the stored BPMN source twice. Read and parse it
once in ImportDocumentAsync and pass the result to both helpers.

Also extends the no-op PUT ETag-stability theory to the
top-level-call-activity.bpmn fixture, which was not previously covered.

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

* refactor(bpmn): filter call activities with Where

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:41:50 -07:00
Sipke Schoorstra 933d1739bd
fix(bpmn): refuse documents with duplicate element ids before recursion (#8080)
* fix(bpmn): refuse documents with duplicate element ids before recursion

A subProcess nested inside another subProcess with the same id made
EnsureCapabilitiesSatisfied, BpmnWorkBinder.BindScope and the interchange
library's own BpmnXmlWriter recurse without terminating, overflowing the
stack and killing the process (.NET cannot catch StackOverflowException).
Refuse such a document up front, coded bpmn.import.duplicate-element-id
(422), on POST bpmn/import and PUT bpmn/definitions/{id}/document, listing
the duplicated ids.

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

* fix(bpmn): include process ids in the duplicate-id check

EnsureElementIdsUnique only pooled element ids, never a process
definition's own ProcessId. A top-level process's id is never one of
its own elements, so a subprocess reusing its parent's id (or two
top-level processes sharing an id) went undetected and still
overflowed the stack in EnsureCapabilitiesSatisfied, BpmnWorkBinder
and BpmnXmlWriter the same way a repeated element id does. Add every
top-level process's own id to the pool; a nested process definition's
own id needs no equivalent addition since it is always exactly the
element id that opens it, already counted once via its owner's
elements.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:26:07 -07:00
Sipke Schoorstra 091e3bc0e4
fix(bpmn): let a process with only a plain start event publish (#8081)
* fix(bpmn): let a process with only a plain start event publish (#8078)

An imported root BpmnProcess is an ITrigger with CanStartWorkflow set
(IsRootScope). For a process whose start events carry no event definition
it rightly returns no payloads, but TriggerIndexer then stored a
null-payload placeholder row and ValidateWorkflowRequestHandler refused
publication with "Trigger should have a payload". That blocked import,
bind, publish and run for most Camunda models.

Adds an additive, opt-in seam: TriggerIndexingContext.RegistersNoTriggers.
A trigger that sets it and returns no payloads gets no row. It has no
effect when the trigger returns payloads, and a trigger that throws still
gets the placeholder, so a failure is never read as a deliberate decline.
Every other ITrigger indexes exactly as before.

BpmnProcess sets it only for a root scope none of whose start events
carries an event definition. A declared start that resolves to nothing,
such as a process whose only start is a refused timer, keeps the
placeholder and still fails publication. Nested scopes are opted out
before that check and are unchanged, and IsRootScope/CanStartWorkflow
semantics are untouched.

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

* test(bpmn): publish for real in the stale-after-publish tests

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

* test(bpmn): name the publish helper for what it does

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:07:59 -07:00
Sipke Schoorstra fcb46a6b6f
fix(bpmn): let the graph hash alone decide BPMN source staleness (#8079)
* fix(bpmn): let the graph hash alone decide BPMN source staleness

A metadata-only save (a rename, a variable edit) bumps a published definition
to a new draft version without touching the graph, but the stale check
compared the version unconditionally, refusing export/document GET even
though the graph the stored source describes had not moved. Once the
graph-hash marker is present it now decides staleness on its own; the version
check remains only as a fallback for definitions imported before that marker
existed.

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

* test(bpmn): share the simulated-publish helper

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:10:39 -07:00
Sipke Schoorstra 9f4d33269d
fix(bpmn): keep subprocess bodies through the document PUT (#8077)
* fix(bpmn): keep subprocess bodies through the document PUT

BpmnDefinitions lists only top-level processes: Bpmn.Interchange carries the
body of an embedded subprocess, transaction or event subprocess as the
subprocess element's NestedProcess work binding. The document GET therefore
never returned the bodies, and the PUT wrote the posted document without any
bindings, so BpmnXmlWriter wrote every subprocess empty and the re-import
replaced the definition with one whose subprocesses were empty.

The PUT now re-reads the stored source and hands the writer the stored body of
every subprocess element the posted document still declares, matched by
element id, together with everything bound inside it (a call activity's
vw:waitForCompletion lives only on its CallProcess binding). A removed
subprocess's body is never written back, not even under a new element that
reuses its bindingRef, and a subprocess with a stored body but no bindingRef
is refused rather than emptied. The GET body, the ETag and the error codes are
unchanged; nested content stays uneditable through the document.

Bpmn.Interchange 0.2.0 also retains a copy of a subprocess's interpreted
multiInstanceLoopCharacteristics inside its body. Handed back verbatim, that
copy would override a posted marker on the next read and add another copy on
every PUT, so the PUT drops it wherever the element carries a marker in the
model.

The D4 round-trip test now covers subprocess-boundary-events.bpmn and
transaction-compensation.bpmn, copied from Studio's fixtures, plus two
nested-scope assets, going through the endpoints' JSON options.

Refs #8072

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

* docs(bpmn): link the loop-marker workaround to its upstream issue

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:50:43 -07:00
Sipke Schoorstra 3bee566657
feat(bpmn): give BPMN interchange refusals stable error codes (#8067)
* feat(bpmn): give BPMN import/export/document refusals stable error codes

Studio has to recognise a BPMN import/export refusal, and extract capability
names and element ids, by matching the server's message text, so any rewording
silently degrades it to a generic error. Add BpmnErrorCodes with a stable code
per refusal (capability-unsupported, binding-invalid, export not-imported/
source-stale/source-version-unknown, and the document PUT's not-found and
precondition codes), sent through an additive BpmnErrorResponse envelope that
keeps today's statusCode/message/errors shape unchanged and adds code/data
alongside it, since FastEndpoints' own error response has no way to surface a
ValidationFailure's error code in this deployment's configuration. Rename the
Import/Export exception cascades to *ErrorResponses to say what they now do.

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

* refactor(bpmn): fold BpmnErrorResponseFactory and BpmnErrorResponseSender into BpmnErrorResponse

Both were thin, single-purpose wrappers around BpmnErrorResponse used only by
the import/export error mapping and one endpoint — a Middle Man chain. Create
and SendAsync now live as static members on BpmnErrorResponse itself; call
sites are unchanged otherwise, and the wire output is byte-identical.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 00:10:12 -07:00
Sipke Schoorstra e54ced5662
feat(bpmn): declare BpmnProcess's Done and Cancelled outcomes as flow ports (#8066)
Elsa.Bpmn.Activities.BpmnProcess completed with the interpreter's Done or
Cancelled outcome but declared no outcomes, so a Flowchart composing it only
saw Studio's synthesized default port and the Cancelled outcome was
unreachable. Declares both via [FlowNode(BpmnInterpreter.DoneOutcomeName,
BpmnInterpreter.CancelledOutcomeName)] (both const in Bpmn.Semantics 0.2.0),
adds a descriptor test asserting the two flow ports, and a composition test
routing a cancelled transaction down the Cancelled port.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:16:21 -07:00
Sipke Schoorstra d0dd7c9ef5
fix(bpmn): detect designer-edited drafts as stale BPMN source (#8065)
An unpublished draft is saved in place, so a designer save that edits a
bound activity's inputs changes StringData without changing Version,
which the export/document staleness check missed. Import now also
records a content hash of the graph (Bpmn:SourceGraphHash) and
ResolveSourceXml refuses as stale when it no longer matches, falling
back to the version-only check for definitions imported before this
marker existed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:46:58 -07:00
Sipke Schoorstra 22c1aea472
fix(bpmn): the document PUT preserves the definition's non-BPMN metadata (#8063)
* fix(bpmn): preserve definition metadata when the document PUT rebinds it

The document PUT edits a definition's BPMN document, not the whole definition, so it must not
reset the author's name, description, variables, options and custom properties the way a
whole-definition import intentionally does. BpmnInterchangeDocumentService.ImportAsync now accepts
the existing definition to preserve that metadata from, which ImportDocumentAsync passes; POST
bpmn/import stays a whole-definition import.

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

* fix(bpmn): preserve IsReadonly and restore ImportAsync's public signature

Review findings on the prior metadata-preservation fix: IsReadonly was still
overwritten by the document PUT because it was missing from the preserved
field set, and the public ImportAsync(string, string?, string?, string?,
CancellationToken) signature had been changed in place by appending an
optional preserveMetadataFrom parameter. Restore that signature exactly and
move the shared import logic into a private ImportCoreAsync(..., preserveMetadataFrom,
CancellationToken), called by both the public ImportAsync and
ImportDocumentAsync, with IsReadonly now carried alongside the other
non-BPMN metadata. Extend the endpoint tests to also set and assert inputs,
outputs, outcomes, tool version and IsReadonly on both the preserve and
replace paths.

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

* fix(bpmn): refuse document PUT when the target definition has vanished

ImportDocumentAsync fell back to the whole-definition import path when
its definition lookup returned null, silently creating a definition
under the requested id with reset metadata instead of reporting that
the PUT's target disappeared (e.g. deleted between the endpoint's
existence/ETag check and this lookup). It now throws
BpmnDefinitionNotFoundException, which the shared exception cascade
maps to 404 for the document PUT endpoint; POST bpmn/import is
unaffected since it never passes a preservation source.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:15:47 -07:00
Sipke Schoorstra e28e35635b
feat(bpmn): GET and PUT the BPMN document as library-format JSON (#8060)
* feat(bpmn): add document GET/PUT endpoints for BPMN JSON round-tripping (W21)

Studio holds a BPMN process as the library's own JSON payload, not as .bpmn
XML, so binding a task (W11) or moving a shape (W14) had no write path back
to the server: bpmn/import only takes XML, and saving the activity JSON
through the ordinary definition save leaves Bpmn:SourceXml stale, so export
then refuses with 422.

Adds GET/PUT bpmn/definitions/{definitionId}/document, sharing the same
analyze-then-commit path Import runs (BpmnInterchangeDocumentService.ReadDocument/
ImportDocumentAsync), so a document read by GET and written back unchanged by
PUT can never disagree with what Import or Export would do with the same
bytes. Records which process a document was imported from
(Bpmn:SourceProcessId) so a multi-process document keeps importing the same
process on every edit. Binds and writes the document body with plain
System.Text.Json defaults, not FastEndpoints' configured serializer, since
Bpmn.Model's JSON payload format (integer enums, explicit property names)
disagrees with Elsa's own API-wide JSON conventions.

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

* refactor(bpmn): deduplicate document endpoint response and exception handling

Import and the document Put endpoint shared a byte-identical Response type and
an identical exception-to-status-code cascade; Export and the document Get
endpoint shared an identical cascade too. Extract both into a shared
BpmnImportResponse and two small cascade helpers under Endpoints/Bpmn, used by
all four endpoints with unchanged status codes and error messages. Also add
endpoint coverage for PUT against a definition imported before
Bpmn:SourceProcessId existed, for both the single- and two-process cases.

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

* fix(bpmn): add optimistic concurrency to the document endpoints and restore Import.Response

GET bpmn/definitions/{id}/document now returns a strong ETag derived from the
definition's Version, its Bpmn:SourceVersion custom property, and a new
Bpmn:DocumentRevision counter (needed because an unpublished draft is edited
in place, so Version/SourceVersion alone do not always change on save). PUT
requires a matching If-Match: missing returns 428, stale returns 412 (checked
before any import work), and a successful PUT returns a new, different ETag.

Also restores the public Elsa.Bpmn.Interchange.Endpoints.Bpmn.Import.Response
type the prior dedupe commit renamed to BpmnImportResponse without cause,
which would have broken source and binary consumers of the preview package;
Import and the document Put endpoint now both use Import.Response again.

Also replaces a foreach-with-continue over a JSON array with .Where(...) in
the document Put test helper, per CodeQL, with no behaviour change.

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

* fix(bpmn): derive the document ETag from the stored document and graph

The document ETag combined the definition's Version, Bpmn:SourceVersion and a
Bpmn:DocumentRevision counter only the document PUT incremented. An unpublished
draft is edited in place under the same version, and both the workflow
definition importer and the designer's save replace CustomProperties
wholesale, so a POST bpmn/import into the same definition or a designer save
of the draft left all three unchanged. A client holding the pre-write ETag
could then PUT and silently overwrite that write.

The ETag is now a SHA-256 hash over the stored definition's id, version, BPMN
source (Bpmn:SourceXml) and activity graph (StringData), computed in one place
for GET and for PUT's precondition check. Every writer changes at least one
input: a document PUT or an import rewrites the source, a designer save
rewrites the graph, a new draft version changes the id and version. Identical
stored content now yields an identical ETag, so a PUT of unchanged content
returns the ETag GET did.

Bpmn:DocumentRevision and the PUT's pre-import read of it are gone. The ETag a
PUT returns is computed from the definition its import persisted. If-Match
must equal the current strong ETag exactly (weak tags and lists never match,
412), and the wildcard "*" is refused along with a missing header (428),
since it matches whatever is stored.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:16:32 -07:00
Sipke Schoorstra 952dfa05ff
feat(bpmn): project interpreter diagnostics onto the scope's execution log (#8058)
* feat(bpmn): project interpreter diagnostics onto the scope's execution log

Under Option A only bound work carries an activity id, so gateways, events and
flows had no per-element trace in the journal. BpmnScopeHost now projects each
new BpmnExecutionState.Diagnostics entry onto the scope's own execution log
before Prune() runs, keyed by element id, with a persisted high-water mark so
a resumed scope never re-emits one. The scope's own start and completion stay
out, since they are already journaled as the activity's own lifecycle. Event
names and the payload shape are documented as a public compatibility surface
for elsa-studio#1000 to mirror.

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

* fix(bpmn): project element and flow diagnostics dropped by the FlowId exclusion

The diagnostics exclusion keyed on Kind == TokenEmitted && FlowId is null/empty
also dropped an error or cancel boundary's own token emission, since a
boundary fires without an inbound flow. Narrow the rule to skip only
diagnostics that name neither an element nor a flow -- the scope's own
terminal Completed summary -- so every diagnostic keyed on an element or a
flow, including a start event's and a boundary's, is projected.

Also make DiagnosticSequence resilient: TryParse instead of Parse, logging a
warning and skipping projection for an id that doesn't match diag:N rather
than faulting the evaluation. Add a reflection-based test that keeps
BpmnDiagnosticEventNames in lockstep with BpmnDiagnosticKind, and record the
diagnostics volume measurement in the wiki.

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

* fix(bpmn): require exact diag:N ids and seed the diagnostics cursor from prior state

Reject any diagnostic id that is not the exact "diag:" prefix followed by a
non-negative integer, so a malformed id can no longer poison the durable
projection cursor and cause later, genuinely valid, lower-sequence
diagnostics to be skipped forever.

Also seed a missing cursor from the highest valid sequence in the scope's
prior persisted state instead of treating it as zero, so a scope persisted
before diagnostics projection existed does not replay every retained
historical diagnostic as new on its next evaluation.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:01:06 -07:00
Shivam Kumar f50d6e984e
fix: preserve single latest draft when saving a new identity (#7918)
Co-authored-by: Shivamkmr8 <shivam.k@surya-fintech.com>
2026-09-08 01:03:30 +02:00
Sipke Schoorstra 83af7309f2
fix(identity): stop returning password hashes and salts from user creation (#8041)
The POST /identity/users response serialized the plain-text password
(including one the caller supplied), the password hash, and the salt.
The response now carries only id, name, roles, tenantId and a nullable
generatedPassword that is populated once, and only when Core generated
the password because none was supplied.

- CreateUserResult gains IsPasswordGenerated so the endpoint can tell a
  generated password from a supplied one without re-deriving it.
- Response.FromResult centralises the mapping and omits credential
  material.
- Expose Elsa.Identity internals to Elsa.Identity.UnitTests and add
  contract tests covering the response shape, the no-echo rule, the
  serialized JSON, and UserManager's generated-password flag.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 00:26:47 +02:00
Sipke Schoorstra faf9d57b8b
fix(efcore-oracle): migrate LOB columns in V3_6 without in-place datatype alteration (#8040)
* fix(efcore-oracle): migrate LOB columns in V3_6 without in-place datatype alteration

Both Oracle V3_6 migrations were generated as in-place `ALTER TABLE ... MODIFY`
statements that change a column's datatype to or from a LOB type: NCLOB to JSON
for `WorkflowDefinitions.StringData`, and NVARCHAR2(450) to NCLOB for
`ActivityNodeId` on `WorkflowExecutionLogRecords` and `ActivityExecutionRecords`.
Oracle refuses both (ORA-22858 / ORA-22859), so neither migration could ever
apply and the reported ORA-22858 was unavoidable.

Convert the columns the way the ORA-22858 message prescribes instead: add a
temporary column of the target type, copy the values across, drop the original
and rename the temporary one. Because Oracle commits DDL implicitly, a run that
fails partway leaves its earlier statements applied - the reporter's already
committed `OriginalSource` column is exactly that - so every step is guarded
against the state a previous attempt can have left behind. The conversion block
derives what still needs doing from `ALL_TAB_COLUMNS`, skips a conversion that
already completed (including one applied by hand), and raises rather than copy
out of and drop a column whose datatype it does not recognize.

Add an offline regression test that generates the Oracle Management and Runtime
V3_6 scripts through `IMigrator.GenerateScript` without a connection and asserts
that no in-place datatype `MODIFY` is emitted for either column, that the
add/copy/drop/rename sequence appears in order, and that the re-run guards are
present. All 19 cases fail against the previous migrations.

Refs #8011

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(efcore-oracle): tighten migration helper visibility and index guard, share the script-generation test harness

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(efcore-oracle): brace the foreach bodies in the V3_6 migration tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(efcore-oracle): refuse to truncate node IDs on downgrade and validate same-named indexes

The Runtime V3_6 downgrade converted ActivityNodeId from NCLOB back to
NVARCHAR2(450) by copying DBMS_LOB.SUBSTR(..., 450, 1), silently
truncating any value the upgraded schema had allowed to grow past 450
characters. EnsureLobLengthAtMost now checks for oversized values while
the column is still a LOB and raises before any data is copied.

CreateIndexIfMissing also treated any index with a matching name as
already done. It now validates the existing index's table, uniqueness
and ordered column list against ALL_INDEXES/ALL_IND_COLUMNS so a
same-named index left behind by schema drift or manual recovery is not
mistaken for the one the migration means to create.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(efcore-oracle): correct the dynamic SQL rationale on the LOB length guard

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(efcore-oracle): preflight both tables before downgrading and escape schema names in migration SQL

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(efcore-oracle): copy every row NULL-preservingly so retries reproduce the current source

The filtered copy `WHERE "<column>" IS NOT NULL` skipped rows whose
nullable source had since become NULL. If a prior conversion committed
the copy and then failed before dropping the source column, an
operator clearing a value before retrying would find the predicate
skip that row, and the stale converted value would be renamed into
place. Replace the filtered UPDATE with an unconditional, NULL-
preserving CASE expression so a retry always reproduces the current
source exactly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 02:09:08 -07:00
Sipke Schoorstra fda3987c0b
fix(efcore): terminate raw SQL statements in the PostgreSQL and SQLite Runtime V3_6 migrations (#8039)
* fix(efcore): terminate raw SQL statements in the PostgreSQL and SQLite Runtime V3_6 migrations

The V3_6 Runtime migration's two DROP INDEX statements were emitted without a
trailing semicolon on PostgreSQL and SQLite, so `dotnet ef migrations script`
produced syntactically invalid SQL (an unterminated statement inside the
idempotent DO $EF$ block, and unseparated statements in the plain script).
MigrateAsync() was unaffected because EF executes each Sql() call individually.

Add the missing terminators and a regression test that generates the Runtime
migration script offline for both providers (idempotent and plain forms) and
asserts the DROP INDEX statements are properly terminated.

Refs #7912

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(efcore): share migration-script setup and cover the schema-prefixed statements

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(efcore): cover schema-prefixed migration statements without static state

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:27:43 -07:00
Sipke Schoorstra 308f013ad1
test(workflows-api): pin that a new read-only workflow can be imported (#8038)
Add regression coverage for both the single and bulk import endpoints
confirming that importing a workflow definition with a fresh
DefinitionId and IsReadonly=true succeeds and persists as read-only.
NotReadOnlyPolicy is only meant to block edits of existing read-only
workflows; on main it already resolves against the stored definition
(null for a new one), so these tests pin that behavior against
regression.

Refs #7981

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:51:17 -07:00
Sipke Schoorstra 85e5fc083c
fix(external-authentication): scope role-deletion impact to the role's tenant (#8036)
* fix(external-authentication): scope role-deletion impact to the role's tenant

ExternalAuthenticationRoleDeletionDependencyContributor scanned every stored
connection with an empty ConnectionFilter and every configured connection
regardless of its tenant, so a role ID that exists in two tenants could report
another tenant's references as its own impact -- and a configuration entry
owned by another tenant could block a role deletion outright. Remediation had
the same reach: it loaded a dependency's connection by the caller-supplied
owner ID without checking which tenant owned it.

Impact, prevalidation and remediation now only see connections in the role's
tenant context, which is the tenant active on ITenantAccessor while the
role-deletion coordinator runs. Host-scoped connections stay in scope for every
tenant, because the connection registry resolves the host scope for every
signing-in tenant and the provisioner resolves a connection's default role IDs
in the signing-in user's tenant, so a host connection naming a role ID really
does reference that tenant's role. Configuration entries that leave the tenant
blank are host-scoped for the same reason the configuration source materializes
them there. A connection carrying another tenant's ID is out of scope in both
directions, and a connection loaded for remediation that is not in the role's
tenant is treated as absent, which fails the request rather than mutating it.

The stored connections are fetched per applicable scope so another tenant's
rows are never materialized, and both connection stores already honor
ConnectionFilter.Scope; the durable store now has a test pinning that, since
the tenant boundary rests on it.

Refs #8013

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(external-authentication): scan every tenant when deleting a tenant-agnostic role

Role stores expose tenant-agnostic roles (TenantId == "*") from every tenant, but the
role-deletion contributor derived its dependency scan boundary from the ambient tenant only,
so deleting an agnostic role while tenant A was active left references from other tenants
dangling. Resolve the role being deleted once per operation, through the active role store,
and scan every connection and configuration entry regardless of tenant when it is agnostic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(external-authentication): share the active role store lookup

Extract the duplicated "active role store is the last registration"
resolution into a single ActiveRoleStore accessor and rename ToScope to
ToConnectionScope for clarity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(external-authentication): read one connection snapshot and prefer the agnostic role

Reading the host and tenant scopes as two separate store queries let a connection
whose TenantId changed mid-flight fall between the reads and escape both, letting
role deletion proceed while a reference remained. FindConnectionsInRoleTenantScopeAsync
now reads one snapshot and filters it in memory. IsAgnosticRoleAsync resolved a role
by an unqualified ID lookup, which could return the ambient tenant's role instead of
an agnostic role sharing its ID, silently narrowing impact scanning and leaving
JIT-policy references in other tenants dangling; it now checks every role sharing the
ID and gives the agnostic scope deterministic precedence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(external-authentication): correct the scope-filter test comment

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(external-authentication): fail closed when a role ID resolves to more than one role

A same-ID collision between a tenant-scoped role and an agnostic role can only
occur in MemoryRoleStore (durable persistence keys roles by ID alone). In that
case the coordinator's own deletion target is already ambiguous, so widening
or narrowing the scope by guessing is wrong in either direction; throw instead
of picking a side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(external-authentication): scope role-deletion impact by the resolved role's tenant

Replace the isAgnosticRole flag with ResolveRoleTenantIdAsync, which returns the
resolved role's own TenantId and falls back to the ambient tenant only when the
role cannot be resolved. With multitenancy disabled the EF role store installs
no tenant query filter and can resolve a tenant-owned role by ID regardless of
the ambient tenant, so scoping by the ambient tenant alone left that role's
connection references out of scan while the coordinator deleted it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(external-authentication): require an agnostic replacement when remediating an agnostic role

Authorization for a replacement role still resolves through the ambient tenant's
role services, so a deletion initiated in tenant A could authorize a tenant-A-only
replacement and then write it into tenant B's connection policy, where that role
does not exist. When the deletion target is agnostic, require the replacement
role to be agnostic too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(external-authentication): require agnostic replacements for host connections and reject ambiguous ones

Extend the agnostic-replacement requirement to host-scoped connections, since a host
connection is served to every signing-in tenant and a tenant-scoped replacement would
resolve in the authorizing tenant but fail to resolve in every other tenant it serves.
Recheck the replacement at removal time through the same agnostic-role resolution used
at validation, instead of trusting whichever same-ID role a plain FindAsync happens to
return, so a replacement collision introduced between validation and mutation is
rejected. Resolve IsAgnosticRoleAsync's candidate directly and return true only when
exactly one matching role is agnostic, so an ambiguous replacement ID is reported as
replacement_role_unavailable_or_unauthorized instead of escaping as an exception.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(external-authentication): keep host-connection replacements allowed for default-tenant roles

Revert the host-scope replacement guard added for host-scoped connections.
IdentityProviderConnectionManagementService forces every managed connection
to host scope, and in a deployment without multitenancy roles are created
scoped to the default tenant rather than agnostic, so requiring an agnostic
replacement for host-scoped connections would make every replacement
remediation impossible in the default deployment. The replacement guard
applies only when the deletion target itself is agnostic, as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:34:44 -07:00
Rostislav Statko 54c8fda65f
fix(alterations): preserve tenant context for background alteration jobs (#7962)
* Fix tenant context propagation for alteration jobs

Capture the current tenant when dispatching a background alteration job
and restore it while executing the queued callback.

Add regression coverage verifying that the dispatch-time tenant is used
and the worker's previous tenant context is restored.

Fixes #7961

* test(alterations): cover tenant restore on failure and concurrent tenant isolation

Add coverage for the spec lines that were previously untested: the
worker's tenant context is restored after execution even when the
job runner throws, and concurrent jobs dispatched under different
tenants do not exchange tenant context. Extract the shared service
provider / job queue / recording runner arrange logic into
constructor-initialized fields so the three tests stay DRY.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(alterations): cover default-tenant dispatch and share dispatcher test setup

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:07:36 -07:00
Copilot 536cee3fc2
fix(identity): publish the role security notification after role deletion (#8026)
* Initial plan

* Publish security notification after role deletion

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Fix atomic role deletion notifications

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* refactor(identity): delegate MemoryRoleStore.DeleteAsync to TryDeleteAsync

Mirrors EFCoreRoleStore.DeleteAsync so the deletion logic exists once
instead of being duplicated between DeleteAsync and TryDeleteAsync.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(identity): scope the atomic role delete capability to a single role ID

Narrow IRoleStoreWithAtomicDelete.TryDeleteAsync to accept a single role
ID instead of a RoleFilter, closing a race where two concurrent deletes
matching multiple roles could each remove one and both report success.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:44:36 -07:00
Sipke Schoorstra cbdc3f7e9d
Honor label filters when listing workflow definitions (#8035)
* Honor label filters when listing workflow definitions

* Authorize label lookups and require filter resolution
2026-09-05 22:13:16 -07:00
Sipke Schoorstra 5f692b956d
Merge pull request #8029 from elsa-workflows/codex/issue-8028-role-remediation-contract
Support selective role deletion remediation
2026-09-05 19:53:52 -07:00
Sipke Schoorstra 77da232bba
fix(identity): select active role store for remediation 2026-09-06 04:32:01 +02:00
Sipke Schoorstra 5442e62112
test(modular-host): add opt-in role management fixture 2026-09-06 04:25:09 +02:00
Sipke Schoorstra f9d41ee85b
Merge remote-tracking branch 'origin/main' into codex/issue-8028-role-remediation-contract 2026-09-06 04:11:47 +02:00
Sipke Schoorstra 07f788afb8
fix(identity): isolate in-memory roles by tenant (#8032)
* fix(identity): isolate in-memory roles by tenant

* fix(identity): retain legacy default-tenant roles
2026-09-05 19:01:42 -07:00
Sipke Schoorstra 62fcc302c7
Guard optional Identity role services 2026-09-06 03:54:35 +02:00
Sipke Schoorstra 602ad38afa
fix: preserve Sequence ownership when retrying child activities (#8027)
* fix: preserve Sequence ownership when retrying child activities

* fix: allow FastEndpoints EmptyRequest in invitation wrapper
2026-09-05 18:42:28 -07:00
Sipke Schoorstra 4bfb0a1f14
Support selective role deletion remediation 2026-09-06 03:09:37 +02:00
Sipke Schoorstra e7def4f1e9
Merge remote-tracking branch 'origin/release/3.8.0'
# Conflicts:
#	.github/workflows/packages.yml
#	.specify/feature.json
#	CONTEXT.md
#	Directory.Packages.props
#	doc/adr/toc.md
#	src/modules/Elsa.Workflows.Api/Endpoints/OutputConverters/List/Endpoint.cs
#	test/unit/Elsa.Workflows.Api.UnitTests/OutputConverters/OutputConverterEndpointTests.cs
2026-09-06 00:06:14 +02:00
Sipke Schoorstra 2787ff6bd0
Preserve StateMachine composite transition continuations 2026-08-31 04:51:34 +02:00
Sipke Schoorstra da5ccca2be
Yield triggerless StateMachine cycles 2026-08-31 03:30:17 +02:00
Sipke Schoorstra 5a820ce33e
Expand StateMachine conformance coverage 2026-08-31 03:11:19 +02:00
Sipke Schoorstra 6a2e530d77
Enforce StateMachine trigger identity boundaries 2026-08-31 02:54:01 +02:00
Sipke Schoorstra 051e12f864
Fix StateMachine transition lifecycle ordering 2026-08-31 02:47:29 +02:00
Xu Jianxiang 484f7a0e5f
fix(core): clear stale blocked tokens when a flowchart activity completes (#7993) (#7994) 2026-08-31 00:59:05 +02:00
Sipke Schoorstra 767c97aad5
refactor(identity)!: retire the SecurityRoot policy in favour of endpoint permissions (#8003)
* refactor(identity)!: retire the SecurityRoot policy in favour of endpoint permissions

Completes T040. ADR 0010 already decided SecurityRoot was overloaded and that
endpoints should be authorized by their own permissions; this removes the last
of it.

Roles/Create and Applications/Create carried Policies(SecurityRoot) alongside
an existing RequirePermission, so the policy was redundant there and the line
is simply dropped.

Secrets/Hash carried only the policy. By default SecurityRoot resolved to
RequireAuthenticatedUser(), so any signed-in caller could exercise the password
hasher. It now declares identity/users:create, on the grounds that hashing a
secret is a step in provisioning a credential. This is a tightening: callers
who could hash before and hold no user-creation permission will now be refused.

The policy, its two registration paths and the IdentityPolicyNames constant are
removed. ConfigureAuthorizationOptions stays public and now defaults to a no-op
so hosts that add their own policies are unaffected.

BREAKING CHANGE: the SecurityRoot authorization policy and the
IdentityPolicyNames class are removed. Hosts referencing either should rely on
endpoint permissions, and use DefaultAdminUserFeature for initial bootstrap.

Note: SecurityRoot was the only attachment point for LocalHostPermissionRequirement,
so the localhost permission grant is now inert. The requirement type and the
EnableLocalHostPermissionGrantForSecurityRoot toggles are left in place rather
than deleted, but they no longer gate anything -- see the PR for why that path
was already incoherent.

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

* refactor(identity)!: delete the localhost bootstrap grant and its machinery

Follows the SecurityRoot removal in the previous commit. SecurityRoot was the
only attachment point for LocalHostPermissionRequirement, so the localhost
permission grant is now removed outright rather than left inert:
LocalHostPermissionRequirement, LocalHostRequirement (already dead -- registered
as a handler but consumed by no policy), LocalHostPermissionRequirementOptions
and the two feature toggles all go.

The grant was the weakest of the three bootstrap mechanisms Elsa already has. It
trusted network position, which stops meaning anything behind a reverse proxy,
inside a container, or across a port-forward; it granted unauthenticated access,
so the bootstrap action carried no identity; it covered only localhost, so it
did nothing for a deployed environment; and it could not perform its headline
job, because it granted identity/users:create while POST /identity/users does
not carry the policy that injected it.

The replacements already exist and both work in deployed environments:
UseDefaultAdmin(...) seeds an admin role and user at startup, idempotently, and
UseAdminApiKey(...) accepts an out-of-band key. What the localhost grant did
usefully provide was a hint that something needed configuring, so
IdentityBootstrapDiagnostic replaces that: when the user store is empty and
neither mechanism is configured, startup logs an error naming both, instead of
every endpoint answering 403 with no explanation.

BREAKING CHANGE: LocalHostRequirement, LocalHostPermissionRequirement,
LocalHostPermissionRequirementOptions and the
Enable/DisableLocalHostPermissionGrantForSecurityRoot toggles are removed. Use
UseDefaultAdmin or UseAdminApiKey to bootstrap an instance.

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

* docs(identity): scope the hash endpoint's documentation to users, and pin the declarations

The hash endpoint's remarks said the callers that need it are "the ones standing up
users and applications", while the endpoint requires identity/users:create alone. An
application provisioner reading that would have been sent into a 403.

The documentation was the part that was wrong. `POST /identity/applications` generates
and hashes the client secret and the API key itself and returns both the plaintext and
the hash, so identity/applications:create is already sufficient to create an application
and the hash endpoint is not on that path at all. Say so, in the endpoint and in the
migration guide, rather than widening a grant nobody needs.

Adds EndpointPermissionTests over the three endpoints that carried the retired
SecurityRoot policy: the two that only lost a redundant policy line must keep the
permission they already declared, and Secrets/Hash must keep the one it gained. The
coverage gate only asks whether an endpoint declares something, so either half could
otherwise change unnoticed.

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

* docs(identity): state the hash endpoint's user-only scope in the summary, and complete the removal list

Moves the user-only scoping into the endpoint's <summary>, which is the part that reaches
the generated API description, rather than leaving it to a paragraph further down. The
remark now says outright that no application-provisioning flow reaches this endpoint and
none is documented to, with the reason: POST /identity/applications generates the client
secret and the API key itself, hashes both, and returns each plaintext alongside its hash.

The migration guide's removal list was partial — it named the requirements and the two
toggles but not the handlers, the options type, the EnableLocalHostPermissionGrant property
on either feature, or the already-obsolete DisableLocalHostRequirement() alias. A reader
hitting a compile error on any of those would not have found it in the guide. It also now
records that ConfigureAuthorizationOptions survives as a no-op default.

Adds the store-failure case to IdentityBootstrapDiagnosticTests: the broad catch is
load-bearing — an unmigrated database must not stop the host from starting — and nothing
was holding it in place. Disposes the test service provider, and folds the repeated arrange
blocks in DefaultAuthenticationFeatureTests into fields.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:28:29 +02:00
Sipke Schoorstra 723bdc0004
fix(identity): route the remaining permission checks through the evaluator (#8001)
* fix(identity): route the remaining permission checks through the evaluator

Completes T038 of the authorization model, and fixes a live defect it was
meant to catch.

RoleDeletionCoordinator.InspectAsync gated on the legacy string "delete:role",
compared by claim-value equality. Nothing has granted that spelling since the
vocabulary migration, so a caller holding identity/roles:delete passed the
endpoint's own RequirePermission check and was then refused mid-handler by the
coordinator. In practice role deletion worked only for holders of "*", across
all three routes that reach the coordinator (Delete, RemediateAndDelete and
GetDeletionImpact). The check now evaluates identity/roles:delete through
PermissionEvaluator, so structured and wildcard grants both reach it.

Every existing coordinator test acted as an administrator holding "*", which is
why this went unnoticed; the added cases exercise identity/roles:delete,
identity/*:delete and identity/roles:* and assert an unrelated grant is still
refused.

AIHttpContextIdentity matched an agent's required permissions by
case-insensitive exact-set containment, which both admitted casing the rest of
the model rejects and refused the wildcards it honours. It now evaluates each
required permission through the same evaluator.

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

* fix(ai): restore the null-safe HttpContext access in the tools endpoint

The tools endpoint dereferenced HttpContext directly when passing the principal
to GetAuthorizedAgent, which threw a NullReferenceException and failed two
Elsa.AI.IntegrationTests cases on CI.

This was collateral from tightening a nullable warning in the chat endpoint. The
chat endpoint dereferences HttpContext.Response unconditionally a few lines
later, so HttpContext.User is safe there; the tools endpoint never does, and its
three sibling calls -- GetPermissions, GetActorId and GetTenantId -- all accept
a null context. The same edit was applied to both, and only chat could take it.

Reverting to HttpContext?.User preserves the endpoint's prior behaviour:
GetAuthorizedAgent treats a null principal as holding nothing, and an agent that
declares no required permissions stays authorized either way, because the
empty-requirements check runs before the null check.

Elsa.AI.IntegrationTests 69/69 (was 67 passed, 2 failed); Elsa.Identity.UnitTests
115/115; Elsa.AI.Host builds with no CS8602.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:03:15 +02:00
Sipke Schoorstra 292e4bd3ea
feat(user-tasks)!: migrate endpoints to structured permissions (#7999)
Re-authors the nine User Tasks permissions as verbs on the user-tasks and
user-tasks/participants resources with a descriptor provider, replacing the
legacy verb:resource strings (UserTasksPermissions is removed along with the
other legacy constant classes). All 17 endpoints declare access through
RequirePermission, and UserTaskActor.HasPermission matches through
PermissionMatcher instead of string equality, so pattern grants reach these
endpoints for the first time. manage:user-tasks becomes user-tasks:supervise
to reflect that it grants oversight, not an aggregate. The migration guide
and contract specs carry the full mapping.

BREAKING CHANGE: legacy user-tasks permission strings no longer authorize
anything. Rewrite grants using the mapping table in
doc/migrations/authorization-model.md.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 12:06:52 +02:00
Sipke Schoorstra 168a8c76f0
fix(auth): validate wildcard permission patterns and warn on deny-list stripping (#7997)
* fix(auth): validate wildcard permission patterns and warn on deny-list stripping

Permission.IsValidPattern rejects inert wildcard spellings (such as
"workflows*:delete") that parse but can never match. The grant boundary,
stored-permission, and external-authentication options validators reject them
at authoring time, and PermissionGrantValidator applies the same check to
incoming grants.

ExternalAuthenticationOptionsValidator now warns (never fails) when
DeniedPermissions is non-empty, because any non-empty deny list refuses every
wildcard grant that could reach a denied permission -- including the seeded
administrator role's "*". The validator takes an ILogger, and
AddExternalAuthenticationServices registers logging alongside its other
framework dependencies (TryAdd-based, so host logging configuration wins).
The operational consequence is recorded in the authorization-model migration
guide.

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

* fix(auth): report subtree grants whose verb nothing under them supports

'workflows/*:frobnicate' reached a non-empty subtree and was therefore
treated as resolved, so the startup audit stayed silent about a grant
that cannot authorize anything. Require at least one reached descriptor
to support a concrete verb; verb wildcards keep the reach-only check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:45:54 +02:00
Sipke Schoorstra 80a5892ecd
fix(external-auth): keep the default-roles guard when an update omits the policy (#7996)
* fix(external-auth): keep the default-roles guard when an update omits the policy

A PUT that omitted unlinkedPolicy hit ValidatePolicyAsync's null-policy early
return before the PolicyDefaultRoles guard, so an actor holding only
connections:update could clear a stored create-user policy and silently drop
its default-role assignments (refs #7977, #7992). The candidate role set is
now computed before the early return -- empty when the policy is omitted --
so clearing, adding, or switching a policy all count as changing default
roles. The cheap permission check also runs before the registry-backed role
comparison, so the common permitted path skips building the registry.

Adds integration coverage for the omitted-policy transition in both
directions and for clearing a policy that assigns no roles.

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

* test(external-auth): dispose the request message in PutConnectionAsync

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:45:44 +02:00
Sipke Schoorstra 8907eeffd2
feat(external-auth)!: require a permission to author policy default roles (#7992)
* feat(external-auth)!: require a permission to author policy default roles

Setting the defaultRoleIds of an unlinked-identity policy was guarded only by
the subset rule -- you could not grant roles carrying permissions you did not
hold -- so any actor able to edit a connection could decide what auto-created
users receive. The permission named for that decision,
external-authentication/policies/default-roles:update, was enforced in one
place: removing policy references while deleting a role.

The asymmetry is what makes this look like a check that was never wired
rather than a deliberate carve-out. Its sibling, policies:update, is already
enforced on the write path at both the create and update sites, through the
same RequiresPolicyManagement condition that covers the very policy the roles
live inside.

Demonstrated rather than argued: with the guard stubbed out, a caller holding
only connections:create and policies:update creates a connection whose policy
assigns "workflow-user", and the response is 201. The subset rule does not
object, because it answers a different question -- it prevents escalation, not
delegation of the decision.

The two checks are now reported independently for that reason. The permission
asks whether this actor may decide default roles at all; the subset rule asks
whether these particular roles stay inside what they already hold. It applies
only when roles are actually being set, so clearing the list, or a policy that
assigns none, needs nothing extra.

Breaking for roles holding the legacy policies:manage but not roles:assign
that set default roles today. Anyone who held roles:assign already maps to the
new permission and is unaffected. Documented in the migration guide.

Closes #7977

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

* fix(external-auth): gate default roles on the set changing, not on it existing

Review reproduced the over-reach through the real endpoints: validation runs
on every update, on enabling a connection, and on read-only validate, so
keying the permission off default roles being present meant that once anyone
set them, an administrator without the permission could no longer edit an
unrelated field on that connection, enable it, or validate it.

The permission now applies when the set changes -- adding, removing, or
clearing all count as deciding what auto-created users receive; leaving a
stored set alone does not. Order is not treated as meaningful, so reordering
is not a change.

The test that was supposed to cover this asserted only that a message was
absent, which passes for any failure response and made it vacuous exactly
when it mattered: it passed with the over-reach still in place, because the
request was failing 405 on the wrong verb. It now uses PUT and asserts
success, and reverting the fix makes it fail with the 400 review described.

Refs #7977

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

* fix(external-auth): treat abandoning a create-user policy as a role change

The permission check sat inside the create-user branch, so it only ran when
the candidate policy still created users. Switching a stored fallback to one
that does not -- 'reject', or match-user with a different noMatchAction --
skipped it entirely and dropped the policy's automatic role assignments
without the permission that governs them. Review reproduced it.

The effective default roles of a policy that does not create users are none,
so computing that first and comparing outside the branch makes abandonment a
change like any other. The subset rule stays inside the branch, because it
only has something to say about roles actually being assigned.

The new test expresses abandonment through noMatchAction rather than the
policy type, since the fixture's registry only knows match-user. Re-scoping
the check to create-user candidates makes it fail with OK instead of the
expected BadRequest, which is the bypass.

Refs #7977

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

* fix(external-auth): take the default-role baseline from the registry

A configuration-owned connection has no database row, so comparing against
the store alone made its configured default roles look newly assigned on
every validation. Validation needs only connections:view, so a caller with
exactly that could not validate such a connection at all -- review
reproduced it.

The baseline now comes from the registry, which answers for both ownerships
and is the question actually being asked: what does this connection assign
today. The store remains a fallback for a record the registry does not know.

The new test gives the fixture's configuration connection an unlinked policy
with default roles and validates it as a view-only caller. Reverting to the
store-only baseline makes it fail with the permission error, which is the
symptom review described.

Refs #7977

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 03:31:44 +02:00
Sipke Schoorstra ee40689ef9
feat(secrets)!: scope secrets to tenants (#7991)
* feat(secrets)!: scope secrets to tenants

Secret was the one user-facing entity with no notion of tenancy. It did not
derive from Entity, so it carried no TenantId and no query filter applied to
it: in a multi-tenant deployment every tenant could see and resolve every
other tenant's secrets. Permissions did not help, because secrets:view is
evaluated against the caller rather than against which tenant owns the
secret, so any caller holding it reached the whole set.

Secret now derives from Entity and is filtered like everything else. The
infrastructure was already in place -- SecretsElsaDbContext derives from
ElsaDbContextBase and the feature from PersistenceFeatureBase, which
registers SetTenantIdFilter -- and the handler was skipping secrets for one
reason: it only applies to Entity.

No backfill, deliberately. The column is added nullable and existing rows
keep a null tenant, because SetTenantIdFilter already treats null as the
default tenant through a clause written for exactly this case. Single-tenant
deployments see no change at all, since the filter is only installed when
multitenancy is enabled. Multi-tenant deployments find pre-existing secrets
invisible until assigned, which is a visible failure rather than continued
cross-tenant exposure.

Two things this needed that were not obvious:

Secret self-initialized its Id and nothing else ever assigned one -- there is
no identity generator on the create path -- while Entity.Id is null!. Simply
deriving would have produced a null id on every insert, which any test that
builds a Secret by hand would have missed. A constructor preserves it.

The unique index moves from NormalizedName to (TenantId, NormalizedName),
matching User, Role and Application in the same release. Leaving it global
would have made secret names a shared resource: the second tenant to want
"smtp-password" could not create one.

Elsa.Secrets.Persistence.VNext cannot support this. It keys documents by name
alone and Elsa.Persistence.VNext has no tenant concept to filter on, so it now
throws outside the default tenant rather than serving one tenant's secret to
another. Making it tenant-aware means changing the document id scheme, which
relocates existing documents and is a storage change to make deliberately.

Refs #7972

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

* fix(secrets): let the VNext repository resolve without multitenancy

The tenancy guard took ITenantAccessor as a required dependency. That
interface is registered by the tenants module, so a host that never added
multitenancy has none, and resolving ISecretRepository threw for exactly the
deployments the guard is meant to leave alone.

The accessor is now optional, and its absence means no tenancy, which is the
default tenant.

Found by review, and it is worth naming why the tests missed it: every case
in VNextSecretRepositoryTests constructs the repository directly with a stub
accessor, so none of them ever went through the container where the failure
lived. The new case resolves through a service collection that adds only the
document store and the module's own registration, which is what a
single-tenant host looks like. Reverting the fix makes it fail with the same
missing-service exception review reported.

Refs #7972

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:56:55 +02:00
Sipke Schoorstra 372cf33cae
refactor(auth): remove the vestigial per-author script permission plumbing (#7990)
* refactor(auth): remove the vestigial per-author script permission plumbing

#7975 is closed won't-do: authoring a workflow is a trusted act, and a
per-author gate would not change what a script can do once it runs. The host
switch stays the control, and it is per language, so an untrusted author gets
a host with the switch off rather than a permission.

That settles what the code was still half-carrying. WorkflowDefinitionScriptAuthorizationService
took a ClaimsPrincipal it never read, and could return a MissingPermission
reason nothing produced; two call sites branched on that reason to send a 403
that could not happen. The expression-descriptor endpoint kept a map from
expression type to per-author permission whose values went unused even before
the permissions were retired -- it only ever tested membership, and the
decision was always IsBrowsable. Each of these reads as an authorization gate
to anyone scanning the file, and none of them is one.

The principal, the unreachable reason, and both dead branches are gone. The
map becomes a set of the expression types the host can switch off, which is
what it was actually being used as. Behaviour is unchanged: the only failure
is a language the host disabled, which is a property of the deployment and
so a 400 naming the switch, never a 403.

PermissionNames loses ExecuteCSharpExpressions and ExecutePythonExpressions,
which existed only for that map and the test mirroring it. Five other legacy
constants there are also unreferenced but belong to other modules; they are
left alone rather than swept up here.

Two tests asserting the host-and-user case were exact duplicates of the
host-only case once the principal stopped mattering, so they go with it.

The migration guide said deployments lose per-author granularity "until
#7975 lands" and advised disabling host code until then. That promise is
withdrawn and replaced with the actual guidance.

Closes #7975

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

* docs(wiki): drop the retired exec:* permissions from the scripting guide

Review found doc/wiki/expressions-and-scripting.md still telling operators
that API callers "must have the exec:csharp-expressions permission" to
author, publish, dispatch or execute workflows containing C#, and the same
for Python. Those permissions no longer exist, so the instruction cannot be
followed and describes a gate that is not there.

Both sections now say what is actually true: the host switch is the whole
control, there is no per-caller permission because a workflow runs under the
server's authority rather than the caller's, and an untrusted author gets a
host with the switch off. The switches are noted as independent, since
enabling Python while leaving C# off is a real posture.

My earlier sweep searched for the issue number rather than the permission
strings, which is why this file was missed.

Refs #7975

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:58:16 +02:00