* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* test(auth): check the upgrade guide's mapping table against the catalog
#7982 asked for the guide to be walked against a real deployment before
release notes point at it. Walking it once tells you about one afternoon, so
it is checked on every run instead.
The table tells operators how to rewrite every stored permission, and nothing
verified that what it tells them to write is a permission Elsa accepts. That
gap was not hypothetical: the cutover left several checks comparing against
legacy constants the guide itself instructs you to replace, so following it
silently disabled them. An entry that does not parse, or that names a
resource or verb no module advertises, is a deployment locked out of an
endpoint by doing exactly as it was told.
The published document is read rather than a copy, so this fails when the
guide drifts from the code, which is the direction drift actually goes. Both
checks are guarded against passing vacuously: the parser asserts it found a
plausible number of rows, and the catalog asserts it is not empty.
Building that catalog took three attempts, and the first two under-reported
in ways worth recording. AppDomain.CurrentDomain.GetAssemblies() describes
whatever earlier tests happened to touch: this class saw 29 resources as
missing when run alone and none in a full run, which is an order-dependent
test and worse than no test. Walking GetReferencedAssemblies() from the two
hosts is no better, because the compiler drops references to assemblies whose
types the app never names -- AI, OpenTelemetry and Shells all vanished
despite being project references. Loading every Elsa.*.dll in the output
directory is complete and gives the same answer in isolation as in a full
run, which is the property that matters.
Verified by injecting a malformed entry and an unadvertised one: each check
fails with exactly that entry named, and nothing else.
The table as it stands passes both.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(auth): read every mapping row, whatever its indentation
Review found the row filter required an unindented "| `", so a formatting-only
change that indented the table would drop rows silently while the totals still
looked plausible. A skipped row is an unchecked permission, which is the one
outcome this test exists to prevent.
Rows are now matched after trimming, and the left column no longer has to be
backticked. Each data row must yield at least one replacement, so a row that
parses to nothing fails instead of disappearing. Rows saying the permission
was *removed* rather than translated are recognised, and only those: ten of
them exist and none has a replacement to check.
Verified by indenting the whole table and breaking one entry. The old parser
would have read nothing and passed; this reports exactly the broken row.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* refactor(auth)!: retire the legacy permission constants and duplicate descriptors
Completes the cutover started in #7980. Seven `<Module>Permissions` classes
holding `verb:resource` strings are removed: AIPermissions, ConsoleLogs,
Dashboard, ExternalAuthentication, OpenTelemetry, Secrets and StructuredLogs.
AIPermissions was not in #7982's list, which was written before the cutover
finished; it is dead by the same measure as the rest.
Removed rather than marked obsolete, which #7982 asked to be an explicit
decision. Every string these classes held carries two colons, so it does not
parse under the new grammar and authorizes nothing. Keeping them obsolete
would leave code that compiles, still reads as a permission check, and
silently grants no access -- a warning that is easy to suppress in front of a
runtime failure that is invisible. A compile error names the call site and
can be fixed against the migration guide's mapping table. Classes their own
modules still reference, WorkflowPermissions and IdentityPermissions among
them, are untouched.
External Authentication's parallel descriptor system is collapsed onto the
core types: its own PermissionDescriptor record, its IPermissionDescriptorProvider
and IPermissionDescriptorRegistry, and DefaultPermissionDescriptorRegistry.
That was not only tidiness. The module's registry was fed exclusively by its
legacy names, so after the cutover every well-formed grant failed the
`unknown_permission_descriptor` check and the warning fired constantly for
correct configuration. The resolver now consults the core catalog, which is
keyed by resource and lists the verbs each accepts, and a wildcard is treated
as advertised because it names a pattern rather than a resource to look up.
The descriptor endpoint serves the core catalog too: choosing what an
external mapping may confer means choosing from everything Elsa declares.
The module contributes its resource descriptors explicitly rather than
relying on the host's assembly scan, for the same reason it registers
AddElsaAuthorization itself.
The two naming tests now pin the new resource name instead of the legacy
string. The convention worth holding was always that the module is called
'diagnostics/console-logs', not that a retired constant kept its old value.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): match the permission descriptor client model to the catalog
Moving the descriptor endpoint onto the core catalog changed its shape from a
single permission string to a resource plus the verbs that resource accepts,
and the Refit client model kept the old one. It still deserialized and still
compiled, handing callers a blank Name and no way to reach the verbs -- the
data went missing without anything failing.
The client model now mirrors the served descriptor, and a contract test
compares the two property sets so the next divergence is a test failure
rather than an empty field. NonCoreVerbs is excluded: the server derives it
from SupportedVerbs, so a client holding the verbs can compute it.
Found by review, not by the suites: nothing here throws.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: match permission grant boundaries as patterns
The deployment allow/deny boundary and the delegation authorizer compared
permission strings with ordinal equality, so under the {resource}:{verb}
vocabulary they could not see wildcards. A deny list naming
'workflows/*:delete' did not deny 'workflows/definitions:delete', and a grant
of 'workflows/*:delete' outflanked a deny naming that leaf.
The bypass was reachable. ElsaRolePermissionGrantSource passes a role's
permissions to the boundary verbatim, survivors land in the issued token as
permission claims, and PermissionEvaluator does expand wildcards there. So an
ordinary role plus a deny list was enough, on every external sign-in, with no
privileged actor involved. Restoring the ordinal boundary under the new tests
fails seven of them.
Deny is now matched in both directions, allow one-directionally, both through
PermissionMatcher. A grant that is not a well-formed permission is dropped
with a warning rather than carried into a token it cannot authorize anything
in.
Five non-endpoint checks -- delegation, role-reference removal, unsafe
settings confirmation, the recovery override and the boundary itself -- also
still compared against the legacy ExternalAuthenticationPermissions
constants. Those carry two colons, so Permission.TryParse rejects them and no
principal can hold one, while the migration guide tells operators to replace
exactly those strings. All five now route through IPermissionEvaluator, and
the module registers AddElsaAuthorization itself instead of depending on host
ordering.
Non-core verbs move to ExternalAuthenticationVerbs, declared beside the
resources they apply to so a delegation check cannot spell one differently
from the endpoint it guards.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style: apply IDE code cleanup to the diagnostics and identity modules
Redundant namespace qualifiers and usings removed, and primary-constructor
and record syntax applied, across Elsa.Diagnostics.ConsoleLogs,
Elsa.Diagnostics.StructuredLogs, Elsa.Expressions.JavaScript and
Elsa.Identity. Produced by a solution-wide IDE cleanup that ran alongside the
authorization work; separated from it so the permission changes can be
reviewed on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(hosts): boot both hosts and assert their gated routes challenge
This repo runs two parallel feature systems, the classic Features/ path and
the CShells ShellFeatures/ path, and every module has to register in both.
Nothing exercised either. The unit and integration suites construct services
directly, so a module registered in one path and not the other, or a service
missing from one container, passes every test and fails only when a host
starts. Three bugs in #7980 were found by running these two hosts by hand,
two of them shell-versus-classic divergences.
Each host is booted through WebApplicationFactory, running its real Program
with full feature registration, and asked for a handful of routes it is
expected to serve behind a permission. A 404 means the module was never
registered, a 5xx means the endpoint was found but its dependencies could not
be constructed, and a 200 means no gate ran; only 401 passes. All routes are
reported together, so a feature system that stops registering a group of
modules reads as one failure rather than a queue of identical ones.
Removing AddExternalAuthenticationServices from the shell feature -- the
divergence this is built to catch -- fails the shell host on all five of its
routes while the classic host stays green.
The assertions go through HTTP rather than the container on purpose. The
hosts have different topologies: the classic host's root provider holds
everything and registers 125 routes, while CShells gives each shell its own
provider and mounts routes per shell, leaving 6 in the root. A container or
route-table assertion would have to encode that difference and would break
whenever CShells changed internally. Behaviour at the edge is host-agnostic,
and it is what actually has to match.
Each host gains a namespaced entry-point marker because both already declare
a Program in the global namespace, which a test project referencing both
cannot tell apart.
Coverage is off for this project: it references both hosts, so every module
either pulls in would enter its denominator without adding real coverage, and
coverlet cannot instrument a graph that size. TreatAsLocalProperty keeps CI's
/p:CollectCoverage=true from overriding that.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: fail closed on an unparseable grant boundary
Two findings from review, both real.
The grant boundary parsed its allow and deny lists and silently dropped what
would not parse. An allow list of nothing but malformed entries therefore
reduced to an empty set, and an empty allow list means unrestricted -- so a
typo turned the boundary off entirely and let external grant sources put
permissions straight into issued tokens. The deny side had the mirror of it:
a malformed entry quietly stopped denying what it named.
A boundary that does not parse now admits nothing, and
ExternalAuthenticationOptionsValidator rejects the configuration at startup,
so the mistake reaches an operator rather than a token. Failing startup is
what makes the runtime behaviour safe to be strict about: it cannot be hit by
someone mid-edit, only by validation having been bypassed.
ConnectionEndpointSupport.HasPermission was a sixth ad-hoc permission check,
missed when the other five were converted. It compared claim values against
the legacy ExternalAuthenticationPermissions constants at four call sites --
policy management on create and update, session revocation, and unsafe
settings confirmation -- and those constants carry two colons, so nothing can
hold one once a deployment follows the migration guide. It now routes through
IPermissionEvaluator like the rest, resolved from the request with a fallback
to the shared evaluator, the same way EndpointSecurity does it.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style(external-auth): filter permission patterns with Where
Addresses a review nit on ValidatePermissionPatterns. Behaviour is unchanged:
a null list still iterates nothing, only malformed entries are reported, and
the message text is identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: apply the grant boundary to role permissions too
Token issuance concatenated the user's Elsa role permissions raw alongside
the boundary-filtered external grants. A permission the boundary had just
excluded during grant resolution therefore reappeared in the issued token
from the same roles, which made the deny list unenforceable for anything a
role carried and left ElsaRolePermissionGrantSource filtering nothing that
was not added back a moment later. The bypass did not even need that grant
source configured: role permissions reached the token regardless of which
sources a connection selected.
Both origins now pass the same boundary. Re-applying it at issuance also
picks up a boundary that changed since sign-in, since refreshing reissues.
This is a behaviour change for deployments that configured a boundary
expecting it to bound only claim-mapped permissions: an external login may
now carry fewer permissions than before. Deployments with no boundary
configured, the default, are unaffected -- every well-formed permission
passes. The migration guide describes both directions.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Runs one suite unchanged against every implementation of IUserTaskRepository,
IUserTaskGuestSessionIssuer, and IUserTaskInvitationOutbox, plus a fault-injection
suite driving the real DefaultUserTaskManager and DefaultUserTaskInvitationService
against a real store. Gated providers report as skipped with a reason rather than
passing vacuously; ConformanceCoverageTests fails when a provider that must run is
unreachable or its variable is set but empty.
The suite found three defects, fixed here:
- VNextUserTaskRepository supplied no index values for WorkflowDefinitionId,
WorkflowInstanceId, ActivityInstanceId, CreatedAt, or CompletedAt, all declared by
its own schema provider, so every write through the VNext provider threw.
- The same provider resolved invitation token hashes by scanning on Status alone,
which matched no declared index, so anonymous invitation verification always threw.
- EFCoreUserTaskInvitationOutbox persisted the delivery recipient but never read it
back, so durably queued invitations reached the dispatcher with no address.
Also switches new ADRs to date-prefixed identifiers and generates doc/adr/toc.md via
scripts/adr/generate-toc.sh, with a --check mode and pull-request workflow so the
index is never hand-edited again.
* fix(user-tasks): let managers revoke a consumed guest invitation
Verification marks the winning invitation Consumed, which is what issues the
guest session — but RevokeAsync rejected Consumed and never touched sessions at
all. A manager therefore could not withdraw a live guest credential: it stayed
authorized until its TTL elapsed or the task closed. The invitations contract
specifies a revocable, task-scoped session, so this was a real gap.
RevokeAsync now accepts a consumed invitation, rejecting only the already
terminal Revoked and Expired states, and revokes the sessions that invitation
issued. Revocation is scoped to one invitation rather than the whole task, so
other guests keep working: UserTaskGuestSession carries its InvitationId and
IUserTaskGuestSessionIssuer gains RevokeForInvitationAsync, implemented for both
the in-memory and EF Core stores.
Reassignment already cut a guest off, because the policy requires the guest to
still be the assignee. That remains the recovery path for abandoned guest work;
this restores the documented direct revocation alongside it.
Adds three tests. The first fails against the previous behavior.
Reported by Greptile on #7955.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(user-tasks): make guest-session revocation fail closed and retryable
Greptile review of the previous commit found three real problems with it.
Revocation committed the invitation as Revoked before revoking its sessions, so
a session-store failure left a live credential behind a guard that rejected the
retry. Sessions are now swept before the terminal state is committed: a failure
commits nothing, leaves the invitation revocable, and a retry repairs it. A
retry against an already-revoked invitation is idempotently successful and
re-runs the sweep, so a caller repairing a partial failure is never told no.
Verification could also hand back a credential that outlived a concurrent
revoke: the manager's sweep ran before the session reached the store and found
nothing. VerifyAsync now re-reads the committed invitation after issuing and
withdraws the credential unless it is still the consumed one it verified.
Invitation-scoped revocation queried an unindexed column, so every revoke
scanned a growing tenant partition of retained session rows. Adds the
(TenantId, InvitationId) index to the EF model and migration, and advertises the
same index from the VNext schema provider.
Adds three tests covering the injected store failure, the idempotent retry, and
the revoke-during-verify race. RevokingAnAlreadyRevokedInvitationIsRefused
asserted the behavior this commit deliberately changes, so it is repurposed to
cover the refusal that remains: an unknown invitation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(user-tasks): sweep guest sessions on both sides of the revoke commit
Moving the sweep before the commit closed the fail-open failure path but opened
its mirror: a concurrent verification can issue a session after the sweep, still
read Consumed at its settled-state check because the revoke has not committed
yet, and hand back a credential that outlives a successful revoke.
Revocation now sweeps after the commit as well. Anything issued in that window
is caught by the second sweep, and any verification that issues after the commit
sees the revoked state at its own settled-state check and withdraws its own
credential. The first sweep still runs before the commit, so a session-store
failure commits nothing and stays retryable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds durable, identity-neutral, workflow-bound human tasks, and reconciles the
REST surface with the approved Studio contract.
- Flat summary/detail DTOs, a global capability descriptor, and workflow context
captured at activation.
- Scope is part of the list authorization predicate; manager decisions require
manage:user-tasks; a denied command answers 404 so it cannot prove a task exists.
- Guest sessions are task-scoped, action-allowlisted, and revoked when the task
closes. Invitations resolve by token hash through the repository, wait in a
Data Protection encrypted outbox, and are rate limited per caller.
- Masked form values are disclosed only through an audited reveal command.
- Store-specific concurrency failures are translated into a single
UserTaskRevisionConflictException, so a concurrent edit returns the documented
revision-conflict result behind any provider instead of a 500.
EF Core (SQLite, SQL Server, PostgreSQL, MySQL, Oracle) and VNext persistence,
hosted due/reconciliation/delivery workers, docs, and 49 tests.
Note: this branch also carries two commits inherited from its branch point that
are not part of User Tasks and are squashed in here — the revert-version
allocation change from #7917 (WorkflowDefinitionPublisher.RevertVersionAsync now
allocates from the last version rather than the latest) and an NU1903 package
pin. Merged deliberately rather than rebased out.
* feat(auth): add the permission model and evaluator (Phase 1)
Additive only. Nothing changes behavior: no endpoint declares against this
yet, and no existing enforcement path routes through it.
A permission is {resource}:{verb}, both axes open and string-keyed. A
trailing wildcard on the resource axis matches the named node and every
descendant at any depth, so workflows/definitions/* covers
workflows/definitions itself; * on the verb axis matches any verb.
Wildcards are the only construct with forward reach.
A bare * parses to *:* at parse time rather than being special-cased in
the evaluator, so superuser stays an ordinary grant and a stored or seeded
* keeps authorizing across the vocabulary migration without a lock-out
window.
Adds:
- Permission, with parsing that rejects a value containing a comma, since
the persistence converter joins collections with one
- CoreVerbs, the recommended set modules should reuse; a convention rather
than a closed vocabulary
- PermissionMatcher, one matching rule shape on both axes
- IPermissionEvaluator, the single place permission decisions are made,
skipping malformed claims so one bad stored grant cannot deny a principal
- PermissionRequirement and PermissionAuthorizationHandler
- The descriptor catalog in core: PermissionDescriptor now carries the
verbs a resource supports and marks non-core ones, and the registry can
report what a wildcard covers today
External Authentication keeps its own descriptor types for now; it moves to
the core catalog with the other modules in Phase 2, which keeps this change
purely additive.
55 unit tests cover the matcher table, wildcard forward reach, the
counterpart that concrete grants stay frozen, absence-is-denial, and the
seeded * case.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(auth): contribute the permission catalog from every module (Phase 2)
Still additive. Existing endpoints keep their legacy declarations; nothing
changes behavior for them.
Every module exposing protected endpoints now declares its resources and
the verbs each accepts, following the pattern already proven in External
Authentication -- constants and descriptors colocated -- refined to one
constant per resource, with the verb supplied separately. 47 resources
across 15 modules, matching the settled vocabulary.
Descriptors are discovered from the same assemblies as a module's
endpoints, in AddFastEndpointsFromModule. Registering them per module
would let the catalog and the endpoints drift, which is the failure this
model exists to remove; tying them to one registration makes the catalog
necessarily describe the endpoints that exist.
Adds:
- GET /identity/permissions, the catalog a role editor renders from, so
no client hard-codes permission strings
- GET /identity/permissions/reach, reporting what a wildcard covers today.
This is the mitigation for forward reach on the resource axis: a
wildcard is useful precisely because it covers things that do not exist
yet, so an author needs to see what it reaches now
- GET /identity/me/permissions, resolving wildcards to concrete verbs so a
client needs no matching logic, and listing denied resources with an
empty verb list so "denied" is distinguishable from "unknown"
- IPermissionGrantValidator, wired into Roles/Create and Roles/Update,
which previously persisted request.Permissions after only the
caller-subset check. Concrete segments validate against the catalog;
wildcards validate structurally and are accepted even when they match
nothing today, since installing a module later is what gives such a
grant meaning
- RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the
endpoint base classes, with the six copy-pasted ConfigurePermissions
bodies collapsed into one implementation
New endpoints require new-format grants, so during the transition they
authorize only for holders of *, which parses to *:*. Phase 3 migrates the
rest and closes that gap.
70 unit tests, including the wildcard-accepting validator cases.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(auth)!: cut every endpoint over to the permission model (Phase 3)
BREAKING: legacy permission strings no longer authorize. A permanent alias
layer would keep two vocabularies valid forever, so the break is
deliberate and reported rather than absorbed. `*` survives unchanged --
it parses to `*:*` -- so an administrator cannot be locked out while
roles are re-authored.
All 168 declaration call sites across 151 files now use
RequirePermission(resource, verb) with the constants their module
declares, so a typo is a compile error rather than an unreachable
endpoint.
Enforcement consolidated onto IPermissionEvaluator:
- RoleAuthorizationService evaluates containment through the evaluator
rather than by set membership. This matters: a caller holding
workflows/*:view can now delegate workflows/definitions:view, which set
membership got wrong and which would otherwise force administrators to
hold every concrete grant they wish to delegate.
- The two Broker/Logout.cs endpoints declare explicitly. Logout is
authenticated-only; ContinueLogout is anonymous, matching every other
broker callback -- the route handle carries the authority and a
top-level browser navigation sends no Authorization header.
Removes the C#/Python expression permissions (#7975). They conflated an
incoherent execution-side gate -- a workflow runs under the server's
authority, not the caller's, so the check never constrained what a script
could do -- with a meaningful authoring-side one. The host switch
(AllowHostCodeExecution) becomes the single control. This is a deliberate
reduction in control: where host code is enabled, any author who may write
definitions may use C# and Python.
Adds the fail-closed gate. Omitting a declaration previously inherited the
FastEndpoints default with no Elsa-level fallback, so an endpoint could
ship ungated unnoticed. EndpointCoverage asserts every endpoint declares
exactly one of RequirePermission, RequireAuthenticatedOnly or
AllowAnonymous, with no exemption list. Its canary assertion earned its
keep immediately by catching that the gate was scanning an assembly
containing no endpoints.
EndpointPermissionRegistry records what each endpoint declares. The
requirement is attached as an inline policy and is not readable back from
the definition, so this keeps the declaration introspectable -- and lets
tests assert a specific requirement rather than merely that one exists.
Two behavior notes worth calling out:
- The runtime status endpoint previously accepted either the read or the
manage permission. It now requires workflows/runtime:view alone, which
is least privilege; a role holding only control must also be granted
view to read status.
- BPMN interchange repeats the workflow-definitions path locally rather
than taking a dependency on Elsa.Workflows.Api for one constant. It
contributes no descriptor: the resource is owned and described by
Workflows.Api, and the registry keeps one entry per resource.
Also adds a startup validator that logs every stored role permission that
no longer resolves, identified by role, so an upgrade is loud.
188 unit tests pass across Api.Common, Workflows.Api and Identity.
Refs #7974, #7975, #7976
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(auth): revocation bound and role audit notifications (Phase 4)
Default access-token lifetime drops from 1 hour to 15 minutes. This is
the revocation bound: permission claims are issued at sign-in and refresh
re-reads the user's roles, so removing a role takes effect at most one
access-token lifetime later. Refresh already rotates both tokens, so no
client change is required and the refresh lifetime is unchanged.
Adds an optional permission stamp for deployments needing a tighter
bound. The stamp is derived from the user's roles and their permissions
rather than stored as a counter on the user. That avoids changing the
Identity schema, which would have required migrations across all five EF
providers and made this milestone depend on the tenancy work. It also
means every node computes the same value from the same store with no
cross-node cache invalidation, which matters because Elsa has none.
The stamp is issued unconditionally and only validated when enabled, so
turning it on does not invalidate tokens already in flight; an absent
stamp is not treated as a mismatch for the same reason. It changes when a
role is added to or removed from the user and when a held role's
permissions change, but not when an unrelated role changes.
Role create and update now publish typed security notifications per ADR
0007, carrying the resulting grants so a reviewer can reconstruct what a
role conferred at a point in time without replaying every prior event.
This module owns no audit store: a future audit module subscribes and
sets its own retention.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(auth): tenancy hardening for identity (Phase 5)
Closes the gaps that made "roles are configurable per tenant" untrue
however the rest of the stack behaved.
Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and
Application.ClientId carried globally unique indexes, so two tenants could
not both hold a role named Admin. Migrations for all five EF providers
drop the global indexes and create composite ones on (TenantId, Name).
The in-memory user and role stores now scope to the ambient tenant.
Isolation previously existed only on the Entity Framework path, and only
when multitenancy was enabled, so a deployment running the default stores
had none at all. The tenant-agnostic sentinel is honored, matching the EF
query filter, so a shared platform role stays visible from every tenant.
RoleFilter gains TenantId, matching UserFilter, and the role and user list
endpoints pass it explicitly rather than relying on an ambient filter that
only exists on one persistence path.
UserManager.CreateUserAsync sets TenantId explicitly instead of relying on
the EF saving handler, which does not run in memory and left users
unassigned there.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: migration guide, ADR, and security wiki for the authorization model
Adds docs/migrations/authorization-model.md, following the shape of the
external-authentication persistence guide. It leads with the three things
that are not a simple rename, because each silently produces a wrong
result if treated as one:
- The migration expands where new sub-resources are finer-grained than
what they replace, so a one-for-one substitution narrows roles.
- read:* and exec:* become materially more powerful. They are literal
claim values today, authorizing twelve of roughly forty read endpoints;
their replacements work as the names always implied. Any role holding
them needs review by hand, not an automated rewrite.
- The C#/Python expression permissions are removed rather than
translated, which is a deliberate reduction in control where host code
is enabled.
It also states plainly that `*` keeps working, and says to do that first,
since it is what stops an instance locking itself out mid-migration.
ADR 0012 records the model and, more usefully, why a closed verb
enumeration was drafted and rejected: it was justified on implication, but
aggregates were already excluded and no verb implies another, so the
bitwise check was expressing set containment all along.
The security wiki's API Authorization section replaces its Secrets-only
route table with the catalog endpoint as the authoritative source, and
states why read-only mode is a separate axis rather than a permission.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(auth): restore the suites after the tenancy and evaluator changes
The whole solution builds with zero errors and every affected suite
passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External
Authentication unit, 133 External Authentication integration.
Most breakage was test call sites constructing the tenant-aware stores
and the evaluator-backed RoleAuthorizationService directly. Adds
TestTenantAccessor to Elsa.Testing.Shared rather than giving the
production constructors an optional accessor, which would have let a
missing registration silently disable isolation.
Several External Authentication tests created fixtures in tenant-a while
running under the default tenant, so the newly isolating store correctly
stopped finding them. They are now scoped to the tenant their own
fixtures use; JustInTimeProvisioningTests, which genuinely spans two
tenants, is scoped per case.
One production fix came out of it: IdentityFeature now ensures an
ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a
host that never enables multitenancy would otherwise fail to construct
them -- which is what the DI registration tests were reporting. TryAdd
leaves MultitenancyFeature's own registration untouched.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): register the identity services on the classic feature path
Found by running Elsa.Server.Web, not by the test suites: the app failed
at startup with "Unable to resolve service for type RoleSecurityNotifier
while attempting to activate Roles.Update".
RoleSecurityNotifier, the permission stamp services, the memory cache and
the stored-permission validator were registered only in the CShells shell
feature. Elsa.Server.Web uses the classic UseIdentity() path, whose
IdentityFeature registered none of them, so every host on that path
crashed while mapping endpoints. Unit tests did not catch it because they
construct services directly rather than through either feature.
Verified end to end against the running server:
- The seeded admin role stores "*". It parsed to *:* and resolved to
concrete verbs across all 27 registered resources, which is the
bare-wildcard parse rule working on real data rather than in a test.
- GET /identity/permissions returns the catalog for the modules this app
installs -- 27 resources, 0 unverified, categories Dashboard, Identity,
Resilience and Workflows -- rather than all 47, which is correct: the
catalog describes what is installed.
- GET /identity/permissions/reach?resource=workflows/* reports 19 covered
resources.
- A role holding only dashboard:view gets 200 on /dashboard/overview and
403 on /identity/roles, /identity/users, /workflow-definitions and
/identity/permissions, while /identity/me/permissions returns 200
because it declares RequireAuthenticatedOnly -- confirming FR-019's
third declaration state behaves as designed.
- That same principal's /me/permissions lists all 27 resources with 26
carrying an empty verb list, so "denied" stays distinguishable from
"unknown to this server".
- The startup validator logged no unresolvable permissions, as expected
for a seed holding only "*".
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): discover permission descriptors on the shell host path
Found by running Elsa.ModularServer.Web. The shell host started cleanly
and authorized correctly, but GET /identity/permissions returned zero
resources and /identity/me/permissions returned no grants.
Descriptor discovery was wired into AddFastEndpointsFromModule, which only
the classic module path calls. CShells discovers endpoints from features
implementing its own marker interface, so on a shell host no provider was
ever registered. Authorization still worked, because the evaluator reads
claims and needs no descriptors -- which is exactly why nothing failed
loudly. What silently broke was everything built on the catalog: role
authoring would have rejected every concrete grant as an unknown
resource, introspection returned nothing for clients to render, and the
stored-permission validator would have reported every concrete stored
permission as unresolvable.
ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa
assemblies, bounded to those and run once per shell.
Verified on the modular host, which installs far more modules than
Elsa.Server.Web:
- 47 resources registered, 0 unverified, across all 12 categories, with
all 17 module-specific verbs present. That is the entire published
vocabulary confirmed against a running server rather than a document.
- Reach reports workflows/* covering 20, external-authentication/*
covering 8, and * covering 47.
- Creating a role with dashboard:view and workflows/*:view succeeds,
confirming a wildcard grant survives authoring validation.
- Creating one with invented/resource:view and secrets:publish is
rejected with 400.
Also makes those rejections actionable. The permission was reported
without the reason, so an operator learned which entry was wrong but not
why; both parts are now in the message, including the supported verbs for
the resource.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* wip: bpmn test vocabulary
* fix(auth): make enforcement DI-independent and finish the hub cutover
CI on #7980 was red. Running the full suite locally rather than the
subset I had been checking surfaced 24 failures across four projects,
in three distinct classes.
Enforcement no longer depends on a DI registration. RequirePermission
attached a PermissionRequirement evaluated by a registered handler, so a
host that had not called AddElsaAuthorization got 403 on every endpoint
with nothing to indicate why. Several test hosts wire FastEndpoints
directly and did exactly that. The requirement is now evaluated inline
against a shared stateless evaluator, with a host-registered
IPermissionEvaluator still taking precedence. Registration remains
worthwhile for the catalog and the validator; authorization can no longer
silently fail closed because of a missing one.
Registration also moved from AddFastEndpointsFromModule to
AddFastEndpointsAssembly. Registering an endpoint assembly is what should
guarantee its permissions work, and a host may never call the former.
Finishes T039. The four SignalR hubs still matched hard-coded legacy
permission strings, which no longer exist, so every hub denied access.
They now route through the evaluator like every other enforcement path.
Test fixtures granting legacy strings were updated to the new vocabulary.
Two categories were deliberately left alone: naming tests asserting the
legacy constants still hold their old values, which is true and worth
keeping, and the workflow script authorization tests, which asserted a
MissingPermission outcome that D21 removed -- those now assert the host
switch is the only control.
One test previously pinned that the hub honors a FastEndpoints-configured
permissions claim type. It now asserts the opposite, and says why: Elsa is
the only authority that expands roles into permission claims (ADR 0009),
and this model no longer uses the FastEndpoints permission mechanism, so
its separately configurable claim type is not consulted. That property is
also unreadable outside reflection.
Whole solution builds with 0 errors and every test project passes.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): scope the permission-stamp cache to the tenant
Greptile found and reproduced a cross-tenant authorization bug, and it was
mine: Phase 5 made user names unique per tenant rather than globally, but
PermissionStampValidator kept caching by user name alone. Tenant A's
lookup could therefore populate the cache with its own stamp and satisfy a
revoked token belonging to a same-named user in tenant B, without ever
resolving tenant B's user.
Both the cache key and the user lookup are now tenant-scoped. Added
PermissionStampValidatorTests, including the cross-tenant case; verified it
fails without the fix and passes with it.
Also from review:
- Removed the legacy permission constants left unused in the three hubs
after they moved to the evaluator, so no stale vocabulary lingers.
- Narrowed two generic catch clauses. The IL scanner now catches only the
exceptions an unresolvable metadata token actually throws, and the
startup validator rethrows cancellation while still refusing to stop the
host for anything else -- an unreachable or half-migrated store is
exactly when an operator most needs the host up.
Whole solution builds with 0 errors and every test project passes.
Refs #7974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(docs): correct path in log message for authorization model migration link
Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output.
* fix(auth): update permissions method to use new syntax
* docs: consolidate docs/ into doc/
The repository had two documentation roots. Merge docs/ into doc/ and
remove the empty docs/ tree.
The two adr/ folders both numbered from 0001, so the identity and
authorization series is renumbered to continue the core series rather
than collide with it:
docs/adr/0001-0012 -> doc/adr/0014-0025
Every reference is updated to match: the Status cross-links between the
renumbered ADRs, the ADR and path links in specs/012-external-authentication
and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md.
doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out
to 25; it had been stale since ADR 10 and now also carries the partial
supersession edges declared by the ADRs themselves.
docs/codebase/ and docs/migrations/ move across unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(auth): simplify syntax in PermissionEvaluator and related classes
Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability.
* ci(bounty): point the footer step at the file's real path
The bounty workflow read docs/bounty-footer.md, the path the file had
when the workflow was added in b421b00e1. The file later moved to
doc/bounty/bounty-footer.md and the workflow was never updated, so the
read step has been resolving nothing and the appended comment was empty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ContinueLogout was unreachable and, once reached, produced no response.
Two independent defects, both of which had to be fixed for the endpoint
to work at all.
Authorization. The endpoint declared neither a permission nor
AllowAnonymous, so it inherited the FastEndpoints default and required an
authenticated caller. It cannot ever satisfy that: the broker revokes the
session before issuing the continuation handle, and the endpoint is
reached by a top-level browser navigation, which sends no Authorization
header -- the only transport Elsa authentication uses. Every other broker
endpoint the browser is navigated to is already AllowAnonymous for the
same reason. The single-use, hashed route handle carries the authority.
Response. The handler wrote to HttpContext.Response directly rather than
through the Send API, so the response never started and the FastEndpoints
auto-response overwrote the status with 204. Both branches were affected:
the redirect to the provider's end-session endpoint and the 400 for an
unknown handle were each discarded, so a caller received 204 No Content
either way. Now mirrors CompleteLogout, using Send.RedirectAsync and
BrokerEndpointSupport.SendErrorAsync.
Logout, in the same file, also relies on an inherited default, but there
the default is correct: it reads the external session id from the
principal, so it needs an identity and no permission. Left as-is with a
comment; an explicit authenticated-only declaration arrives with the
authorization model work.
Adds LogoutAuthorizationTests, which builds a host with authorization
enforced and no principal injected -- the existing broker fixture injects
one and disables endpoint security, so it could not catch either defect.
Verified failing before the change (401, then 204) and passing after.
Fixes#7976
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Backport to release/3.8.0 so Elsa.Api.Client 3.8.0-rc2 exposes the
Resources/OutputConverters surface that Elsa Studio's release/3.8.0 branch
already consumes. Without it, Studio cannot build against a released client:
it was green against 3.8.0-preview.5397 (built from main) and broke when its
pin moved to 3.8.0-rc1 (built from this branch).
(cherry picked from commit d698e6b005)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dotnet test test/unit/Elsa.Resilience.Core.UnitTests` exited 1 on a clean
checkout with all 56 tests passing. The failure was the coverlet gate, not a
test: the project pinned `<Threshold>49</Threshold>` against 48.17% measured
line coverage in Debug. Release measures slightly differently and cleared it,
so CI (which builds `--configuration Release`) stayed green while every local
run — Debug is the default — went red. A red exit for a suite that passes
trains people to ignore exit codes.
Rather than move the goalposts, cover the code. The gap was concentrated in
`ResilientActivityInvoker`, which had no tests at all, plus the serializer,
the activity-execution extensions and the retry telemetry listener.
`Elsa.Testing.Shared`'s `ActivityTestFixture` was already referenced here and
builds a real `ActivityExecutionContext`, which is what all of them needed.
Adds 40 tests. The invoker ones drive a real zero-delay Polly retry pipeline,
so the telemetry listener is exercised through the actual Polly path rather
than being called directly: pass-through when no strategy is configured, the
applied strategy recorded on the context, retry-then-succeed, one record per
retry carrying identifiers and details, null details dropped, the retries flag
and attempt count, exhausted retries rethrowing, and an unhandled exception
type not being retried. The extensions tests build a three-level context chain
to pin down that the retries flag propagates up the ancestor chain and not
down.
Line coverage goes 48.17% -> 98.17% in Debug and 97.8% in Release; the five
lines still uncovered are defensive early-returns. The threshold moves to 90,
below the lower of the two configurations with enough headroom that the
Debug/Release delta cannot straddle it again. Verified by deleting the invoker
tests once: coverage falls to 68.97% and the gate fails as it should.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Bpmn.Interchange, Bpmn.Model and Bpmn.Semantics move from 0.1.1-preview.19 to
0.2.0. The three changes below are one unit: the bump is what makes the other
two true.
Retire the private feed. All three packages are on nuget.org at 0.2.0 --
including Bpmn.Semantics, which had no stable release under 0.1.x. The
bpmn-feedz source and its Bpmn.* packageSourceMapping entry both go, which
removes a setup step for every consumer. This is not merely cleanup: the feedz
feed does not carry 0.2.0 stable, so with the mapping left in place the bump
would not restore at all. Restore now resolves Bpmn.* from nuget.org via the
existing `*` mapping.
Lift IsPackable=false. The comment on the flag named its own removal condition
-- the Bpmn.* packages reaching nuget.org -- and that condition is now met, so
Elsa.Bpmn and Elsa.Bpmn.Interchange begin shipping. Both pack with every
dependency publicly restorable, and both emit a package manifest carrying
runtimeKinds ["elsa.server"], so neither is silently excluded from the catalog.
They were the last two IsPackable=false projects under src/.
Flip the compensation pin to the fixed behaviour. 0.2.0 contains the fix for
valence-works/bpmn#13 (filed from here as #7959), so
CompensationRunCancelledMidReplay went red on the bump exactly as it was built
to. Upstream took the wide fix: every token a cancelled transaction abandons now
gets a real teardown. The head handler still starts twice -- that is the release
being real -- but the first run is now torn down, so the scope is left holding
one live record for the slot instead of two. The applier is deliberately
unpatched; the assertions moved to describe the fix, not to accommodate it.
Note on persisted state: 0.2.0 freezes the payload format at 1.0.0 and state
persisted by 0.1.x no longer deserializes. Elsa persists the library's
BpmnExecutionState into workflow state, so an in-flight BPMN instance does not
survive this bump. Neither module has ever been published, so no released
consumer can be holding such state -- which is why this is the moment to take
the break.
BpmnRuntimeCapabilitiesTests stays green: 0.2.0 defines no capability flag Elsa
does not already declare. The new cancel-end-event requirement is
SubtreeCancellation, which BpmnRuntimeCapabilities.Declared already carries.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix: stop two silent serialization and test-isolation traps
Two follow-ups from #7957.
ExternalAuthentication tests: the same process-global
EndpointSecurityOptions.SecurityIsEnabled race the shells API tests had,
across the six classes in that assembly that build an endpoint host —
five setting it to false and IdentityLinkAuthorizationTests to true.
Unlike the shells case these all call UseAuthorization(), so it does not
surface as a missing-middleware error: anonymous endpoints answer
401/403, and the authorization test's endpoints come back AllowAnonymous
and stop enforcing what it asserts. A module initializer cannot fix it
since the assembly genuinely needs both values, so the six now share one
collection with DisableParallelization. They are also the only six that
build a host, so nothing else can observe a leaked value.
Unaliased payloads: a payload whose type has no registered serialization
alias is written without a _type discriminator and read back as an
ExpandoObject whose keys carry the state serializer's camel-case naming
policy, so a consumer that published Status finds status. The
degradation is deliberate — the alias registry is an allow-list that
keeps arbitrary CLR type names out of deserialization — but it was
silent. It is now reported once per type, naming the type and both
lossless alternatives, and PublishEvent.Payload documents them. Measured
across the integration suite, only genuine user payload types reach this
path, so the warning does not fire for Elsa's own types.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: check the log level before claiming the once-per-type warning slot
WarnAboutUnaliasedType claimed a type's single report via TryAdd before
LogWarning applied its level filter, so a type first serialized while
Warning was disabled spent its slot on a call that logged nothing and
then stayed silent forever, including after the level was raised at
runtime. Check IsEnabled first, so the slot is only consumed by a report
that is actually emitted.
The regression test needs the capture to be the only logging provider:
IsEnabled on the composite logger is an OR across providers, so the test
builder's own xunit provider would otherwise keep Warning enabled
regardless of what the test asked for.
Reported by Greptile on #7969.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The two-record assertion in CompensationRunCancelledMidReplay pins a bug whose
root cause is in Bpmn.Semantics, not in this host, so the tracker that will
actually move is valence-works/bpmn#13. #7959 stays named as provenance -- it
carries the decompiled evidence and the routing rationale -- but is closed as
routed upstream, so it is no longer the thing to watch.
Also records what the current comment did not: both counts become 1 when the fix
lands, and the flip may not be mechanical. Upstream is choosing between tearing
down only the compensation handler it re-starts and tearing down every abandoned
token, and the second also cancels transaction branches still in flight, which
can move other scenarios in this suite.
Comment-only. The assertions and the applier are deliberately unchanged.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Module.Apply() enumerated _features.Values directly while calling
feature.Apply(). A feature whose Apply() introduces another feature —
Module.Configure<T>() directly, or via a helper such as AddActivity<T>()
which configures WorkflowManagementFeature — mutated that collection
mid-enumeration and threw "Collection was modified; enumeration
operation may not execute", naming nothing about features. Whether it
fired depended on whether the other feature happened to be installed
already, so a module built or did not based on unrelated host config.
The module already treats introduction-during-apply as supported: the
ConfigureFeature loop iterates a snapshot for exactly this reason, and
Configure<T>() has an _isApplying branch that creates, resolves and
configures a feature introduced mid-Apply. Only the final apply loop
missed the same treatment, so make it tolerant rather than diagnose a
constraint the code does not hold.
The apply loop now runs in rounds until no new features appear, each
round topologically sorted so a late feature's dependencies apply before
it. Hosted services are registered in a single pass after that loop,
then moved back to the index the block previously occupied: registering
late is needed so features contributed during Apply() are included and
ordered by priority, while keeping the position matters because features
register hosted services directly from Apply() — WorkflowRuntimeFeature
adds DrainOrchestratorHostedService that way — and module-managed
services must keep starting first, or a priority such as ActivateTenants
at -1 would silently start ordering after them.
Adds Elsa.Features.UnitTests, covering the introduced feature applying,
a three-deep introduction chain, dependency ordering, hosted service
registration and priority ordering for late arrivals, the installed-
feature registry, and no double-apply, plus guards for pre-existing
ordering behaviour.
Closes#7944
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both tests read a value that is usually one thing and occasionally
another, with a race deciding which.
ReloadTests: EndpointSecurityOptions.SecurityIsEnabled is a process-
global static, and ShellsApiTestBase saved/set/restored it per test
method. ReloadTests and ReloadAllTests carry no [Collection], so xUnit
runs them in parallel. FastEndpoints reads that global once per host
while UseFastEndpoints() configures the endpoints, so when one class's
DisposeAsync restores true inside another class's set-false ->
UseFastEndpoints() window, that host's endpoints get authorization
metadata in a pipeline with no UseAuthorization, and every request to
them throws. Every test in the assembly wants security off, so set it
once in a module initializer and stop mutating it per test.
PublishEvent_WithPayload_TransmitsPayloadToConsumer: the payload's
representation is not stable. While it is still the original CLR object
its properties are PascalCase; once it has been through
JsonWorkflowStateSerializer it is an ExpandoObject whose keys were
camelCased by that serializer's naming policy. Which one the test sees
depends on whether GetSingleWorkflowInstanceAsync returned the live
in-memory instance or one read back from the store, and TryGetProperty
is case-sensitive. Assert the payload's content through a DTO with
PropertyNameCaseInsensitive instead of one of the two representations.
Also require a terminal instance at both exits of
GetSingleWorkflowInstanceAsync: it accepted any save, and an instance is
saved several times over its lifetime, so it could hand a caller that
asserts Finished an instance that is still running.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A container that schedules a child and then decides the child must not run
had no way to withdraw it. `IActivityScheduler` exposed no removal operation,
and `CancelActivityAsync` no-opped on a context whose status was `Pending`,
so a container could tear a branch down and still have an activity from that
branch execute afterwards, side effects and all. Fixes#7943.
- `IActivityScheduler.RemoveWhere` removes work items and keeps the order the
survivors would have been taken in; implemented in both the FIFO and LIFO
schedulers.
- `CancelActivityAsync` (both the public extension and the internal one used
when a container completes) cancels `Pending` contexts as well as running
ones, and withdraws the work item that would have started the cancelled
activity plus the items it had scheduled for children with no context yet.
Withdrawal is a real removal rather than a terminal status honoured at dequeue
time, because the scheduler is also read: `Flowchart.HasPendingWork` inspects
it to decide whether it may complete, and the work item list is extracted into
the persisted workflow state — a withdrawn-but-queued item would be persisted
and rehydrated with a fresh context after a suspend/resume.
`StateMachine` had hand-rolled the same operation to drop competing triggers by
clearing the scheduler and re-scheduling everything else; it now calls
`RemoveWhere`. `Elsa.Bpmn` no longer needs to refuse a teardown whose subtree
still has queued work, so `BpmnWorkTeardown` drops the `NotSupportedException`
and records the teardown reason on the torn-down activity's journal instead.
BREAKING: `IActivityScheduler` gains a member; external implementations must
add `RemoveWhere`.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
BpmnTestProcesses had grown to 905 lines as one flat static class holding the
fixtures for every BPMN construct family the runtime slice covers, and two
standards reviews flagged it as Divergent Change while judging the split out of
scope for the issue in hand.
It becomes a partial class across six sibling files, one per family -- boundary
events, compensation and transactions, event subprocesses, flow and gateways,
multi-instance -- with the shared element factories (Timer, Cancel, Compensation,
CompensationBoundary, Escalation, Error, Message, EventSubprocess,
EventSubprocessStart) and the Scope/Immediate/Blocking/Faulting builders left in
one place, so no new file duplicates them. Partial rather than separate types
because every call site says BpmnTestProcesses.X and none of them change.
Pure move: all 47 members were carved out programmatically and diffed back
against HEAD, each present exactly once and byte-identical. In particular
EscalationOutOfSubprocess keeps its leading subFirst work item and the comment
explaining why the nested scope's handle counter must run ahead of its parent's.
The identical Compensation/CompensationBoundary helpers in
Elsa.Bpmn.Interchange.IntegrationTests are deliberately left duplicated: the only
assembly both test projects can see is Elsa.Testing.Shared.Integration, which
ships as a NuGet package, so sharing ten lines of test helper would mean adding
an Elsa.Bpmn reference to a published package's dependency graph.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): event subprocesses, dormant and listener-backed (#7933)
Both flavours, and the three responsibilities the host has for them. The
production diff is empty: the applier and binder need no event-subprocess
code path, which is what #7909 measured and what this pins.
A dormant catcher -- error or escalation -- rides the FaultSignal seam and the
escalation signal path, and reaches the host as an ordinary StartWork for its
body. A listener-backed one gets a second StartWork for its listenerBindingRef
at scope start and a CancelWorkSubtree for it when the scope completes. Both
already apply like any other command.
The host responsibilities, each with the failure it would otherwise hide:
- The listener is armed at scope start and observable in the scope's own ledger
before anything fires, not inferred from a fire that worked.
- A completing scope retires a still-armed listener. Pinned twice: at the root,
where Elsa's own container completion would cancel the child regardless and
only the scope's ledger tells the two apart, and inside a subprocess the
workflow outlives, where a listener left behind is one something could still
resume into.
- The start-element hint reaches the body and nothing else inherits it. The
body's only start event is event-defined, so a body that never received the
hint faults bpmn.start.none-available rather than starting somewhere
plausible; the ordinary subprocess inside it faults bpmn.start.unresolved-hint
if the hint travels where it must not. The scope's invocation correlation is
read back after the body has run work of its own, because the dictionary is
fixed for the scope's lifetime and the hint is read from it.
- A non-interrupting listener fired twice re-arms onto the slot the first fire
vacated, holding one live record and one bookmark at a time. This is the case
the completed-work-removed-before-the-interpreter-is-asked ordering exists
for, and it is now observable; BpmnHostInvariantTests points at it.
The library's declaration rules are pinned as refusals rather than gaps: a body
with more than one start event, a second error-triggered event subprocess in a
scope, and a non-interrupting error event subprocess are each refused when the
scope builds its graph, before any work starts. The last is additionally
dropped at import, with the rest of the document reading as written -- the
dropped body carries an undeclared serviceTask, so an import that still
succeeds is what proves the drop took its bindings with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): pin the catch-all escalation event subprocess refusal
Two error-triggered event subprocesses per scope was pinned but its sibling
rule -- at most one code-less catch-all escalation event subprocess -- had
no test. Add TwoCatchAllEscalationEventSubprocesses_AreRefused, mirroring
the error refusal test, and let Escalation() build a code-less definition.
Also record in BpmnCommandApplier why CancelSubtreeAsync's explicit
subtree cancellation is redundant on the scope-completion path (Elsa's own
container-completion behaviour already covers it) while the ledger removal
above it is not, so a future reader does not "simplify" the ledger removal
away.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): cover compensation, targeted replay and transaction cancellation
W15 turns on compensation boundary events, reverse-order replay, targeted
compensation, transaction subprocesses and cancel end/boundary events. The
measured claim in the design holds: the command applier needs no changes, and
none were made. A compensation handler arrives as an ordinary StartWork
carrying cause=compensation, and the binder already binds it because the reader
emits an ordinary Primary binding for an isForCompensation element.
What is new is the test mass that says so, and each case pins a failure that
otherwise looks like success:
- three registrations replayed in reverse, asserted as an ordered log rather
than as "all three ran"
- a compensate throw naming an activityRef, where the two unselected handlers
are bound work that must stay unrun -- which is also where "a handler is
never scheduled from flow" becomes observable
- a compensation run torn down mid-replay by a cancel end event, so its claimed
but unrun entry is released back to registered and the cancellation's own
replay reaches it; leaving it claimed would cancel with nothing to compensate
and finish looking healthy
- a transaction completing Cancelled with no cancel boundary to route it, which
must fault rather than take the ordinary sequence flow
- two compensation logs, one per scope, in a subprocess and around it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): pin the duplicate live-work record a cancelled transaction leaves
CompensationRunCancelledMidReplay asserted only log counts and Finished, so
the two live ledger records/bookmarks it produces for the releaseSeat slot
went unasserted. Add an explicit assertion on the scope's ledger, and correct
the comment that framed the second handler start as evidence only of the
release working, when it is also the symptom of the interpreter defect
tracked in #7959.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bpmn): refuse to publish a definition with an unbound BPMN task
BpmnWorkBinder already refuses an UnboundTask with no elsa:activityBinding
at import - that is the first net. A definition can be edited after
import, through Elsa's own designer rather than the BPMN document, and
that edit can remove the activity a task was bound to without touching
the document snapshot the scope still carries. ValidateBpmnProcessBindings
is the second net: a WorkflowDefinitionValidating handler that walks the
materialized workflow graph (not the inert BpmnProcessDefinition snapshot
or the stored BPMN source, both of which a graph-only edit leaves
untouched) and fails publication for any task-family element whose
binding no longer resolves to an activity in the graph, naming the
offending element id.
Import-time Dropped/Degraded findings from BpmnImportAnalysis are not
persisted anywhere a publish-time handler can reach, and BpmnImportIssue
carries no field distinguishing a Dropped finding that changes executable
meaning from one that does not; WorkflowValidationError has no severity
concept either. Extending this gate to those findings would mean guessing
at a classification the library does not expose, so it is left alone -
see the delivery notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): refuse to publish a BPMN task bound to a missing activity type
The publish gate only checked that a binding pointed at some activity id;
it did not check the activity actually resolved, so a binding left
pointing at an uninstalled type passed the gate and failed at run time
instead. Report that case distinctly from "not bound at all", name the
containing BpmnProcess node (not the BPMN element id) as the error's
ActivityId to match how the rest of the codebase reports it, and prove
the BpmnProcess-inside-Flowchart graph-walk with a dedicated test. Also
de-duplicate the publish-gate test fixture's binding helpers by deriving
from BpmnBindingTestBase instead of re-declaring them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(bpmn): filter the publish gate's loops explicitly
Replace the implicit-filter foreach/continue pattern in
ValidateBpmnProcessBindings with .OfType/.Where so each loop only
iterates the elements it acts on, without changing behaviour, error
messages, or error ordering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>