* 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>
15 KiB
Implementation Plan: Authorization Model
Status: Draft — pending approval
Tracking: #7974
Branch: 013-rbac-authorization-model | Date: 2026-08-23 | Spec: spec.md
Input: Feature specification from /specs/013-rbac-authorization-model/spec.md, grounded in research.md
Summary
Replace Elsa's ad-hoc permission vocabulary with a two-axis authorization model: a hierarchical resource axis and an open verb axis, both module-contributed, evaluated by a single evaluator that every enforcement path routes through.
Both axes are open because Elsa is a framework third parties extend, and because ADR 0017 commits to an open vocabulary. Prefix matching on the resource axis makes section-wide grants a single token, which removes the pressure for a second, coarse-grained gate. Wildcards are the only construct with forward reach on either axis, so *:* is superuser with no sentinel and no aggregate to reinterpret. A closed verb enumeration was drafted and rejected; see research.md D13.
Storage is unchanged: Role.Permissions remains a string collection of flat {resource}:{verb} entries.
Technical Context
Language/Version: C# latest; nullable reference types and implicit usings; multi-target net8.0, net9.0, net10.0.
Primary Dependencies: Elsa.Api.Common (FastEndpoints base classes, permission constants), Elsa.Identity (roles, users, applications, token issuance), Elsa.Features / CShells shell features, ASP.NET Core authorization, Elsa.Mediator for audit notifications, Entity Framework Core for Identity persistence.
Storage: No schema change for grants. Role.Permissions stays ICollection<string> persisted through the existing comma-joining converter in src/modules/Elsa.Persistence.EFCore/Modules/Identity/Configurations.cs, which forbids commas inside a permission string. The tenancy milestone changes Identity indexes only and requires migrations across all five providers.
Testing: xUnit unit tests for the resource and verb matchers; a reflection-driven gate asserting every in-repository endpoint declares a permission resolving to a registered descriptor; integration tests for introspection, revocation, and per-tenant isolation; regression tests proving legacy strings no longer authorize and that the whole-vocabulary grant still does.
Target Platform: ASP.NET Core Elsa Server; consumed by Elsa Studio and other clients through the catalog and introspection endpoints.
Project Type: Modular .NET server libraries with REST endpoints.
Performance Goals: Evaluation is O(number of grants held) with no store access on the request path; no measurable regression against today's ordinal set lookup. The catalog is built once per shell and cached.
Constraints: No new infrastructure may become a prerequisite for correct authorization — in particular the optional security stamp must not depend on cross-node cache invalidation, which Elsa does not have (ChangeTokenSignalInvoker is per-process). Elsa remains the only authority expanding roles into permission claims (ADR 0022). Permission strings may not contain commas. No cross-tenant principal is introduced.
Scale/Scope: 47 resources and 23 verbs at publication, rising as modules contribute descriptors; 160 endpoint files; 174 declaration call sites; hand-rolled claim inspections across 15 files; 3 named-policy usages; 4 SignalR hubs. A further 15 mid-handler NotReadOnlyPolicy calls exist but are out of scope — read-only mode is a separate axis.
Constitution Check
GATE: PASS before research and after design.
| Principle | Verdict | Evidence |
|---|---|---|
| I. Modular Architecture | PASS | The model and evaluator live in Elsa.Api.Common; each module owns its own descriptors and constants. The descriptor registry moves out of an optional module into core, correcting an existing inversion. |
| II. Composition & Extensibility | PASS | The resource axis is open and contributed through IPermissionDescriptorProvider. Third-party modules keep working through an obsolete-but-functional declaration API with graceful degradation. |
| III. Convention-Driven Design | PASS | Adopts the established Permissions/<Module>Permissions.cs pattern already proven in External Authentication, refined to one constant per resource. Verb coherence is maintained by a recommended core set as convention rather than by enforcement. |
| IV. Async & Pipeline Execution | PASS | Evaluation is synchronous and allocation-light by design; catalog contribution and introspection follow existing async contracts. |
| V. Testing Discipline | PASS | Unit, integration, regression, and an automated coverage gate; the gate is itself a deliverable. |
| VI. Trunk-Based Development | PASS | Milestones are independently shippable; the cutover is split one pull request per module, landing in any order because the obsolete declaration path and the seeded admin grant keep trunk green throughout. |
| VII. Simplicity, SRP, DRY & KISS | PASS | Two axes, one matching rule shape, one wildcard. No enumeration, no mask, no aggregates, no second gate, no sentinel. Collapses four parallel permission-checking mechanisms into one and six duplicated method bodies into one; read-only mode correctly keeps its own axis. |
Project Structure
Documentation
specs/013-rbac-authorization-model/
├── spec.md # feature specification
├── plan.md # this file
├── research.md # grounded assessment and decisions log
└── contracts/
├── rest-api.md # catalog and introspection contracts
└── permissions.md # the resource tree and supported verbs
docs/
├── adr/00NN-two-axis-authorization-model.md
└── migrations/authorization-model.md
Elsa Core Repository
src/common/Elsa.Api.Common/
├── Authorization/
│ ├── CoreVerbs.cs # recommended verb constants (convention)
│ ├── Permission.cs # (resource, verb) with parse/format
│ ├── IPermissionEvaluator.cs
│ ├── PermissionEvaluator.cs # the single decision point
│ ├── PermissionMatcher.cs # exact and wildcard, on both axes
│ ├── PermissionRequirement.cs
│ └── PermissionAuthorizationHandler.cs
├── Permissions/
│ ├── PermissionDescriptor.cs # promoted from Elsa.ExternalAuthentication
│ ├── IPermissionDescriptorProvider.cs
│ ├── IPermissionDescriptorRegistry.cs
│ └── DefaultPermissionDescriptorRegistry.cs
├── Abstractions/Endpoints.cs # RequirePermission(resource, verb); collapse 6 duplicates
├── PermissionNames.cs # reduced to claim type and the whole-vocabulary grant
└── EndpointSecurityOptions.cs # remove dead role-name fields
src/modules/<Module>/Permissions/<Module>Permissions.cs # constants + descriptors, per module
src/modules/Elsa.Identity/
├── Endpoints/Me/Permissions/Endpoint.cs # introspection
├── Services/DefaultAccessTokenIssuer.cs # emit new-format claims
├── Services/RoleAuthorizationService.cs # delegate to the evaluator
└── Options/IdentityTokenOptions.cs # shorter default lifetime; stamp options
src/modules/Elsa.Persistence.EFCore/Modules/Identity/Configurations.cs # per-tenant indexes
src/modules/Elsa.Common/Services/MemoryStore.cs # tenant filtering
Phase 0: Research
Complete. See research.md for the grounded assessment and the decision record D1–D26 that this plan implements. Note that D1–D12 are partly superseded — most importantly D3 by D13, which opened the verb axis — so the governing set is the record as a whole, not its first twelve entries. Two findings materially shaped the design and are recorded there rather than restated: the resource axis had to become hierarchical for the model to remove the need for a coarse second gate, and Elsa.Caching provides no distributed invalidation, which constrains the revocation design.
Phase 1: Data Model and Contracts
Produce contracts/permissions.md — the full resource tree with supported verbs per resource, derived from the current 33 resources and the endpoint census — and contracts/rest-api.md for the catalog and introspection endpoints. Publish the legacy-to-new mapping as doc/migrations/authorization-model.md, following the shape of doc/migrations/external-authentication-persistence.md. Record the model in an ADR.
The resource tree is the highest-value artefact to review early: it is the vocabulary every module and client will hold, and it is expensive to change once published.
Implementation Sequence
Milestone 1: Model and Evaluator
Additive only; nothing changes behavior.
CoreVerbs,Permission,PermissionMatcher,IPermissionEvaluatorand its implementation.- Promote the descriptor registry from
Elsa.ExternalAuthenticationintoElsa.Api.Common, leaving type-forwarding shims so External Authentication keeps compiling. - Unit tests for the matcher table: exact match on both axes, subtree wildcard, verb wildcard, whole-vocabulary, absence denying, and a wildcard covering a newly registered resource or verb.
Milestone 2: Catalog Coverage
Still additive.
- Every module with protected endpoints contributes a
Permissions/<Module>Permissions.cscarrying one constant per resource and its descriptors, following the External Authentication pattern. - The catalog endpoint, and the reach report backing "this grant currently covers these resources".
- External Authentication's existing
unknown_permission_descriptorwarning becomes meaningful for core permissions for the first time.
Milestone 3: Cutover
The breaking change. One pull request per module.
- Endpoints migrate to
RequirePermission(resource, verb). - The hand-rolled claim inspections (15 files), 3 named-policy usages, and 4 SignalR hub checks all route through the evaluator. The 15 mid-handler
NotReadOnlyPolicycalls are deliberately excluded — read-only mode is a separate axis. ConfigurePermissions(params string[])becomes obsolete but functional, with unresolvable strings registering implicit unverified descriptors and logging warnings.- The fail-closed gate lands, asserting every in-repository endpoint declares a permission resolving to a registered descriptor.
- The token issuer emits new-format claims; the startup validator reports unresolvable stored permissions by role.
- No migration scaffold is required: the obsolete
ConfigurePermissions(string[])path translates legacy endpoint declarations through the migration table, and the seeded admin*grant satisfies every endpoint throughout, so module PRs can land in any order. Module-specific authorization fixtures migrate with their module.
Milestone 4: Introspection, Revocation, and Audit
GET /identity/me/permissions, including denied resources with an emptyverbsarray.- Access-token lifetime default lowered from 1 hour to 15 minutes, documented as the revocation bound. Refresh already rotates both tokens and re-reads roles, so no client change is required; refresh-token lifetime is unchanged at 2 hours.
- Optional per-principal security stamp with a per-node cache and configurable interval, dependent on no new infrastructure.
- Typed security notifications for role and assignment mutations, per ADR 0020.
Milestone 5: Tenancy Hardening
Independently justified as a latent-defect fix; sequenced last so the unresolved isolation-boundary question does not block delivery.
- Per-tenant composite unique indexes for role, user, and application names, with migrations across all five providers.
- Tenant filtering in
MemoryStore, so the default stores isolate rather than relying on the Entity Framework path. - Explicit tenant filters on role and user listing, and on user creation.
Excluded: Elsa.Secrets tenancy, tracked as #7972.
Post-Design Constitution Re-check
GATE: performed 2026-08-23, after the Phase 1 contracts landed. PASS.
| Principle | Verdict | Evidence |
|---|---|---|
| I. Modular Architecture | PASS | Model and evaluator in Elsa.Api.Common; each module owns its own resources. The descriptor registry moves out of an optional module into core, correcting an existing inversion. |
| II. Composition & Extensibility | PASS | Both axes open and module-contributed; third-party modules keep working through an obsolete-but-functional declaration path with graceful degradation. |
| III. Convention-Driven Design | PASS | Follows the proven Permissions/<Module>Permissions.cs pattern; verb coherence is convention (a recommended core set) rather than enforcement. American English throughout. |
| IV. Async & Pipeline Execution | PASS | Evaluation is synchronous and allocation-light by design; catalog contribution and introspection follow existing async contracts. |
| V. Testing Discipline | PASS | Unit, integration, regression, plus the coverage gate as a deliverable. |
| VI. Trunk-Based Development | PASS | Milestones independently shippable; the cutover is one PR per module, landing in any order. |
| VII. Simplicity, SRP, DRY & KISS | PASS with a note | Two axes, one matching rule shape, one wildcard; no enumeration, mask, aggregates, second gate or sentinel. Note: the tree carries 47 resources and 23 verbs, of which 17 verbs are module-specific and used once or twice. Each traces to a distinction an existing endpoint already makes, and the alternative — a closed verb set — was measured and rejected in D13. Re-examine at Milestone 2 if any module proposes a verb no endpoint distinguishes. |
Complexity Tracking
| Item | Justification | Exit condition |
|---|---|---|
Obsolete ConfigurePermissions(params string[]) retained indefinitely |
Third-party modules outside this repository must keep compiling across the upgrade. | Removed at the next major version. |
| Implicit unverified descriptors for unrecognized third-party permissions | Failing a host at boot because a module the operator does not own uses an unknown string is disproportionate; the existing unknown_permission_descriptor precedent warns instead. |
None; permanent, with the gap visible in the catalog. |
| Wildcard grants confer forward reach on the resource axis | This is the property that makes section-wide grants viable and removes the need for a second gate. | None; mitigated by catalog reach reporting. |