* fix(runtime): bound concurrent pre-cancel snapshot Finds during drain
Unbounded Task.WhenAll of per-cycle Finds self-contends under large
live-cycle N: more 250ms timeouts, more drainInduced excludes, more
Interrupted misses. Cap snapshot Finds at 16. The 250ms budget still
starts only after a slot is acquired so queued Finds are not fail-open
excluded by waiting.
Phase C stays sequential. No store-contract change.
Closes#8083
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(runtime): exclude null pre-cancel snapshots from drainInduced
A successful Find that returns no row is unknown pre-state, not a
confirmed non-Cancelled snapshot. Joining drainInduced let Phase C
rewrite a later Finished/Cancelled as Interrupted. Timeout/error
already excluded; null now does too.
Closes nothing extra; keeps #8083 fail-open exclude.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(runtime): promote null snapshot after Phase A force-cancel
A successful pre-cancel Find that returns no row is not a persisted user-cancel, but excluding it from drainInduced skipped Interrupted persist after deadline-breach force-cancel of a live cycle (DeadlineBreachPersistsInterrupted). Join drainInduced only after we ourselves cancel that handle. Timeout/error and confirmed Cancelled snapshots stay excluded.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(runtime): promote null snapshot only when TryCancel transitions
Cancel() is a no-op on an already-disposed handle, so treating every Cancel() call as drain-induced could rewrite a Finished/Cancelled row the runner committed while snapshot was in flight. TryCancel reports a real transition; only those ids join drainInduced after a null Find.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(labels): enforce per-tenant uniqueness on NormalizedName
Finish the unused Label.NormalizedName contract the same way Secrets
does: unique (TenantId, NormalizedName) in EF, fail-closed Memory saves,
and keep NormalizedName in sync with Name.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(labels): clone Memory label reads so a rejected rename cannot persist
Find/List handed out live store refs. Labels.Update mutates Name (and
NormalizedName) on that instance before Save; a uniqueness rejection
then left the stored row already renamed. Clone-on-read matches Memory
identity stores from #8108.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(labels): stamp null TenantId, fail-loud on leftover duplicates, cap names at 255
Follow the Architect steer for Greptile P1s: do not auto-delete duplicate
labels; UPDATE Labels SET TenantId = '' WHERE TenantId IS NULL on every
provider before CreateIndex; keep SQL Server/Oracle filtered unique
indexes; HasMaxLength(255) on Name and NormalizedName with no silent
truncate.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(labels): fail-loud preflight for leftover keys and over-length names
No silent dedupe: every provider lists leftover (TenantId, NormalizedName)
keys and aborts before CreateIndex. SQL Server/Oracle keep their filtered
unique indexes. PostgreSQL/SQLite keep provider column types. Providers
that narrow Name/NormalizedName to 255 preflight over-length Ids first.
Memory uniqueness semantics are unchanged.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* test(labels): match Oracle filtered-index escape in migration assertion
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(identity): enforce per-tenant uniqueness in Memory identity stores
Mirror EF PerTenantIdentityUniqueness on Memory user, role, and application
stores so a second Id cannot claim the same Name or ClientId within a tenant.
Same-Id upserts may still rename themselves.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(identity): keep Memory identity finds from mutating stored rows
Clone user, role, and application rows on read so a rejected Save of a
Find result cannot leave a colliding name or client id in the store.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(identity): honor tenant isolation in Memory user and application stores
Align MemoryUserStore and MemoryApplicationStore with EF SetTenantIdFilter
via TenantVisibility: null TenantId is default-tenant-only, and applications
filter by ambient tenant. Stamp missing TenantId on write like ApplyTenantId.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
* fix(identity): delete memory users and applications under tenant lock
DeleteAsync now removes under MemoryStore.Sync with TenantVisibility in the
same predicate, so a same-Id replacement from another tenant cannot be wiped.
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
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>
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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
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>
* 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>
* 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>
* 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>
* 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>
* 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>
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>