elsa-core/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs

970 lines
56 KiB
C#
Raw Permalink Normal View History

feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
using Elsa.Testing.Shared.Multitenancy;
using System.Data.Common;
2026-07-24 16:59:17 +00:00
using System.Text.Json;
using Elsa.Common;
using Elsa.Common.Services;
using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Persistence.EFCore;
using Elsa.ExternalAuthentication.Persistence.EFCore.Stores;
2026-07-24 16:59:17 +00:00
using Elsa.ExternalAuthentication.Services;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Providers;
using Elsa.Identity.Services;
using Elsa.Persistence.EFCore;
2026-07-24 16:59:17 +00:00
using Elsa.Workflows;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
2026-07-24 16:59:17 +00:00
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
2026-07-24 16:59:17 +00:00
namespace Elsa.ExternalAuthentication.IntegrationTests.Persistence;
public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
{
private SqliteConnection _connection = null!;
private ServiceProvider _services = null!;
private TestDbContextFactory _dbContextFactory = null!;
private ExternalAuthenticationDbContextLeaseFactory _leaseFactory = null!;
2026-07-24 16:59:17 +00:00
private ISystemClock _clock = null!;
private MemoryUserStore _userStore = null!;
private StoreBasedUserProvider _userProvider = null!;
2026-07-24 16:59:17 +00:00
public async Task InitializeAsync()
{
_connection = new SqliteConnection("Data Source=:memory:");
await _connection.OpenAsync();
_clock = new SystemClock();
var optionsBuilder = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>();
optionsBuilder.UseElsaDbContextOptions(null);
optionsBuilder.UseSqlite(_connection, sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName));
var options = optionsBuilder.Options;
2026-07-24 16:59:17 +00:00
_services = new ServiceCollection()
.AddSingleton<IDbContextFactory<ExternalAuthenticationElsaDbContext>>(serviceProvider => new TestDbContextFactory(options, serviceProvider))
2026-07-24 16:59:17 +00:00
.BuildServiceProvider();
_dbContextFactory = _services.GetRequiredService<IDbContextFactory<ExternalAuthenticationElsaDbContext>>() as TestDbContextFactory ?? throw new InvalidOperationException();
_leaseFactory = new ExternalAuthenticationDbContextLeaseFactory(_services.GetRequiredService<IServiceScopeFactory>());
feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
_userStore = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
_userProvider = new StoreBasedUserProvider(_userStore);
2026-07-24 16:59:17 +00:00
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _services.DisposeAsync();
await _connection.DisposeAsync();
}
private EFCoreExternalIdentityProvisioner CreateProvisioner(
IExternalAuthenticationHandleHasher hasher,
IDbContextFactory<ExternalAuthenticationElsaDbContext>? dbContextFactory = null,
IUserStore? userStore = null,
IUserProvider? userProvider = null) =>
new(dbContextFactory ?? _dbContextFactory,
userStore ?? _userStore,
userProvider ?? new StoreBasedUserProvider(userStore ?? _userStore),
Substitute.For<IRoleProvider>(),
hasher,
new GuidIdentityGenerator(),
_clock,
NullLogger<EFCoreExternalIdentityProvisioner>.Instance);
2026-07-24 16:59:17 +00:00
[Fact]
public async Task PersistsEveryDurableExternalAuthenticationAggregateWithTheRequiredIndexes()
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var model = dbContext.Model;
Assert.Contains(dbContext.Database.GetMigrations(), x => x.EndsWith("_Initial", StringComparison.Ordinal));
2026-07-24 16:59:17 +00:00
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedIdentityProviderConnection));
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedExternalIdentityLink));
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedBrokerTransaction));
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedAuthorizationGrant));
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedExternalAuthenticationSession));
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedExternalAuthenticationRefreshToken));
2026-07-24 16:59:17 +00:00
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedConnectionObservation));
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedPreviewResult));
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(ExternalAuthenticationRegistryVersion));
var connection = model.FindEntityType(typeof(PersistedIdentityProviderConnection))!;
Assert.True(connection.FindProperty(nameof(PersistedIdentityProviderConnection.Revision))!.IsConcurrencyToken);
Assert.Contains(connection.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedIdentityProviderConnection.TenantId), nameof(PersistedIdentityProviderConnection.Key)]));
var link = model.FindEntityType(typeof(PersistedExternalIdentityLink))!;
Assert.Contains(link.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedExternalIdentityLink.TenantId), nameof(PersistedExternalIdentityLink.ConnectionKey), nameof(PersistedExternalIdentityLink.Issuer), nameof(PersistedExternalIdentityLink.SubjectHash)]));
var refreshToken = model.FindEntityType(typeof(PersistedExternalAuthenticationRefreshToken))!;
Assert.Contains(refreshToken.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedExternalAuthenticationRefreshToken.Hash)]));
}
[Fact]
public async Task SqliteInitialMigrationCreatesTheOptionalRefreshTokenTable()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var optionsBuilder = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>();
optionsBuilder.UseElsaDbContextOptions(null);
optionsBuilder.UseSqlite(connection, sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName));
var options = optionsBuilder.Options;
await using var services = new ServiceCollection().BuildServiceProvider();
await using var dbContext = new ExternalAuthenticationElsaDbContext(options, services);
await dbContext.Database.MigrateAsync();
Assert.Single(await dbContext.Database.GetAppliedMigrationsAsync());
await using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'ExternalAuthenticationSessionRefreshTokens'";
Assert.Equal(1L, (long)(await command.ExecuteScalarAsync())!);
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('ExternalAuthenticationSessions') WHERE name = 'CurrentRefreshTokenHash'";
Assert.Equal(0L, (long)(await command.ExecuteScalarAsync())!);
2026-07-24 16:59:17 +00:00
}
[Fact]
public async Task ExternalAuthenticationModelDoesNotReachIntoTheIdentityAggregate()
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var model = dbContext.Model;
// External authentication owns its own database context, so the identity aggregate must not leak into it.
// ExternalIdentityLink.UserId is resolved through IUserProvider/IUserStore instead of a foreign key.
Assert.DoesNotContain(model.GetEntityTypes(), x => x.ClrType == typeof(User));
Assert.Empty(model.FindEntityType(typeof(PersistedExternalIdentityLink))!.GetForeignKeys());
}
2026-07-24 16:59:17 +00:00
[Fact]
public async Task ConnectionStoreEnforcesUniqueScopeKeysAndOptimisticConcurrency()
{
var store = new EFCoreIdentityProviderConnectionStore(_leaseFactory);
2026-07-24 16:59:17 +00:00
var created = Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(CreateConnection()));
Assert.Equal(1, created.Connection.Revision);
Assert.IsType<ConnectionMutationResult.DuplicateKey>(await store.CreateAsync(CreateConnection("connection-b")));
created.Connection.DisplayName = "Updated";
var updated = Assert.IsType<ConnectionMutationResult.Updated>(await store.UpdateAsync(created.Connection, 1));
Assert.Equal(2, updated.Connection.Revision);
Assert.Equal("Updated", updated.Connection.DisplayName);
Assert.Equal(2, Assert.IsType<ConnectionMutationResult.RevisionConflict>(await store.UpdateAsync(created.Connection, 1)).CurrentRevision);
}
fix(external-authentication): scope role-deletion impact to the role's tenant (#8036) * fix(external-authentication): scope role-deletion impact to the role's tenant ExternalAuthenticationRoleDeletionDependencyContributor scanned every stored connection with an empty ConnectionFilter and every configured connection regardless of its tenant, so a role ID that exists in two tenants could report another tenant's references as its own impact -- and a configuration entry owned by another tenant could block a role deletion outright. Remediation had the same reach: it loaded a dependency's connection by the caller-supplied owner ID without checking which tenant owned it. Impact, prevalidation and remediation now only see connections in the role's tenant context, which is the tenant active on ITenantAccessor while the role-deletion coordinator runs. Host-scoped connections stay in scope for every tenant, because the connection registry resolves the host scope for every signing-in tenant and the provisioner resolves a connection's default role IDs in the signing-in user's tenant, so a host connection naming a role ID really does reference that tenant's role. Configuration entries that leave the tenant blank are host-scoped for the same reason the configuration source materializes them there. A connection carrying another tenant's ID is out of scope in both directions, and a connection loaded for remediation that is not in the role's tenant is treated as absent, which fails the request rather than mutating it. The stored connections are fetched per applicable scope so another tenant's rows are never materialized, and both connection stores already honor ConnectionFilter.Scope; the durable store now has a test pinning that, since the tenant boundary rests on it. Refs #8013 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scan every tenant when deleting a tenant-agnostic role Role stores expose tenant-agnostic roles (TenantId == "*") from every tenant, but the role-deletion contributor derived its dependency scan boundary from the ambient tenant only, so deleting an agnostic role while tenant A was active left references from other tenants dangling. Resolve the role being deleted once per operation, through the active role store, and scan every connection and configuration entry regardless of tenant when it is agnostic. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(external-authentication): share the active role store lookup Extract the duplicated "active role store is the last registration" resolution into a single ActiveRoleStore accessor and rename ToScope to ToConnectionScope for clarity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): read one connection snapshot and prefer the agnostic role Reading the host and tenant scopes as two separate store queries let a connection whose TenantId changed mid-flight fall between the reads and escape both, letting role deletion proceed while a reference remained. FindConnectionsInRoleTenantScopeAsync now reads one snapshot and filters it in memory. IsAgnosticRoleAsync resolved a role by an unqualified ID lookup, which could return the ambient tenant's role instead of an agnostic role sharing its ID, silently narrowing impact scanning and leaving JIT-policy references in other tenants dangling; it now checks every role sharing the ID and gives the agnostic scope deterministic precedence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(external-authentication): correct the scope-filter test comment Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): fail closed when a role ID resolves to more than one role A same-ID collision between a tenant-scoped role and an agnostic role can only occur in MemoryRoleStore (durable persistence keys roles by ID alone). In that case the coordinator's own deletion target is already ambiguous, so widening or narrowing the scope by guessing is wrong in either direction; throw instead of picking a side. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scope role-deletion impact by the resolved role's tenant Replace the isAgnosticRole flag with ResolveRoleTenantIdAsync, which returns the resolved role's own TenantId and falls back to the ambient tenant only when the role cannot be resolved. With multitenancy disabled the EF role store installs no tenant query filter and can resolve a tenant-owned role by ID regardless of the ambient tenant, so scoping by the ambient tenant alone left that role's connection references out of scan while the coordinator deleted it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require an agnostic replacement when remediating an agnostic role Authorization for a replacement role still resolves through the ambient tenant's role services, so a deletion initiated in tenant A could authorize a tenant-A-only replacement and then write it into tenant B's connection policy, where that role does not exist. When the deletion target is agnostic, require the replacement role to be agnostic too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require agnostic replacements for host connections and reject ambiguous ones Extend the agnostic-replacement requirement to host-scoped connections, since a host connection is served to every signing-in tenant and a tenant-scoped replacement would resolve in the authorizing tenant but fail to resolve in every other tenant it serves. Recheck the replacement at removal time through the same agnostic-role resolution used at validation, instead of trusting whichever same-ID role a plain FindAsync happens to return, so a replacement collision introduced between validation and mutation is rejected. Resolve IsAgnosticRoleAsync's candidate directly and return true only when exactly one matching role is agnostic, so an ambiguous replacement ID is reported as replacement_role_unavailable_or_unauthorized instead of escaping as an exception. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): keep host-connection replacements allowed for default-tenant roles Revert the host-scope replacement guard added for host-scoped connections. IdentityProviderConnectionManagementService forces every managed connection to host scope, and in a deployment without multitenancy roles are created scoped to the default tenant rather than agnostic, so requiring an agnostic replacement for host-scoped connections would make every replacement remediation impossible in the default deployment. The replacement guard applies only when the deletion target itself is agnostic, as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 05:34:44 +00:00
[Fact]
public async Task ConnectionStoreReturnsOnlyTheRequestedScope()
{
var store = new EFCoreIdentityProviderConnectionStore(_leaseFactory);
Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(CreateConnection()));
Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(CreateConnection("connection-b", "tenant-b")));
Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(CreateConnection("connection-host", ConnectionScope.HostTenantId)));
var tenantScoped = await store.FindAsync(new() { Scope = new(ConnectionScopeKind.Tenant, "tenant-a") });
var hostScoped = await store.FindAsync(new() { Scope = ConnectionScope.Host });
var unscoped = await store.FindAsync(new());
// The store honors ConnectionFilter.Scope, so callers that query by scope get only that scope's rows;
// a store that accepted and ignored the filter would silently widen their reach.
Assert.Equal(["connection-a"], tenantScoped.Items.Select(x => x.Id).ToArray());
Assert.Equal(["connection-host"], hostScoped.Items.Select(x => x.Id).ToArray());
Assert.Equal(3, unscoped.Items.Count);
}
2026-07-24 16:59:17 +00:00
[Fact]
public async Task DurableStateGrantSessionAndRegistryVersionOperationsAreSingleUseOrCompareAndSwap()
{
var durableDbContexts = _leaseFactory;
2026-07-24 16:59:17 +00:00
var stateStore = new EFCoreExternalAuthenticationStateStore(durableDbContexts, _clock);
var transaction = new BrokerTransaction { HandleHash = "state", Purpose = BrokerTransactionPurpose.ExternalSignIn, ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"), ReturnPath = "/", TenantId = "tenant-a", PkceChallenge = "challenge", ExpiresAt = _clock.UtcNow.AddMinutes(1) };
await stateStore.PutAsync("ExternalSignIn", "state", transaction, transaction.ExpiresAt);
Assert.IsType<TakeResult<BrokerTransaction>.Taken>(await stateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", "state"));
Assert.IsType<TakeResult<BrokerTransaction>.AlreadyConsumed>(await stateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", "state"));
var grantStore = new EFCoreAuthorizationGrantStore(durableDbContexts, _clock);
await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = "code", ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"), TenantId = "tenant-a", UserId = "user-a", PkceChallenge = "challenge", ExpiresAt = _clock.UtcNow.AddMinutes(1) });
Assert.IsType<TakeResult<AuthorizationGrant>.Taken>(await grantStore.TryTakeAsync("code"));
Assert.IsType<TakeResult<AuthorizationGrant>.AlreadyConsumed>(await grantStore.TryTakeAsync("code"));
var sessionStore = new EFCoreExternalAuthenticationSessionStore(durableDbContexts, _clock);
await sessionStore.SaveAsync(CreateSession());
Assert.IsType<ExternalAuthenticationSessionRotationResult.Rotated>(await sessionStore.TryRotateRefreshTokenAsync("session-a", "refresh-a", 0, "refresh-b", _clock.UtcNow));
Assert.Null(await sessionStore.FindByRefreshTokenHashAsync("refresh-a"));
Assert.Equal("session-a", (await sessionStore.FindByRefreshTokenHashAsync("refresh-b"))!.Id);
2026-07-24 16:59:17 +00:00
Assert.IsType<ExternalAuthenticationSessionRotationResult.Reused>(await sessionStore.TryRotateRefreshTokenAsync("session-a", "refresh-a", 0, "refresh-c", _clock.UtcNow));
var firstNode = new EFCoreConnectionRegistryVersionStore(durableDbContexts);
var secondNode = new EFCoreConnectionRegistryVersionStore(durableDbContexts);
Assert.Equal(1, await firstNode.GetVersionAsync());
var version = await firstNode.AdvanceAsync();
Assert.True(await secondNode.IsCurrentAsync(version));
}
[Fact]
public async Task DurableStateStoreRoundTripsRelativePreviewCallbackUris()
{
var stateStore = new EFCoreExternalAuthenticationStateStore(_leaseFactory, _clock);
var transaction = new BrokerTransaction
{
HandleHash = "preview-state",
Purpose = BrokerTransactionPurpose.Preview,
ClientId = "administrator",
CallbackUri = new Uri("/external-authentication/previews/preview-handle/authorize", UriKind.Relative),
ReturnPath = "/",
TenantId = "tenant-a",
ConnectionId = "connection-a",
ConnectionMaterialRevision = "revision-a",
PkceChallenge = string.Empty,
ExpiresAt = _clock.UtcNow.AddMinutes(1)
};
await stateStore.PutAsync("PreviewStart", transaction.HandleHash, transaction, transaction.ExpiresAt);
var stored = Assert.IsType<TakeResult<BrokerTransaction>.Taken>(
await stateStore.TryTakeAsync<BrokerTransaction>("PreviewStart", transaction.HandleHash));
Assert.False(stored.Value.CallbackUri.IsAbsoluteUri);
Assert.Equal(transaction.CallbackUri, stored.Value.CallbackUri);
}
[Fact]
public async Task DurableSingleUseStoresRejectExpiredEntriesInTheAtomicConsumePredicate()
{
var durableDbContexts = _leaseFactory;
var beforeExpiry = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var afterExpiry = beforeExpiry.AddMinutes(2);
var expiresAt = beforeExpiry.AddMinutes(1);
var stateStore = new EFCoreExternalAuthenticationStateStore(durableDbContexts, new SteppingSystemClock(afterExpiry));
await stateStore.PutAsync("state", "state", new BrokerTransaction { HandleHash = "state", Purpose = BrokerTransactionPurpose.ExternalSignIn, ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"), ReturnPath = "/", TenantId = "tenant-a", PkceChallenge = "challenge", ExpiresAt = expiresAt }, expiresAt);
Assert.IsType<TakeResult<BrokerTransaction>.Expired>(await stateStore.TryTakeAsync<BrokerTransaction>("state", "state"));
var grantStore = new EFCoreAuthorizationGrantStore(durableDbContexts, new SteppingSystemClock(afterExpiry));
await grantStore.SaveAsync(new AuthorizationGrant { CodeHash = "grant", ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"), TenantId = "tenant-a", UserId = "user-a", PkceChallenge = "challenge", ExpiresAt = expiresAt });
Assert.IsType<TakeResult<AuthorizationGrant>.Expired>(await grantStore.TryTakeAsync("grant"));
var previewStore = new EFCorePreviewResultStore(durableDbContexts, new SteppingSystemClock(afterExpiry));
await previewStore.SaveAsync(new PreviewResult("preview", "admin-a", "tenant-a", "connection-a", "revision-a", "https://issuer.example", "subject", new Dictionary<string, IReadOnlyCollection<string>>(), "allowed", [], [], expiresAt, null));
Assert.IsType<TakeResult<PreviewResult>.Expired>(await previewStore.TryTakeAsync("preview", "admin-a"));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Null((await dbContext.ExternalAuthenticationBrokerTransactions.SingleAsync(x => x.HandleHash == "state")).ConsumedAt);
Assert.Null((await dbContext.ExternalAuthenticationAuthorizationGrants.SingleAsync(x => x.CodeHash == "grant")).ConsumedAt);
Assert.Null((await dbContext.ExternalAuthenticationPreviewResults.SingleAsync(x => x.HandleHash == "preview")).ConsumedAt);
}
2026-07-24 16:59:17 +00:00
[Fact]
public async Task ProvisionerCreatesCredentiallessUserAndOneDurableLinkPerIdentityTuple()
{
using var hasher = new HmacExternalAuthenticationHandleHasher();
var provisioner = CreateProvisioner(hasher);
2026-07-24 16:59:17 +00:00
var request = new ProvisioningRequest("tenant-a", "connection-a", new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary<string, IReadOnlyCollection<string>>()), new UserCreationProposal("external"));
var created = await provisioner.CreateLinkOrGetExistingAsync(request);
var converged = await provisioner.CreateLinkOrGetExistingAsync(request);
Assert.True(created.WasCreated);
Assert.False(converged.WasCreated);
Assert.Equal(created.Link.Id, converged.Link.Id);
var user = Assert.Single(await _userStore.FindManyAsync(new UserFilter()));
2026-07-24 16:59:17 +00:00
Assert.Null(user.HashedPassword);
Assert.Null(user.HashedPasswordSalt);
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerPersistsTheLatestSuccessfulSignInTimestamp()
{
using var hasher = new HmacExternalAuthenticationHandleHasher();
var provisioner = CreateProvisioner(hasher);
var identity = new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims);
var request = new ProvisioningRequest("tenant-a", "connection-a", identity, new UserCreationProposal("external"));
var created = await provisioner.CreateLinkOrGetExistingAsync(request);
var firstSignInAt = new DateTimeOffset(2026, 7, 26, 10, 0, 0, TimeSpan.Zero);
var latestSignInAt = firstSignInAt.AddMinutes(1);
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, firstSignInAt));
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, latestSignInAt));
Assert.True(await provisioner.RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, firstSignInAt));
var persisted = await CreateProvisioner(hasher).FindLinkAsync("tenant-a", "connection-a", identity);
Assert.Equal(latestSignInAt, persisted!.LastSignedInAt);
}
[Fact]
public async Task ConcurrentSignInsPreserveTheLatestTimestamp()
{
using var hasher = new HmacExternalAuthenticationHandleHasher();
var provisioner = CreateProvisioner(hasher);
var identity = new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims);
var created = await provisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "connection-a", identity, new UserCreationProposal("external")));
var signInTimes = Enumerable.Range(0, 8)
.Select(minutes => new DateTimeOffset(2026, 7, 26, 10, minutes, 0, TimeSpan.Zero))
.ToArray();
var results = await Task.WhenAll(signInTimes.Select(async signedInAt =>
await CreateProvisioner(hasher).RecordSuccessfulSignInAsync("tenant-a", "connection-a", identity, created.UserId, signedInAt)));
Assert.All(results, Assert.True);
var persisted = await CreateProvisioner(hasher).FindLinkAsync("tenant-a", "connection-a", identity);
Assert.Equal(signInTimes.Max(), persisted!.LastSignedInAt);
}
[Fact]
public async Task ProvisionerRemovesTheJustInTimeUserThatLosesTheLinkRace()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"elsa-external-identity-provisioning-{Guid.NewGuid():N}.db");
await using var services = new ServiceCollection().BuildServiceProvider();
try
{
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite($"Data Source={databasePath};Default Timeout=30")
.Options;
var factory = new TestDbContextFactory(options, services);
await using (var dbContext = await factory.CreateDbContextAsync())
await dbContext.Database.EnsureCreatedAsync();
feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
var durableUsers = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
var coordinatedUsers = new CoordinatedUserStore(durableUsers, 2);
using var hasher = new HmacExternalAuthenticationHandleHasher();
var firstNode = CreateProvisioner(hasher, factory, coordinatedUsers);
var secondNode = CreateProvisioner(hasher, factory, coordinatedUsers);
var request = new ProvisioningRequest("tenant-a", "connection-a", new ExternalIdentity("https://issuer.example", "subject-race", EmptyClaims), new UserCreationProposal("external"));
var results = await Task.WhenAll(
firstNode.CreateLinkOrGetExistingAsync(request).AsTask(),
secondNode.CreateLinkOrGetExistingAsync(request).AsTask());
Assert.Single(results, x => x.WasCreated);
Assert.Single(results, x => !x.WasCreated);
Assert.Single(results.Select(x => x.Link.Id).Distinct(StringComparer.Ordinal));
var user = Assert.Single(await durableUsers.FindManyAsync(new UserFilter()));
Assert.Equal(results[0].UserId, user.Id);
Assert.Equal(results[1].UserId, user.Id);
await using var verificationContext = await factory.CreateDbContextAsync();
Assert.Single(await verificationContext.ExternalIdentityLinks.ToListAsync());
}
finally
{
SqliteConnection.ClearAllPools();
File.Delete(databasePath);
}
}
[Fact]
public async Task ProvisionerRemovesTheJustInTimeUserWhenLinkPersistenceFails()
{
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new FailingLinkSaveInterceptor())
.Options;
var provisioner = CreateProvisioner(
new HmacExternalAuthenticationHandleHasher(),
new TestDbContextFactory(options, _services));
var request = new ProvisioningRequest(
"tenant-a",
"connection-a",
new ExternalIdentity("https://issuer.example", "subject-link-failure", EmptyClaims),
new UserCreationProposal("external"));
await Assert.ThrowsAsync<DbUpdateException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
Assert.Empty(await _userStore.FindManyAsync(new UserFilter()));
}
[Fact]
public async Task ProvisionerRemovesTheJustInTimeUserWhenPublicationIsCancelled()
{
using var cancellationTokenSource = new CancellationTokenSource();
feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
var users = new CancelAfterSaveUserStore(new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a")), cancellationTokenSource);
using var hasher = new HmacExternalAuthenticationHandleHasher();
var provisioner = CreateProvisioner(hasher, userStore: users);
var request = new ProvisioningRequest(
"tenant-a",
"connection-a",
new ExternalIdentity("https://issuer.example", "subject-cancelled-publication", EmptyClaims),
new UserCreationProposal("external"));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
provisioner.CreateLinkOrGetExistingAsync(request, cancellationTokenSource.Token).AsTask());
Assert.Empty(await users.FindManyAsync(new UserFilter()));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerFailsWhenAJustInTimeUserCannotBeCompensated()
{
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new FailingLinkSaveInterceptor())
.Options;
feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
var userStore = new DeleteFailingUserStore(new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a")));
var provisioner = CreateProvisioner(
new HmacExternalAuthenticationHandleHasher(),
new TestDbContextFactory(options, _services),
userStore);
var request = new ProvisioningRequest(
"tenant-a",
"connection-a",
new ExternalIdentity("https://issuer.example", "subject-compensation-failure", EmptyClaims),
new UserCreationProposal("external"));
var exception = await Assert.ThrowsAsync<AggregateException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
Assert.Contains("No credentials were issued", exception.Message, StringComparison.Ordinal);
Assert.Single(await userStore.FindManyAsync(new UserFilter()));
}
[Fact]
public async Task ProvisionerRemovesTheLinkWhenUserDeletionWinsTheRace()
{
feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
var users = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new DeleteLinkedUserBeforeCommitInterceptor(users))
.Options;
var provisioner = CreateProvisioner(
new HmacExternalAuthenticationHandleHasher(),
new TestDbContextFactory(options, _services),
users);
var request = new ProvisioningRequest(
"tenant-a",
"connection-a",
new ExternalIdentity("https://issuer.example", "subject-user-deletion-race", EmptyClaims),
new UserCreationProposal("external"));
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
Assert.Empty(await users.FindManyAsync(new UserFilter()));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
2026-07-24 16:59:17 +00:00
}
[Fact]
public async Task ProvisionerReconcilesAmbiguousPublicationForExistingUser()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new DeleteLinkedUserAfterSaveAndThrowInterceptor(_userStore))
.Options;
using var hasher = new HmacExternalAuthenticationHandleHasher();
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services));
var request = new ProvisioningRequest(
"tenant-a",
"connection-a",
new ExternalIdentity("https://issuer.example", "subject-ambiguous-existing-user", EmptyClaims),
null,
"user-a");
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerAtomicallyReplacesLinksAndPreservesTheOldLinkOnConflict()
{
using var hasher = new HmacExternalAuthenticationHandleHasher();
var provisioner = CreateProvisioner(hasher);
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
var old = (await provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var conflicting = (await provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-conflict", EmptyClaims), null, "user-b"))).Link;
var conflict = Assert.IsType<ExternalIdentityLinkReplaceResult.Conflict>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-conflict", EmptyClaims))));
Assert.Equal(conflicting.Id, conflict.ConflictingLink.Id);
Assert.IsType<ExternalIdentityLinkReplaceResult.NotFound>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-b", old.Id, "user-b", "contoso", new ExternalIdentity("https://issuer.example", "cross-tenant", EmptyClaims))));
await using (var dbContext = await _dbContextFactory.CreateDbContextAsync())
{
Assert.Contains(await dbContext.ExternalIdentityLinks.ToListAsync(), x => x.Id == old.Id);
}
var sameTupleReplacement = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(
await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims))));
Assert.NotEqual(old.Id, sameTupleReplacement.NewLink.Id);
var replaced = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(await provisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", sameTupleReplacement.NewLink.Id, "user-b", "fabrikam", new ExternalIdentity("https://replacement.example", "subject-new", EmptyClaims))));
Assert.NotEqual(sameTupleReplacement.NewLink.Id, replaced.NewLink.Id);
Assert.Equal("user-b", replaced.NewLink.UserId);
Assert.Equal("fabrikam", replaced.NewLink.ConnectionKey);
Assert.Null(replaced.NewLink.LastSignedInAt);
await using (var dbContext = await _dbContextFactory.CreateDbContextAsync())
{
var links = await dbContext.ExternalIdentityLinks.ToListAsync();
Assert.DoesNotContain(links, x => x.Id == old.Id);
Assert.DoesNotContain(links, x => x.Id == sameTupleReplacement.NewLink.Id);
Assert.Contains(links, x => x.Id == replaced.NewLink.Id);
Assert.Contains(links, x => x.Id == conflicting.Id);
}
}
[Fact]
public async Task ProvisionerPreservesTheOldLinkWhenTargetUserDeletionWinsReplacementRace()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2);
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.Equal(old.Id, durableLink.Id);
Assert.Equal("user-a", durableLink.UserId);
}
[Fact]
public async Task ProvisionerLeavesNoLinkWhenBothReplacementUsersAreDeletedDuringCompensation()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2, 3);
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerPreservesRestoredLinkWhenPreviousUserLookupFails()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var racingProvider = new DeleteThenThrowUserProvider(new StoreBasedUserProvider(_userStore), _userStore);
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
Assert.Contains("lookup failure", exception.Message, StringComparison.Ordinal);
Assert.NotNull(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.Equal(old.Id, durableLink.Id);
Assert.Equal("user-a", durableLink.UserId);
}
[Fact]
public async Task ProvisionerFallsBackWhenInvalidRestoredLinkCleanupFailsOnce()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new FailSelectedLinkDeleteInterceptor(3))
.Options;
using var hasher = new HmacExternalAuthenticationHandleHasher();
var factory = new TestDbContextFactory(options, _services);
var originalProvisioner = CreateProvisioner(hasher, factory);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2, 3);
var racingProvisioner = CreateProvisioner(hasher, factory, userProvider: racingProvider);
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
}
[Fact]
public async Task ProvisionerDoesNotMisclassifyPostCommitUserLookupFailureAsConflict()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var failingProvider = new ThrowOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), 2);
var failingProvisioner = CreateProvisioner(hasher, userProvider: failingProvider);
await Assert.ThrowsAsync<DbUpdateException>(() => failingProvisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.NotEqual(old.Id, durableLink.Id);
Assert.Equal("user-b", durableLink.UserId);
}
[Fact]
public async Task ProvisionerReconcilesReplacementWhenCommitAcknowledgementIsLost()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new ThrowAfterSelectedCommitInterceptor(1))
.Options;
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services));
var result = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(await provisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))));
Assert.NotEqual(old.Id, result.NewLink.Id);
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.Equal(result.NewLink.Id, durableLink.Id);
Assert.Equal("user-b", durableLink.UserId);
}
[Fact]
public async Task ProvisionerPreservesRestoredLinkWhenCompensationCommitAcknowledgementIsLost()
{
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var originalProvisioner = CreateProvisioner(hasher);
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite(_connection)
.AddInterceptors(new ThrowAfterSelectedCommitInterceptor(2))
.Options;
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2);
var provisioner = CreateProvisioner(hasher, new TestDbContextFactory(options, _services), userProvider: racingProvider);
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.ReplaceAsync(
new ExternalIdentityLinkReplaceRequest(
"tenant-a",
old.Id,
"user-b",
"contoso",
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
Assert.Equal(old.Id, durableLink.Id);
Assert.Equal("user-a", durableLink.UserId);
}
[Fact]
public async Task DurableConcurrentReplacementUsesTheOldLinkIdAsAnAtomicGuard()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"elsa-external-identity-links-{Guid.NewGuid():N}.db");
await using var services = new ServiceCollection().BuildServiceProvider();
try
{
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
.UseSqlite($"Data Source={databasePath};Default Timeout=30")
.Options;
var factory = new TestDbContextFactory(options, services);
await using (var dbContext = await factory.CreateDbContextAsync())
{
await dbContext.Database.EnsureCreatedAsync();
}
// Both nodes share one user directory, which is what a multi-node deployment actually looks like.
feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
var sharedUserStore = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
await sharedUserStore.SaveAsync(new User { Id = "user-a", Name = "alice-concurrent", TenantId = "tenant-a" });
using var hasher = new HmacExternalAuthenticationHandleHasher();
var firstNode = CreateProvisioner(hasher, factory, sharedUserStore);
var secondNode = CreateProvisioner(hasher, factory, sharedUserStore);
var old = (await firstNode.CreateLinkOrGetExistingAsync(
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
var results = await Task.WhenAll(
firstNode.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims))).AsTask(),
secondNode.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-b", EmptyClaims))).AsTask());
Assert.Single(results.OfType<ExternalIdentityLinkReplaceResult.Success>());
Assert.Single(results.OfType<ExternalIdentityLinkReplaceResult.NotFound>());
await using var verificationContext = await factory.CreateDbContextAsync();
Assert.Single(await verificationContext.ExternalIdentityLinks.ToListAsync());
}
finally
{
SqliteConnection.ClearAllPools();
File.Delete(databasePath);
}
}
[Fact]
public async Task CallbackCompletionPersistsTheSessionBeforeAnyRefreshTokenIsIssued()
{
var identityResolver = Substitute.For<IExternalIdentityResolver>();
identityResolver.ResolveAsync(Arg.Any<ExternalIdentityResolutionContext>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new ExternalIdentityResolution("user-a", false)));
var permissionGrantResolver = Substitute.For<IPermissionGrantResolver>();
permissionGrantResolver.ResolveAsync(Arg.Any<PermissionGrantResolutionContext>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new PermissionGrantResult([], [])));
var adapter = new Broker.BrokerSecurityTests.RecordingAdapter
{
AuthenticationResult = new ExternalAuthenticationResult(new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims), EmptyClaims, [])
};
var broker = Broker.BrokerSecurityTests.CreateBroker(adapter, identityResolver: identityResolver, permissionGrantResolver: permissionGrantResolver, sessionStore: new EFCoreExternalAuthenticationSessionStore(_leaseFactory, _clock));
await broker.InitiateExternalAsync(new BrokerAuthorizationRequest("studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", "/workflows", "contoso"), "tenant-a");
var result = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [adapter.CorrelationState!] });
Assert.Null(result.Error);
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var session = Assert.Single(await dbContext.ExternalAuthenticationSessions.ToListAsync());
Assert.Equal("user-a", session.UserId);
Assert.Empty(await dbContext.ExternalAuthenticationRefreshTokens.ToListAsync());
Assert.Null((await new EFCoreExternalAuthenticationSessionStore(_leaseFactory, _clock).FindByIdAsync(session.Id))!.CurrentRefreshTokenHash);
Assert.Null(await new EFCoreExternalAuthenticationSessionStore(_leaseFactory, _clock).FindByRefreshTokenHashAsync(null!));
}
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
fix(external-authentication): scope role-deletion impact to the role's tenant (#8036) * fix(external-authentication): scope role-deletion impact to the role's tenant ExternalAuthenticationRoleDeletionDependencyContributor scanned every stored connection with an empty ConnectionFilter and every configured connection regardless of its tenant, so a role ID that exists in two tenants could report another tenant's references as its own impact -- and a configuration entry owned by another tenant could block a role deletion outright. Remediation had the same reach: it loaded a dependency's connection by the caller-supplied owner ID without checking which tenant owned it. Impact, prevalidation and remediation now only see connections in the role's tenant context, which is the tenant active on ITenantAccessor while the role-deletion coordinator runs. Host-scoped connections stay in scope for every tenant, because the connection registry resolves the host scope for every signing-in tenant and the provisioner resolves a connection's default role IDs in the signing-in user's tenant, so a host connection naming a role ID really does reference that tenant's role. Configuration entries that leave the tenant blank are host-scoped for the same reason the configuration source materializes them there. A connection carrying another tenant's ID is out of scope in both directions, and a connection loaded for remediation that is not in the role's tenant is treated as absent, which fails the request rather than mutating it. The stored connections are fetched per applicable scope so another tenant's rows are never materialized, and both connection stores already honor ConnectionFilter.Scope; the durable store now has a test pinning that, since the tenant boundary rests on it. Refs #8013 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scan every tenant when deleting a tenant-agnostic role Role stores expose tenant-agnostic roles (TenantId == "*") from every tenant, but the role-deletion contributor derived its dependency scan boundary from the ambient tenant only, so deleting an agnostic role while tenant A was active left references from other tenants dangling. Resolve the role being deleted once per operation, through the active role store, and scan every connection and configuration entry regardless of tenant when it is agnostic. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(external-authentication): share the active role store lookup Extract the duplicated "active role store is the last registration" resolution into a single ActiveRoleStore accessor and rename ToScope to ToConnectionScope for clarity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): read one connection snapshot and prefer the agnostic role Reading the host and tenant scopes as two separate store queries let a connection whose TenantId changed mid-flight fall between the reads and escape both, letting role deletion proceed while a reference remained. FindConnectionsInRoleTenantScopeAsync now reads one snapshot and filters it in memory. IsAgnosticRoleAsync resolved a role by an unqualified ID lookup, which could return the ambient tenant's role instead of an agnostic role sharing its ID, silently narrowing impact scanning and leaving JIT-policy references in other tenants dangling; it now checks every role sharing the ID and gives the agnostic scope deterministic precedence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(external-authentication): correct the scope-filter test comment Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): fail closed when a role ID resolves to more than one role A same-ID collision between a tenant-scoped role and an agnostic role can only occur in MemoryRoleStore (durable persistence keys roles by ID alone). In that case the coordinator's own deletion target is already ambiguous, so widening or narrowing the scope by guessing is wrong in either direction; throw instead of picking a side. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scope role-deletion impact by the resolved role's tenant Replace the isAgnosticRole flag with ResolveRoleTenantIdAsync, which returns the resolved role's own TenantId and falls back to the ambient tenant only when the role cannot be resolved. With multitenancy disabled the EF role store installs no tenant query filter and can resolve a tenant-owned role by ID regardless of the ambient tenant, so scoping by the ambient tenant alone left that role's connection references out of scan while the coordinator deleted it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require an agnostic replacement when remediating an agnostic role Authorization for a replacement role still resolves through the ambient tenant's role services, so a deletion initiated in tenant A could authorize a tenant-A-only replacement and then write it into tenant B's connection policy, where that role does not exist. When the deletion target is agnostic, require the replacement role to be agnostic too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require agnostic replacements for host connections and reject ambiguous ones Extend the agnostic-replacement requirement to host-scoped connections, since a host connection is served to every signing-in tenant and a tenant-scoped replacement would resolve in the authorizing tenant but fail to resolve in every other tenant it serves. Recheck the replacement at removal time through the same agnostic-role resolution used at validation, instead of trusting whichever same-ID role a plain FindAsync happens to return, so a replacement collision introduced between validation and mutation is rejected. Resolve IsAgnosticRoleAsync's candidate directly and return true only when exactly one matching role is agnostic, so an ambiguous replacement ID is reported as replacement_role_unavailable_or_unauthorized instead of escaping as an exception. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): keep host-connection replacements allowed for default-tenant roles Revert the host-scope replacement guard added for host-scoped connections. IdentityProviderConnectionManagementService forces every managed connection to host scope, and in a deployment without multitenancy roles are created scoped to the default tenant rather than agnostic, so requiring an agnostic replacement for host-scoped connections would make every replacement remediation impossible in the default deployment. The replacement guard applies only when the deletion target itself is agnostic, as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 05:34:44 +00:00
private static IdentityProviderConnection CreateConnection(string id = "connection-a", string tenantId = "tenant-a") => new()
2026-07-24 16:59:17 +00:00
{
Id = id,
fix(external-authentication): scope role-deletion impact to the role's tenant (#8036) * fix(external-authentication): scope role-deletion impact to the role's tenant ExternalAuthenticationRoleDeletionDependencyContributor scanned every stored connection with an empty ConnectionFilter and every configured connection regardless of its tenant, so a role ID that exists in two tenants could report another tenant's references as its own impact -- and a configuration entry owned by another tenant could block a role deletion outright. Remediation had the same reach: it loaded a dependency's connection by the caller-supplied owner ID without checking which tenant owned it. Impact, prevalidation and remediation now only see connections in the role's tenant context, which is the tenant active on ITenantAccessor while the role-deletion coordinator runs. Host-scoped connections stay in scope for every tenant, because the connection registry resolves the host scope for every signing-in tenant and the provisioner resolves a connection's default role IDs in the signing-in user's tenant, so a host connection naming a role ID really does reference that tenant's role. Configuration entries that leave the tenant blank are host-scoped for the same reason the configuration source materializes them there. A connection carrying another tenant's ID is out of scope in both directions, and a connection loaded for remediation that is not in the role's tenant is treated as absent, which fails the request rather than mutating it. The stored connections are fetched per applicable scope so another tenant's rows are never materialized, and both connection stores already honor ConnectionFilter.Scope; the durable store now has a test pinning that, since the tenant boundary rests on it. Refs #8013 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scan every tenant when deleting a tenant-agnostic role Role stores expose tenant-agnostic roles (TenantId == "*") from every tenant, but the role-deletion contributor derived its dependency scan boundary from the ambient tenant only, so deleting an agnostic role while tenant A was active left references from other tenants dangling. Resolve the role being deleted once per operation, through the active role store, and scan every connection and configuration entry regardless of tenant when it is agnostic. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(external-authentication): share the active role store lookup Extract the duplicated "active role store is the last registration" resolution into a single ActiveRoleStore accessor and rename ToScope to ToConnectionScope for clarity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): read one connection snapshot and prefer the agnostic role Reading the host and tenant scopes as two separate store queries let a connection whose TenantId changed mid-flight fall between the reads and escape both, letting role deletion proceed while a reference remained. FindConnectionsInRoleTenantScopeAsync now reads one snapshot and filters it in memory. IsAgnosticRoleAsync resolved a role by an unqualified ID lookup, which could return the ambient tenant's role instead of an agnostic role sharing its ID, silently narrowing impact scanning and leaving JIT-policy references in other tenants dangling; it now checks every role sharing the ID and gives the agnostic scope deterministic precedence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(external-authentication): correct the scope-filter test comment Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): fail closed when a role ID resolves to more than one role A same-ID collision between a tenant-scoped role and an agnostic role can only occur in MemoryRoleStore (durable persistence keys roles by ID alone). In that case the coordinator's own deletion target is already ambiguous, so widening or narrowing the scope by guessing is wrong in either direction; throw instead of picking a side. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scope role-deletion impact by the resolved role's tenant Replace the isAgnosticRole flag with ResolveRoleTenantIdAsync, which returns the resolved role's own TenantId and falls back to the ambient tenant only when the role cannot be resolved. With multitenancy disabled the EF role store installs no tenant query filter and can resolve a tenant-owned role by ID regardless of the ambient tenant, so scoping by the ambient tenant alone left that role's connection references out of scan while the coordinator deleted it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require an agnostic replacement when remediating an agnostic role Authorization for a replacement role still resolves through the ambient tenant's role services, so a deletion initiated in tenant A could authorize a tenant-A-only replacement and then write it into tenant B's connection policy, where that role does not exist. When the deletion target is agnostic, require the replacement role to be agnostic too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require agnostic replacements for host connections and reject ambiguous ones Extend the agnostic-replacement requirement to host-scoped connections, since a host connection is served to every signing-in tenant and a tenant-scoped replacement would resolve in the authorizing tenant but fail to resolve in every other tenant it serves. Recheck the replacement at removal time through the same agnostic-role resolution used at validation, instead of trusting whichever same-ID role a plain FindAsync happens to return, so a replacement collision introduced between validation and mutation is rejected. Resolve IsAgnosticRoleAsync's candidate directly and return true only when exactly one matching role is agnostic, so an ambiguous replacement ID is reported as replacement_role_unavailable_or_unauthorized instead of escaping as an exception. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): keep host-connection replacements allowed for default-tenant roles Revert the host-scope replacement guard added for host-scoped connections. IdentityProviderConnectionManagementService forces every managed connection to host scope, and in a deployment without multitenancy roles are created scoped to the default tenant rather than agnostic, so requiring an agnostic replacement for host-scoped connections would make every replacement remediation impossible in the default deployment. The replacement guard applies only when the deletion target itself is agnostic, as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 05:34:44 +00:00
TenantId = tenantId,
2026-07-24 16:59:17 +00:00
Key = "contoso",
AdapterType = "openid-connect",
AdapterSettingsVersion = 1,
AdapterSettings = JsonDocument.Parse("{}").RootElement.Clone(),
DisplayName = "Contoso",
MaterialRevision = "revision-a",
Revision = 1,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
private ExternalAuthenticationSession CreateSession() => new()
{
Id = "session-a", AuthenticationClientId = "studio", TenantId = "tenant-a", UserId = "user-a", ConnectionKey = "contoso", ConnectionMaterialRevision = "revision-a", Issuer = "https://issuer.example", SubjectHash = "subject", ExternalGrants = [], StartedAt = _clock.UtcNow, LastRefreshedAt = _clock.UtcNow, ExpiresAt = _clock.UtcNow.AddHours(1), RefreshExpiresAt = _clock.UtcNow.AddHours(1), CurrentRefreshTokenHash = "refresh-a"
2026-07-24 16:59:17 +00:00
};
private sealed class TestDbContextFactory(DbContextOptions<ExternalAuthenticationElsaDbContext> options, IServiceProvider serviceProvider) : IDbContextFactory<ExternalAuthenticationElsaDbContext>
2026-07-24 16:59:17 +00:00
{
public ExternalAuthenticationElsaDbContext CreateDbContext() => new(options, serviceProvider);
public Task<ExternalAuthenticationElsaDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext());
2026-07-24 16:59:17 +00:00
}
private sealed class SteppingSystemClock(params DateTimeOffset[] instants) : ISystemClock
{
private int _index;
public DateTimeOffset UtcNow => instants[Math.Min(_index++, instants.Length - 1)];
}
private sealed class CoordinatedUserStore(IUserStore inner, int participantCount) : IUserStore
{
private readonly TaskCompletionSource _participantsReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _participants;
public async Task SaveAsync(User user, CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref _participants) == participantCount)
_participantsReady.TrySetResult();
await _participantsReady.Task.WaitAsync(cancellationToken);
await inner.SaveAsync(user, cancellationToken);
}
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.DeleteAsync(filter, cancellationToken);
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
}
private sealed class FailingLinkSaveInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default) =>
throw new DbUpdateException("Simulated external identity link persistence failure.");
}
private sealed class DeleteLinkedUserBeforeCommitInterceptor(IUserStore users) : SaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var userId = eventData.Context!.ChangeTracker.Entries<PersistedExternalIdentityLink>()
.Single(x => x.State == EntityState.Added).Entity.UserId;
await users.DeleteAsync(new UserFilter { Id = userId }, cancellationToken);
return result;
}
}
private sealed class DeleteLinkedUserAfterSaveAndThrowInterceptor(IUserStore users) : SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
var userId = eventData.Context!.ChangeTracker.Entries<PersistedExternalIdentityLink>()
.Single().Entity.UserId;
await users.DeleteAsync(new UserFilter { Id = userId }, CancellationToken.None);
throw new InvalidOperationException("Simulated ambiguous post-save failure.");
}
}
private sealed class DeleteFailingUserStore(IUserStore inner) : IUserStore
{
public Task SaveAsync(User user, CancellationToken cancellationToken = default) => inner.SaveAsync(user, cancellationToken);
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Simulated user cleanup failure.");
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
}
private sealed class CancelAfterSaveUserStore(IUserStore inner, CancellationTokenSource cancellationTokenSource) : IUserStore
{
public async Task SaveAsync(User user, CancellationToken cancellationToken = default)
{
await inner.SaveAsync(user, cancellationToken);
cancellationTokenSource.Cancel();
}
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.DeleteAsync(filter, cancellationToken);
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
}
private sealed class FailSelectedLinkDeleteInterceptor(int failureCount) : DbCommandInterceptor
{
private int _deleteCount;
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.Contains("DELETE FROM \"ExternalIdentityLinks\"", StringComparison.Ordinal) &&
Interlocked.Increment(ref _deleteCount) == failureCount)
throw new InvalidOperationException("Simulated external identity link cleanup failure.");
return ValueTask.FromResult(result);
}
}
private sealed class ThrowAfterSelectedCommitInterceptor(int failureCount) : DbTransactionInterceptor
{
private int _commitCount;
public override Task TransactionCommittedAsync(
DbTransaction transaction,
TransactionEndEventData eventData,
CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref _commitCount) == failureCount)
throw new InvalidOperationException("Simulated lost transaction commit acknowledgement.");
return Task.CompletedTask;
}
}
private sealed class DeleteOnSelectedFindUserProvider(IUserProvider inner, IUserStore users, params int[] deletionCounts) : IUserProvider
{
private readonly HashSet<int> _deletionCounts = deletionCounts.ToHashSet();
private int _findCount;
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var user = await inner.FindAsync(filter, cancellationToken);
if (user is not null && _deletionCounts.Contains(Interlocked.Increment(ref _findCount)))
{
await users.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
return null;
}
return user;
}
}
private sealed class DeleteThenThrowUserProvider(IUserProvider inner, IUserStore users) : IUserProvider
{
private int _findCount;
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var user = await inner.FindAsync(filter, cancellationToken);
var findCount = Interlocked.Increment(ref _findCount);
if (user is not null && findCount == 2)
{
await users.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
return null;
}
if (findCount == 3)
throw new InvalidOperationException("Simulated user-directory lookup failure.");
return user;
}
}
private sealed class ThrowOnSelectedFindUserProvider(IUserProvider inner, int failureCount) : IUserProvider
{
private int _findCount;
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
var user = await inner.FindAsync(filter, cancellationToken);
if (Interlocked.Increment(ref _findCount) == failureCount)
throw new DbUpdateException("Simulated post-commit user-directory failure.");
return user;
}
}
2026-07-24 16:59:17 +00:00
}