elsa-core/specs/013-rbac-authorization-model/spec.md
Sipke Schoorstra 74123110d5
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 23:44:55 +02:00

18 KiB

Feature Specification: Authorization Model

Feature Branch: 013-rbac-authorization-model

Created: 2026-08-23

Status: Draft — pending approval

Tracking: #7974

Input: A customer request for role-based access control, assessed in research.md, reframed against Elsa's existing identity, permission, and multitenancy infrastructure, domain language, and architecture decisions.

Product Context

Elsa already has most of an authorization system. Roles carry permissions, tokens carry permission claims, and 151 of 160 endpoint files declare a required permission. What it lacks is a model: the permission vocabulary is an open set of ad-hoc strings compared by ordinal equality, with no implication, no grouping, and no catalog.

The consequences are concrete. "*", "read:*" and "exec:*" are literal claim values rather than patterns, so read:* grants access only to the twelve endpoints that happen to list it, out of roughly forty read endpoints. read:workflow-definitions does not imply read:workflow-definitions:versions. Fifty-seven permission strings appear as inline literals across 174 call sites in three competing naming schemes, so a typo silently produces an unreachable endpoint and no user interface can render a sensible role editor. Omitting the declaration fails open. Four parallel permission-checking mechanisms — FastEndpoints permissions, named ASP.NET policies, hand-rolled claim inspection, and SignalR hub checks — leave no single place to audit. (Read-only mode also uses mid-handler authorization calls, but that is a separate axis and stays as it is.)

This feature replaces that vocabulary with a structured authorization model: a hierarchical resource axis, an open verb axis, a module-contributed permission catalog, and a single evaluator that every enforcement path routes through.

Both axes are open and string-keyed, because Elsa is a framework that third parties extend and ADR 0017 establishes an open permission vocabulary. A closed verb enumeration was drafted and rejected: fitting the census to seven verbs forced six mappings and three invented sub-resources, and every open question it produced was an artefact of the closure. Coherence is maintained by a recommended core verb set as convention, per Principle III. ADR 0022 remains in force: Elsa is the only authority that expands Roles into permission claims.

Clarifications

Session 2026-08-23

  • The permission vocabulary is a clean break. Canonical form is {resource}:{verb}, reversing today's {verb}:{resource}. The existing scheme is ad hoc and carries three competing conventions; it is replaced rather than preserved.
  • The resource axis is hierarchical with prefix matching. / separates depth, : separates resource from verb: workflows/definitions:view, workflows/*:view, *:*.
  • Wildcards are the only construct with forward reach, on either axis: workflows/* covers resources registered later, definitions:* covers verbs added later, and *:* is superuser without a special case.
  • Aggregates are not part of the model and no verb implies another, matching both Elsa's current behavior and the original proposal's own worked example. manage is a user-interface preset, not a model concept.
  • Descriptors declare which verbs each resource supports. This is required regardless — to render a role editor, validate a submitted grant, and report the current reach of a wildcard grant.
  • Storage is unchanged. Role.Permissions stays a string collection holding flat {resource}:{verb} entries.
  • Legacy permission strings stop matching. A startup validator reports them loudly and a migration document carries the full mapping. Operators of the requesting deployment have confirmed they will migrate rather than requiring a compatibility layer.
  • Revocation latency is addressed by lowering the default access-token lifetime from 1 hour to 15 minutes. An optional per-user security stamp is available where tighter bounds are required, and must not depend on cross-node cache invalidation, which Elsa does not have.
  • All in-repository endpoints migrate at once. ConfigurePermissions(params string[]) remains obsolete-but-functional so third-party modules keep compiling; an unresolvable third-party permission registers an implicit unverified descriptor and logs a warning rather than failing the host at boot.
  • Elsa gains no cross-tenant principal. Administering multiple tenants belongs to applications built above Elsa; machine access uses a tenant-scoped Application credential.
  • Tenancy hardening for Identity is in scope. Tenancy for Elsa.Secrets is not, and is tracked as #7972.

User Scenarios & Testing (mandatory)

User Story 1 - Grant a Whole Section in One Grant (Priority: P1)

An administrator creates a "Workflow Operator" role that may view everything under workflows and start instances, without enumerating individual resources and without that role silently missing endpoints added in a later release.

Acceptance: Granting workflows/*:view authorizes every current resource beneath workflows/, including definitions, instances, activity executions, and all descriptor endpoints. A resource added under workflows/ in a subsequent release is covered by the same grant with no role edit.

User Story 2 - Deny by Default, Fail Closed (Priority: P1)

A role holding no grant for a resource is refused, and an endpoint whose author forgot to declare a permission does not silently become public.

Acceptance: A caller with no matching grant receives 403. An automated build-time gate fails when an in-repository endpoint declares none of a permission, anonymous access, or authenticated-only access.

User Story 3 - Discover the Permission Catalog (Priority: P1)

A role editor renders the full set of grantable permissions, grouped for a human, without hard-coding strings that drift from the server.

Acceptance: A catalog endpoint returns every registered resource with its display metadata, category, and supported verbs. Every permission declared by an in-repository endpoint resolves to a registered descriptor.

User Story 4 - Know My Own Permissions (Priority: P1)

A client conditionally renders its interface — hiding sections, disabling actions, showing read-only states — from a single call, without probing endpoints.

Acceptance: GET /identity/me/permissions returns the caller's effective grants for the current tenant context. Resources the caller cannot access are present with an empty verb list rather than absent, so a client can distinguish "denied" from "unknown".

User Story 5 - Migrate an Existing Deployment (Priority: P2)

An operator upgrading a deployment with hand-authored roles learns exactly which stored permissions no longer resolve, and what to replace them with, instead of discovering it through user reports.

Acceptance: On startup, every unrecognized permission in a stored role is logged with its role name. A migration document maps every legacy permission to its replacement. The administrator role, granted *, continues to function throughout so an instance cannot be locked out.

User Story 6 - Revoke Access Promptly (Priority: P2)

Removing a role from a user takes effect within a bounded, documented window.

Acceptance: With default settings, revocation takes effect within the access-token lifetime. With the optional security stamp enabled, it takes effect within the configured stamp cache interval without requiring distributed cache infrastructure.

User Story 7 - Audit Role Changes (Priority: P2)

A compliance reviewer can reconstruct who changed which role, when, and what the resulting grants were.

Acceptance: Role creation, update, and deletion, and user role assignment and removal, each publish a typed security notification carrying the resulting grants.

User Story 8 - Keep Third-Party Modules Working (Priority: P3)

A module maintained outside this repository continues to function after upgrading, with its gaps visible rather than silent.

Acceptance: A third-party module calling the obsolete declaration API compiles and runs. Its unrecognized permissions register implicit descriptors marked unverified, appear in the catalog as such, and log a warning.

User Story 9 - Isolate Roles Between Tenants (Priority: P3)

Two tenants each define a role named Admin without collision, and neither can see the other's roles or users.

Acceptance: Role and user names are unique per tenant rather than globally. Listing roles or users returns only the current tenant's records under both the Entity Framework and in-memory stores.

Edge Cases

  • A grant whose resource matches but whose verb does not is refused; a partial match never partially authorizes.
  • A role holding no grant for a resource is denied; absence is denial, and there is no stored value meaning "no access".
  • A wildcard grant confers access to resources registered after the grant was authored. This is intended; the catalog makes current reach inspectable.
  • A concrete verb outside a resource's declared set, or a concrete resource with no descriptor, is rejected at role-authoring time. Wildcard segments are validated structurally and are accepted even when they currently match nothing, so a grant against a not-yet-installed module survives.
  • A permission string containing a comma is rejected, because the persistence converter joins collections with commas.
  • Two tenants holding roles of the same name must not collide, and a role must never resolve across a tenant boundary.
  • A caller authenticated by API key resolves grants through the application's roles by the same evaluator as an interactive user.
  • An endpoint declaring a resource with no registered descriptor fails the in-repository gate at startup.

Requirements (mandatory)

Functional Requirements

Permission Model

  • FR-001: A permission MUST be a pair of a hierarchical resource path and a verb.
  • FR-002: The canonical textual form MUST be {resource}:{verb}, with / separating resource path segments.
  • FR-003: The verb axis MUST be open and string-keyed, with verbs declared per resource by the owning module.
  • FR-004: The resource axis MUST remain open, with resources contributed by modules.
  • FR-005: A request MUST be authorized when a held grant matches both the required resource and the required verb.
  • FR-006: Elsa MUST publish a recommended core verb set that modules SHOULD reuse, and MUST NOT prevent a module declaring a verb outside it.
  • FR-007: Both axes MUST support an exact match and a wildcard: a trailing * matching a resource subtree, and * matching any verb. Wildcards MUST be the only construct conferring access to resources or verbs registered later.
  • FR-008: A role holding no matching grant MUST NOT be authorized; absence of a grant is denial.
  • FR-009: The model MUST NOT define verb aggregates, and no verb may imply another.
  • FR-010: Effective permissions MUST be the union of grants across all roles held by the principal.

Catalog and Descriptors

  • FR-011: Every module exposing protected endpoints MUST contribute permission descriptors through a registry hosted in core rather than in an optional module.
  • FR-012: A descriptor MUST declare the resource, its supported verbs, display name, description, and category.
  • FR-012a: Role create and update MUST reject a concrete resource with no registered descriptor, and a concrete verb outside that resource's supported verbs. Wildcard segments MUST be validated structurally only.
  • FR-013: The catalog MUST be exposed through an endpoint suitable for driving a role editor, and MUST mark verbs outside the recommended core set.
  • FR-014: Every permission declared by an in-repository endpoint MUST resolve to a registered descriptor, verified by an automated gate.
  • FR-015: The catalog MUST be able to report the resources a given wildcard grant currently covers.

Enforcement

  • FR-016: All permission decisions MUST route through a single evaluator. Authorization concerns that are not permission checks — notably read-only mode — are a separate axis and MUST retain their own enforcement.
  • FR-017: The existing hand-rolled permission-claim inspections, named-policy permission checks, and SignalR hub permission checks MUST be replaced by calls to that evaluator. This does NOT extend to the mid-handler NotReadOnlyPolicy calls in the workflow API: those enforce deployment read-only mode rather than a permission, and folding them into the permission evaluator would conflate two independent axes.
  • FR-018: Endpoints MUST declare their requirement as a resource constant plus a verb, with the resource constant shared with the descriptor declaration.
  • FR-019: Every in-repository endpoint MUST declare exactly one of: a required permission, anonymous access, or authenticated-only access. An endpoint declaring none MUST fail an automated build-time coverage gate. The authenticated-only state exists so that a deliberate "needs an identity but no grant" choice is distinguishable from an author's omission.
  • FR-020: A failed authorization check MUST return 403.
  • FR-021: Superuser access MUST be expressed within the model as the whole-vocabulary grant *:*, not as a special-cased sentinel.

Roles, Grants, and Introspection

  • FR-022: Roles MUST support create, read, update, and delete, scoped to a tenant.
  • FR-023: A caller MUST NOT be able to create or modify a role granting permissions the caller does not hold.
  • FR-024: A caller MUST NOT be able to assign a role granting permissions the caller does not hold.
  • FR-025: An endpoint MUST return the calling principal's effective grants for the current tenant context.
  • FR-026: That response MUST include resources the caller cannot access, carrying an empty verb list.
  • FR-027: Role and assignment mutations MUST publish typed security notifications, without this feature owning audit persistence.

Tokens and Revocation

  • FR-028: Permission claims MUST continue to be issued by Elsa alone, from roles.
  • FR-029: The default access-token lifetime MUST be 15 minutes, documented as the revocation bound. Refresh MUST continue to rotate both tokens and re-read roles, so that each refresh reflects current grants.
  • FR-030: An optional per-principal security stamp MUST be available to tighten that window.
  • FR-031: The security stamp MUST NOT require cross-node cache invalidation or additional infrastructure.

Migration and Compatibility

  • FR-032: Legacy permission strings MUST NOT authorize under the new vocabulary.
  • FR-033: Startup MUST report every stored permission that does not resolve, identified by role.
  • FR-034: A migration document MUST map every legacy permission to its replacement.
  • FR-035: The whole-vocabulary grant MUST survive migration unchanged, so an administrator cannot be locked out.
  • FR-036: The existing string-based declaration API MUST remain functional but obsolete.
  • FR-037: An unresolvable third-party permission MUST register an implicit descriptor marked unverified and log a warning, rather than preventing startup.

Tenancy

  • FR-038: Role and user names MUST be unique per tenant rather than globally.
  • FR-039: The default in-memory user and role stores MUST filter by tenant.
  • FR-040: Role and user listing MUST filter by tenant explicitly, not solely through an ambient persistence filter.
  • FR-041: Elsa MUST NOT introduce a principal that spans tenants.

Key Entities

  • Permission: a resource path paired with a verb; the unit of both declaration and grant.
  • Verb: an open, module-declared action name; Elsa publishes a recommended core set as convention.
  • Permission Descriptor: module-contributed metadata for one resource — supported verbs, display name, description, category.
  • Role: a tenant-scoped, named collection of permissions.
  • User: a tenant-scoped principal holding role identifiers.
  • Application: a tenant-scoped machine principal holding role identifiers, authenticated by API key or client credentials.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: A grant of workflows/*:view authorizes all resources beneath workflows/, where read:* authorizes twelve of approximately forty read endpoints today.
  • SC-002: Every in-repository endpoint declares a permission resolving to a registered descriptor, verified automatically; the current figure is 151 of 160 files declaring, with no descriptor coverage for core permissions.
  • SC-003: Permission decisions route through one evaluator, replacing four parallel permission-checking mechanisms — FastEndpoints permissions, named policies, hand-rolled claim inspections across fifteen files, and four SignalR hub checks. Read-only mode keeps its own enforcement and is out of scope.
  • SC-004: A role editor can be built with no hard-coded permission strings.
  • SC-005: An operator upgrading a deployment with legacy roles receives a complete, actionable startup report and cannot be locked out.
  • SC-006: Revocation takes effect within a documented bound under default settings, and within a configurable shorter bound with the optional stamp, without new infrastructure.
  • SC-007: Two tenants can each define a role named Admin, which is impossible today.

Assumptions

  • The requesting deployment has hand-authored roles in production and has confirmed it will migrate, so no permanent compatibility layer is required.
  • Whether the requesting deployment isolates per Elsa tenant or per Elsa instance is unconfirmed. Tenancy hardening is justified independently as a latent-defect fix and is sequenced last so the answer does not block delivery.
  • Product-level section taxonomies belong to applications built above Elsa, expressed as groupings over the resource tree.
  • Cross-tenant administration belongs to applications built above Elsa.
  • Elsa.Secrets tenancy is out of scope and tracked separately as #7972.
  • Studio and other clients live outside this repository and consume the catalog and introspection endpoints rather than hard-coded strings.