docs: authorization model design (spec 013) (#7978)

* docs: add authorization model design (spec 013)

Replaces Elsa's ad-hoc permission vocabulary with a structured two-axis
model. Design only -- no code changes.

A census of all 150 permission-declaring endpoints found the current
vocabulary has no model behind it: "read:*" is a literal claim value
rather than a pattern, so it authorizes 12 of roughly 40 read endpoints;
57 permission strings appear as inline literals across 174 call sites in
three competing naming schemes; omitting a declaration fails open; and
four parallel enforcement mechanisms leave no single place to audit.

A permission becomes {resource}:{verb}, with both axes open and
string-keyed and contributed by modules through descriptors. A trailing
wildcard matches the named node and all descendants, so workflows/*:view
is a single grant covering definitions, instances, executions and every
descriptor endpoint, including ones registered in later releases.
Wildcards are the only construct with forward reach; there are no
aggregates and no verb implies another. Coherence without closure comes
from a recommended core verb set as convention, per Principle III.

A closed verb enumeration was drafted and rejected: fitting the census to
seven verbs forced six mappings, invented three sub-resources, and every
open question it produced was an artefact of the closure.

Contents:
- spec.md: 41 functional requirements, 9 user stories, 7 success criteria
- plan.md: 5 milestones, constitution check, project structure
- research.md: grounded assessment and decision record D1-D23
- contracts/permissions.md: resource tree plus the full migration mapping,
  verified complete against all 57 literal permissions in the codebase
- contracts/rest-api.md: catalog, reach report, and introspection
- tasks.md: 63 tasks across 5 phases, tagged by user story

Breaking changes are documented in the spec and tracked in the issue:
legacy permission strings stop authorizing; the migration expands rather
than renames, because several sub-resources are granularity increases;
read:* and exec:* become materially more powerful; and the C#/Python
expression permissions are removed rather than translated, which is a
deliberate reduction in control.

Refs #7974, #7972, #7975

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: scope the evaluator consolidation to permission checks

FR-016 and FR-017 swept in the mid-handler AuthorizeAsync calls, which in
the workflow API are the NotReadOnlyPolicy checks. Those enforce
deployment read-only mode -- whether the instance accepts mutations at
all -- which is orthogonal to whether a principal holds a permission. A
workflow author with full grants is still refused while the deployment is
read-only, and correctly so. Folding them into the permission evaluator
would conflate two independent axes and make read-only mode expressible
as a grant, which it must not be.

The consolidation still covers four parallel mechanisms, but not the same
four: FastEndpoints permissions, named ASP.NET policies (3 sites),
hand-rolled claim inspections (15 files), and SignalR hub checks (4 hubs).

- FR-016 scoped to permission decisions, with the separate axis named
- FR-017 states the NotReadOnlyPolicy exclusion and why
- FR-018 said "scope value"; corrected to "verb" after D13 opened the
  verb axis
- SC-003, the plan's constitution row, scale figures and milestone 3
  updated to match
- D5 and D6 marked where they still reference the withdrawn mask
- D24 records the correction rather than rewriting the assessment
- Permission string count corrected from 56 to the verified 57

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: apply module-owner review outcomes to the authorization model

Resolves the five open vocabulary questions and the two model gaps they
surfaced.

- External Authentication descriptors get their own resource,
  external-authentication/descriptors:view, as a single node. One legacy
  permission governs all six endpoints, which is the same principle that
  gives workflows/descriptors nine separate resources -- those were
  separately permissioned already. The tree reflects the API in both cases.
- /user-options stays on identity-links:view. It is a user search backing
  the link picker and the linking UI cannot function without it. Recorded
  consequence: identity-link rights confer tenant-wide user enumeration in
  a reduced projection, without identity/users:view.
- The roles:assign descriptor is corrected to describe what it guards.
  Setting defaultRoleIds is guarded by the ordinary subset rule, so no
  escalation was possible either way.
- The two Broker/Logout.cs endpoints declare differently: Logout is
  authenticated-only because it reads the session claim from the
  principal, ContinueLogout is anonymous because the route handle carries
  the authority. ContinueLogout inheriting the authenticated default today
  is a probable live bug -- the identity provider redirects the browser
  there during upstream logout, possibly after the Elsa session is gone.
  The fail-closed gate surfaced it; this work did not introduce it.
- T028 splits four ways along resource-group seams (31/20/15/12 files)
  rather than landing as one 78-file pull request.

Two model gaps followed, one closed and one recorded:

- FR-019 now accepts a third declaration state, authenticated-only.
  Logout needs an identity but no grant, which the two-state rule could
  not express without either a fabricated permission or a gate exemption,
  and an exemption list is a hole in a fail-closed guarantee.
- Conjunctive requirements remain unexpressible. An endpoint declares one
  resource and one verb, so "needs link rights and user read" cannot be
  stated declaratively. Recorded so the next case is not solved ad hoc.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: link the authorization review follow-ups

Refs #7976, #7977

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: mark T062 complete

The five module-owner questions are resolved and folded into the
vocabulary, so Phase 2 is unblocked.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: address review findings on the authorization model

Automated review on #7978 surfaced several genuine gaps. Two changed the
model rather than the prose.

A bare `*` now parses as `*:*`. FR-021 forbids a superuser sentinel while
D2 requires a stored `*` to keep authorizing so no instance locks itself
out, and the seed default is ["*"]. These are reconciled at the parse
layer rather than the evaluation layer: a string with no colon consisting
solely of `*` normalizes to resource `*`, verb `*`. The evaluator never
sees a sentinel, so FR-021 holds.

Wildcards are validated structurally, not against the catalog.
`workflows/*` matches no single descriptor and `*` is deliberately absent
from supported verbs, so naive descriptor validation would have rejected
the grants US1 is built on. Concrete resources and verbs validate against
the registry; wildcard segments are accepted when syntactically well
formed, including when they match nothing today, since installing a
module later is what gives such a grant meaning. Adds FR-012a and
T022a/T022b, which also close a real gap: the role write paths persist
request.Permissions after only the caller-subset check, and no task had
wired registry validation into them.

Also:
- Counts corrected to 47 resources and 23 verbs; the PR said 45/21 and
  the tracking issue 44/21, having drifted as resources were added
- Post-design constitution re-check performed and recorded, with the 17
  module-specific verbs called out as a Principle VII note
- T025 scoped to descriptor consistency; it asserted endpoint resolution
  during Phase 2, when endpoints still declare legacy strings
- T047a carries the security stamp's provider migrations, so Phase 4 no
  longer depends on Phase 5 to be shippable
- T038a covers wildcard containment in RoleAuthorizationService
- Staleness guidance corrected: the catalog and reach report are registry
  snapshots, not token projections
- Abbreviated migration rows (`:write`, `:delete`, `:update`) spelled out
  so the table is mechanically checkable
- rest-api.md now lists all three endpoints and their differing access
- American English throughout, per the constitution

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: publish the migration guide alongside the contract

The vocabulary contract named docs/migrations/authorization-model.md as
the authoritative source for converting stored permissions, but the file
lived only on the implementation branch. A design change that
intentionally stops legacy grants authorizing must not point operators at
an upgrade guide it does not ship: following a dangling reference is how
roles get silently narrowed, or non-admin roles locked out, during an
upgrade.

Publishes the guide and ADR 0012 here, and marks T057-T061 complete. The
contract's reference is now a working relative link.

Refs #7974

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-08-24 16:19:22 +02:00 committed by GitHub
parent ae146a1765
commit 3921715060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1827 additions and 0 deletions

View file

@ -0,0 +1,33 @@
# Two-axis authorization model with open resources and open verbs
**Status**: Accepted
**Date**: 2026-08-24
## Decision
A permission is `{resource}:{verb}`. Both axes are open, string-keyed, and contributed by modules through permission descriptors discovered from the same assemblies as their endpoints.
The resource axis is hierarchical. A trailing `*` matches the named node and every descendant at any depth, so `workflows/*:view` is a single grant covering definitions, instances, executions and every descriptor endpoint, including resources registered in later releases. On the verb axis, `*` matches any verb. `*:*` is superuser, and a bare `*` normalizes to it at parse time.
Wildcards are the only construct with forward reach. There are no aggregates, and no verb implies another. Coherence without closure comes from a recommended core verb set — `view`, `create`, `update`, `write`, `delete`, `execute` — published as convention per Principle III, with the catalog marking non-core verbs for review. A resource declares either `create` + `update` or `write`, never both, depending on whether its API separates the operations.
All permission decisions route through a single evaluator. Concerns that are not permission checks — notably deployment read-only mode — keep their own enforcement.
## Rationale
The prior vocabulary had no model behind it. `read:*` was a literal claim value rather than a pattern, so it authorized twelve of roughly forty read endpoints; 57 permission strings appeared as inline literals across 174 call sites in three competing naming schemes; omitting a declaration failed open; and four parallel enforcement mechanisms left no single place to audit.
A **closed verb enumeration was drafted and rejected**. Fitting a census of all 150 permission-declaring endpoints to seven verbs forced six mappings, invented three sub-resources to express what the enum could not, and produced five open questions that were all artifacts of the closure — with only 16 first-party modules in one repository.
The decisive argument was that the enum was not buying what it appeared to. It had been justified on implication, but aggregates were already excluded, and no verb implies another in this model or in the proposal that prompted it. So the bitwise containment check expressed "a grant may carry several verbs and a requirement may need several", which set containment satisfies identically. With implication gone, the enum's remaining benefits were compactness — already surrendered by storing one string per resource-verb pair — and resembling the original proposal.
A coarse per-role module gate was also proposed and rejected. Its stated benefits — authoring broad roles without enumerating everything, and new endpoints being covered automatically — both fall out of the hierarchical resource axis, which delivers them in a single grant that composes with the verb axis instead of overriding it. Two independent gates keyed by the same taxonomy would drift, and a 403 would take two screens to explain.
## Consequences
- Legacy permission strings stop authorizing. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate, reported by a startup validator, and documented in `docs/migrations/authorization-model.md`. `*` survives, so no instance can lock itself out.
- Migration expands rather than renames where new sub-resources are finer-grained than what they replace.
- Wildcards confer forward reach on both axes. This is the property that makes section-wide grants viable; it is mitigated by a reach report showing what a grant covers today, and by a deployment-level allow/deny boundary.
- The vocabulary can fragment, since modules may coin synonyms. Mitigated by convention and by the catalog marking non-core verbs, not by enforcement.
- Every endpoint must declare exactly one of a permission, anonymous access, or authenticated-only access. An automated gate enforces this with no exemption list.

View file

@ -0,0 +1,161 @@
# Migrate to the Structured Authorization Model
Elsa's permission vocabulary changes shape. A permission is now `{resource}:{verb}` — a hierarchical resource path paired with a verb — replacing the flat `verb:resource` strings.
**This is a breaking change for any deployment with hand-authored roles.** Legacy permission strings stop authorizing. Nothing silently degrades: a startup validator reports every stored permission that no longer resolves, identified by the role that holds it.
## What you have to do
Re-author each role's permissions using the table below, or through the catalog at `GET /identity/permissions`, which lists every registered resource and the verbs it accepts.
**`*` keeps working.** It parses to `*:*`, so the seeded administrator role continues to authorize everything and an instance cannot lock itself out while the rest is re-authored. Do this first, before touching anything else.
## Three things that are not a simple rename
### The migration expands
Some new resources are finer-grained than the permissions they replace, so one legacy string becomes several. **A one-for-one substitution silently narrows the role.**
| Legacy | Expands to |
| --- | --- |
| `read:workflow-definitions` | `workflows/definitions:view` **and** `workflows/definitions/versions:view` |
| `delete:workflow-definitions` | `workflows/definitions:delete` **and** `workflows/definitions/versions:delete` |
| `publish:workflow-definitions` | `workflows/definitions:publish` **and** `workflows/definitions/versions:revert` |
| `external-authentication:links:manage` | `identity-links:view`, `:write` **and** `:delete` |
| `external-authentication:policies:manage` | `policies:view` **and** `:update` |
### `read:*` and `exec:*` become more powerful
Today they are literal claim values, not patterns: `read:*` authorizes only the twelve endpoints that happen to list it, out of roughly forty read endpoints. Their replacements, `*:view` and `*:execute`, work as the names always implied — across every resource, including ones added later.
**Review any role holding them by hand.** Do not rewrite them automatically.
### The C#/Python expression permissions are removed
`exec:csharp-expressions` and `exec:python-expressions` are dropped rather than translated. 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.
**This is a deliberate reduction in control.** The host switch (`CSharpOptions.AllowHostCodeExecution`, `PythonOptions.AllowHostCodeExecution`) becomes the single control:
- Where host code is **disabled**, nothing changes.
- Where host code is **enabled**, any author who may write workflow definitions may use C# and Python, and the editor offers those expression types to every such author.
Deployments that enabled host code while trusting only *some* authors lose that granularity until [#7975](https://github.com/elsa-workflows/elsa-core/issues/7975) lands. If that matters to you, disable host code until then.
## Revocation
The default access-token lifetime drops from 1 hour to **15 minutes**. This is the revocation bound: permission claims are issued at sign-in, and refreshing 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.
For a tighter bound, enable the optional permission stamp (`Identity:PermissionStamp:IsEnabled`). It is derived from the user's roles rather than stored, so it needs no schema change and no cross-node cache invalidation. `CacheLifetime`, default 30 seconds, is the effective bound when enabled.
## Third-party modules
Modules outside this repository keep compiling. `ConfigurePermissions(params string[])` remains available but obsolete, and a permission that resolves to no registered descriptor registers an implicit one marked unverified, logs a warning, and appears as such in the catalog. The module keeps working and the gap stays visible.
## Per-tenant identity uniqueness
Included in the same release: `User.Name`, `Role.Name`, `Application.Name` and `Application.ClientId` move from globally unique indexes to composite indexes on `(TenantId, Name)`. Two tenants could not previously hold a role of the same name. Apply the `PerTenantIdentityUniqueness` migration for your provider.
If you have duplicate names across tenants today, they were impossible to create, so no data conflict can arise. Going the other way — downgrading — will fail if duplicates exist by then.
## Full mapping
| Legacy permission | Replacement |
| --- | --- |
| `*` | `*:*` |
| `read:*` | `*:view` |
| `exec:*` | `*:execute` |
| `read:workflow-definitions` | `workflows/definitions:view` **+** `workflows/definitions/versions:view` |
| `write:workflow-definitions` | `workflows/definitions:write` |
| `delete:workflow-definitions` | `workflows/definitions:delete` **+** `workflows/definitions/versions:delete` |
| `exec:workflow-definitions` | `workflows/definitions:execute` |
| `publish:workflow-definitions` | `workflows/definitions:publish` **+** `workflows/definitions/versions:revert` |
| `retract:workflow-definitions` | `workflows/definitions:retract` |
| `actions:workflow-definitions:refresh` | `workflows/definitions:refresh` |
| `actions:workflow-definitions:reload` | `workflows/definitions:reload` |
| `read:workflow-definition-labels` | `workflows/definitions/labels:view` |
| `update:workflow-definition-labels` | `workflows/definitions/labels:update` |
| `read:workflow-instances` | `workflows/instances:view` |
| `write:workflow-instances` | `workflows/instances:write` |
| `delete:workflow-instances` | `workflows/instances:delete` |
| `cancel:workflow-instances` | `workflows/instances:cancel` |
| `read:activity-execution` | `workflows/activity-executions:view` |
| `read:workflow-runtime` | `workflows/runtime:view` |
| `ManageWorkflowRuntime` | `workflows/runtime:control` |
| `read:bookmark-queue:dead-letters` | `workflows/bookmark-queue/dead-letters:view` |
| `replay:bookmark-queue:dead-letters` | `workflows/bookmark-queue/dead-letters:replay` |
| `delete:bookmark-queue:dead-letters` | `workflows/bookmark-queue/dead-letters:delete` |
| `trigger:event` | `workflows/events:trigger` |
| `tasks:complete` | `workflows/tasks:complete` |
| `exec:tests` | `workflows/tests:execute` |
| `read:activity-descriptors` | `workflows/descriptors/activities:view` |
| `read:activity-descriptors-options` | `workflows/descriptors/activities:view` |
| `read:expression-descriptors` | `workflows/descriptors/expressions:view` |
| `read:storage-drivers` | `workflows/descriptors/storage-drivers:view` |
| `read:variable-descriptors` | `workflows/descriptors/variables:view` |
| `read:commit-strategies` | `workflows/descriptors/commit-strategies:view` |
| `read:incident-strategies` | `workflows/descriptors/incident-strategies:view` |
| `read:log-persistence-strategies` | `workflows/descriptors/log-persistence-strategies:view` |
| `read:output-converters` | `workflows/descriptors/output-converters:view` |
| `read:workflow-activation-strategies` | `workflows/descriptors/activation-strategies:view` |
| `read:javascript-type-definitions` | `workflows/scripting/javascript:view` |
| `exec:csharp-expressions` | *removed* — see #7975 |
| `exec:python-expressions` | *removed* — see #7975 |
| `read:user` | `identity/users:view` |
| `create:user` | `identity/users:create` |
| `update:user` | `identity/users:update` |
| `delete:user` | `identity/users:delete` |
| `read:role` | `identity/roles:view` |
| `create:role` | `identity/roles:create` |
| `update:role` | `identity/roles:update` |
| `delete:role` | `identity/roles:delete` |
| `create:application` | `identity/applications:create` |
| `read:secrets` | `secrets:view` |
| `write:secrets` | `secrets:write` |
| `delete:secrets` | `secrets:delete` |
| `test:secrets` | `secrets:test` |
| `use:secrets` | *removed* — unused |
| `import:secrets` | *removed* — unused |
| `export:secrets` | *removed* — unused |
| `external-authentication:connections:read` | `external-authentication/connections:view` **+** `external-authentication/descriptors:view` |
| `external-authentication:connections:create` | `external-authentication/connections:create` |
| `external-authentication:connections:update` | `external-authentication/connections:update` |
| `external-authentication:connections:archive` | `external-authentication/connections:archive` |
| `external-authentication:connections:test` | `external-authentication/connections:test` |
| `external-authentication:connections:preview` | `external-authentication/connections:preview` |
| `external-authentication:links:manage` | `external-authentication/identity-links:view` **+** `external-authentication/identity-links:write` **+** `external-authentication/identity-links:delete` |
| `external-authentication:sessions:read` | `external-authentication/sessions:view` |
| `external-authentication:sessions:revoke` | `external-authentication/sessions:revoke` |
| `external-authentication:policies:manage` | `external-authentication/policies:view` **+** `external-authentication/policies:update` |
| `external-authentication:roles:assign` | `external-authentication/policies/default-roles:update` |
| `external-authentication:provider-trust:unsafe` | `external-authentication/provider-trust:override` |
| `external-authentication:permissions:delegate` | `external-authentication/permission-grants:delegate` |
| `external-authentication:permissions:delegate-unrestricted` | `external-authentication/permission-grants:delegate-unrestricted` |
| `read:dashboard` | `dashboard:view` |
| `read:diagnostics:console-logs` | `diagnostics/console-logs:view` |
| `read:diagnostics:structured-logs` | `diagnostics/structured-logs:view` |
| `read:diagnostics:opentelemetry` | `diagnostics/opentelemetry:view` |
| `ingest:diagnostics:opentelemetry` | *removed* — unused |
| `read:resilience` | `resilience/*:view` |
| `read:resilience:retries` | `resilience/retries:view` |
| `read:resilience:strategies` | `resilience/strategies:view` |
| `exec:resilience` | `resilience/*:execute` |
| `exec:resilience:simulate-response` | `resilience/simulation:execute` |
| `read:alterations` | `alterations:view` |
| `run:alterations` | `alterations:execute` |
| `read:labels` | `labels:view` |
| `create:labels` | `labels:create` |
| `update:labels` | `labels:update` |
| `delete:labels` | `labels:delete` |
| `read:tenants` | `tenants:view` |
| `write:tenants` | `tenants:write` |
| `delete:tenants` | `tenants:delete` |
| `execute:tenants:refresh` | `tenants:refresh` |
| `read:installed-features` | `system/features:view` |
| `actions:shells:reload` | `system/shells:reload` |
| `ai:chat` | `ai/chat:execute` |
| `ai:tools:view` | `ai/tools:view` |
| `ai:capabilities:view` | `ai/capabilities:view` |
| `ai:tools:manage` | *removed* — unused |
| `ai:proposals:view` | *removed* — unused |
| `ai:proposals:approve` | *removed* — unused |
| `ai:proposals:apply` | *removed* — unused |

View file

@ -0,0 +1,279 @@
# Contract: Permission Vocabulary
**Status**: Draft — module-owner review pass completed 2026-08-23
**Derived from**: a full census of every permission-declaring endpoint across 16 modules
A permission is `{resource}:{verb}`. Both axes are open, string-keyed, and contributed by modules through descriptors.
## Structure
```
permission := {resource}:{verb}
resource := hierarchical, '/'-separated
verb := flat string
satisfies := resourceMatches(granted, required) && verbMatches(granted, required)
```
**Wildcards.** A trailing `*` on the resource axis matches the named node **and every descendant at any depth**: `workflows/definitions/*` covers `workflows/definitions`, `workflows/definitions/versions` and `workflows/definitions/labels`. A bare `*` matches every resource. On the verb axis, `*` matches any verb. `*:*` is the whole vocabulary.
Including the node itself is deliberate — it is how an administrator reads "grant this subtree", and it behaves consistently whether or not the parent is itself a resource. Withholding a parent while granting children remains possible by naming the children.
Wildcards are the only construct with forward reach: they cover resources and verbs registered in later releases. There are no aggregates, and no verb implies another.
**A bare `*` parses as `*:*`.** A permission string containing no `:` and consisting solely of `*` is normalized at parse time to resource `*`, verb `*`. This is a parsing rule, not an evaluation special case, so FR-021 holds: the evaluator never sees a sentinel and superuser is an ordinary grant. It is what allows the seeded admin role (`DefaultAdminUserOptions.AdminRolePermissions`, defaulting to `["*"]`) and any stored `*` to keep authorizing across the migration without a lock-out window, and it is why the migration table maps `*` to `*:*` as a normalization rather than a behavioral change.
**Wildcards are valid in grants and are validated structurally, not against the catalog.** A grant may name a wildcard resource, a wildcard verb, or both. Because `workflows/*` matches no single descriptor and `*` is deliberately absent from any resource's supported verbs, descriptor validation applies only to *concrete* grants: a concrete resource must be registered, and a concrete verb must appear in that resource's supported verbs. A wildcard segment is accepted whenever it is syntactically well formed, including when it currently matches nothing — a grant written against a module that is not yet installed must survive, since installing the module later is what gives it meaning. The reach report is how an author sees what a wildcard covers today.
## Core verbs (convention, not enforcement)
| Verb | Meaning |
| --- | --- |
| `view` | read, list, query, inspect, export |
| `create` | bring a new record into existence |
| `update` | modify an existing record |
| `write` | create or modify, where the API does not separate the two |
| `delete` | remove a record |
| `execute` | run, dispatch, or invoke against a live system |
**A resource declares either `create` + `update`, or `write` — never both.** Which one depends on whether the module's API separates the operations. Never-both is what stops `write` becoming an aggregate: within any one resource there is no ambiguity about which verb an endpoint requires, so no implication is needed and FR-009 holds. `update` is rejected for upsert endpoints because it misdescribes the grant — `POST /workflow-definitions` creates when no definition ID is supplied. A resource may declare just one where only one operation exists.
Any other verb is module-specific and legitimate; the catalog marks non-core verbs so a reviewer can spot needless synonyms. The check is "is this a redundant synonym", not "is this on an approved list".
## Resource tree
Verbs marked ★ are module-specific.
### Workflows
| Resource | Verbs |
| --- | --- |
| `workflows/definitions` | view, write, delete, execute, publish★, retract★, refresh★, reload★ |
| `workflows/definitions/versions` | view, delete, revert★ |
| `workflows/definitions/labels` | view, update |
| `workflows/instances` | view, write, delete, cancel★ |
| `workflows/activity-executions` | view |
| `workflows/runtime` | view, control★ |
| `workflows/bookmark-queue/dead-letters` | view, delete, replay★ |
| `workflows/events` | trigger★ |
| `workflows/tasks` | complete★ |
| `workflows/tests` | execute |
| `workflows/descriptors/activities` | view |
| `workflows/descriptors/expressions` | view |
| `workflows/descriptors/storage-drivers` | view |
| `workflows/descriptors/variables` | view |
| `workflows/descriptors/commit-strategies` | view |
| `workflows/descriptors/incident-strategies` | view |
| `workflows/descriptors/log-persistence-strategies` | view |
| `workflows/descriptors/output-converters` | view |
| `workflows/descriptors/activation-strategies` | view |
| `workflows/scripting/javascript` | view |
`refresh` and `reload` are both retained and are genuinely distinct: `refresh` is targeted (takes definition IDs, `IWorkflowDefinitionsRefresher`), `reload` is wholesale (`IWorkflowDefinitionsReloader`).
`workflows/runtime:control` is one verb because a single permission governs pause, resume and force-drain today. Named `control` rather than `manage` so it is not mistaken for an aggregate; splitting into `pause`/`resume`/`drain` later is additive.
The nine `workflows/descriptors/*` resources stay distinct because the hierarchy already collapses them — `workflows/descriptors/*:view` grants all nine — so distinctness costs nothing and preserves the option to withhold one.
BPMN interchange reuses `workflows/definitions`: analyze and export require `view`, import requires `write`.
**`exec:csharp-expressions` and `exec:python-expressions` are not carried forward.** See [research.md](../research.md) D21 and [#7975](https://github.com/elsa-workflows/elsa-core/issues/7975). They conflated an incoherent execution-side gate with a meaningful authoring-side one; the host switch (`AllowHostCodeExecution`) becomes the single control. This is a deliberate reduction in control and must be prominent in the migration document.
### Identity
| Resource | Verbs |
| --- | --- |
| `identity/users` | view, create, update, delete |
| `identity/roles` | view, create, update, delete |
| `identity/applications` | create |
`identity/applications` declares `create` alone because a create endpoint is all that exists.
### Secrets
| Resource | Verbs |
| --- | --- |
| `secrets` | view, write, delete, test★ |
`write` covers rotate and revoke, as `write:secrets` does today. `use:secrets`, `import:secrets` and `export:secrets` are dropped as unused ([research.md](../research.md) D14).
### External Authentication
| Resource | Verbs |
| --- | --- |
| `external-authentication/connections` | view, create, update, archive★, test★, preview★ |
| `external-authentication/descriptors` | view |
| `external-authentication/identity-links` | view, write, delete |
| `external-authentication/sessions` | view, revoke★ |
| `external-authentication/policies` | view, update |
| `external-authentication/policies/default-roles` | update |
| `external-authentication/provider-trust` | override★ |
| `external-authentication/permission-grants` | delegate★, delegate-unrestricted★ |
**Connections have no hard delete.** `DELETE /connections/{connectionId}` maps to the archive permission and is paired with `restore`; enable and disable map to update. The absence of `delete` here is correct, not an omission.
`delegate` and `delegate-unrestricted` govern which Elsa permissions an external claim mapping may confer: `delegate` allows mapping only permissions the actor already holds, `delegate-unrestricted` lifts that restriction. They are a **privilege tier**, not sibling actions — `DefaultPermissionDelegationAuthorizer` computes `mayDelegate = unrestricted || hasDelegate`. That implication stays application logic in the module and is deliberately not modeled; FR-009's absence of verb implication is correct, not a gap. Both remain verbs because "unrestricted" is a mode of one action, not a thing being administered.
`policies/default-roles` is a sub-resource because the default-role list — the roles granted to a user auto-created for an unknown external identity — genuinely is a distinct thing being administered.
### Diagnostics, dashboard, and operations
| Resource | Verbs |
| --- | --- |
| `dashboard` | view |
| `diagnostics/console-logs` | view |
| `diagnostics/structured-logs` | view |
| `diagnostics/opentelemetry` | view |
| `resilience/retries` | view |
| `resilience/strategies` | view |
| `resilience/simulation` | execute |
| `alterations` | view, execute |
| `labels` | view, create, update, delete |
`ingest:diagnostics:opentelemetry` is dropped as declared-but-unused, consistent with D14.
### Platform
| Resource | Verbs |
| --- | --- |
| `tenants` | view, write, delete, refresh★ |
| `system/features` | view |
| `system/shells` | reload★ |
| `ai/chat` | execute |
| `ai/tools` | view |
| `ai/capabilities` | view |
`ai/proposals` and `ai:tools:manage` are not carried forward — they guard no endpoint. The AI module declares them when its endpoints ship ([research.md](../research.md) D14).
## Migration mapping
The source for [`docs/migrations/authorization-model.md`](../../../docs/migrations/authorization-model.md), which is the operator-facing guide and is published alongside this contract. Full legacy strings, so it is checkable mechanically.
**Several mappings expand rather than rename**, because some new sub-resources are granularity increases. A migration must expand, not substitute.
| Legacy permission | New permission(s) |
| --- | --- |
| `*` | `*:*` |
| `read:*` | `*:view` |
| `exec:*` | `*:execute` |
| `read:workflow-definitions` | `workflows/definitions:view` **+** `workflows/definitions/versions:view` |
| `write:workflow-definitions` | `workflows/definitions:write` |
| `delete:workflow-definitions` | `workflows/definitions:delete` **+** `workflows/definitions/versions:delete` |
| `exec:workflow-definitions` | `workflows/definitions:execute` |
| `publish:workflow-definitions` | `workflows/definitions:publish` **+** `workflows/definitions/versions:revert` |
| `retract:workflow-definitions` | `workflows/definitions:retract` |
| `actions:workflow-definitions:refresh` | `workflows/definitions:refresh` |
| `actions:workflow-definitions:reload` | `workflows/definitions:reload` |
| `read:workflow-definition-labels` | `workflows/definitions/labels:view` |
| `update:workflow-definition-labels` | `workflows/definitions/labels:update` |
| `read:workflow-instances` | `workflows/instances:view` |
| `write:workflow-instances` | `workflows/instances:write` |
| `delete:workflow-instances` | `workflows/instances:delete` |
| `cancel:workflow-instances` | `workflows/instances:cancel` |
| `read:activity-execution` | `workflows/activity-executions:view` |
| `read:workflow-runtime` | `workflows/runtime:view` |
| `ManageWorkflowRuntime` | `workflows/runtime:control` |
| `read:bookmark-queue:dead-letters` | `workflows/bookmark-queue/dead-letters:view` |
| `replay:bookmark-queue:dead-letters` | `workflows/bookmark-queue/dead-letters:replay` |
| `delete:bookmark-queue:dead-letters` | `workflows/bookmark-queue/dead-letters:delete` |
| `trigger:event` | `workflows/events:trigger` |
| `tasks:complete` | `workflows/tasks:complete` |
| `exec:tests` | `workflows/tests:execute` |
| `read:activity-descriptors` | `workflows/descriptors/activities:view` |
| `read:activity-descriptors-options` | `workflows/descriptors/activities:view` |
| `read:expression-descriptors` | `workflows/descriptors/expressions:view` |
| `read:storage-drivers` | `workflows/descriptors/storage-drivers:view` |
| `read:variable-descriptors` | `workflows/descriptors/variables:view` |
| `read:commit-strategies` | `workflows/descriptors/commit-strategies:view` |
| `read:incident-strategies` | `workflows/descriptors/incident-strategies:view` |
| `read:log-persistence-strategies` | `workflows/descriptors/log-persistence-strategies:view` |
| `read:output-converters` | `workflows/descriptors/output-converters:view` |
| `read:workflow-activation-strategies` | `workflows/descriptors/activation-strategies:view` |
| `read:javascript-type-definitions` | `workflows/scripting/javascript:view` |
| `exec:csharp-expressions` | *removed* — see #7975 |
| `exec:python-expressions` | *removed* — see #7975 |
| `read:user` | `identity/users:view` |
| `create:user` | `identity/users:create` |
| `update:user` | `identity/users:update` |
| `delete:user` | `identity/users:delete` |
| `read:role` | `identity/roles:view` |
| `create:role` | `identity/roles:create` |
| `update:role` | `identity/roles:update` |
| `delete:role` | `identity/roles:delete` |
| `create:application` | `identity/applications:create` |
| `read:secrets` | `secrets:view` |
| `write:secrets` | `secrets:write` |
| `delete:secrets` | `secrets:delete` |
| `test:secrets` | `secrets:test` |
| `use:secrets` | *removed* — unused |
| `import:secrets` | *removed* — unused |
| `export:secrets` | *removed* — unused |
| `external-authentication:connections:read` | `external-authentication/connections:view` **+** `external-authentication/descriptors:view` |
| `external-authentication:connections:create` | `external-authentication/connections:create` |
| `external-authentication:connections:update` | `external-authentication/connections:update` |
| `external-authentication:connections:archive` | `external-authentication/connections:archive` |
| `external-authentication:connections:test` | `external-authentication/connections:test` |
| `external-authentication:connections:preview` | `external-authentication/connections:preview` |
| `external-authentication:links:manage` | `external-authentication/identity-links:view` **+** `external-authentication/identity-links:write` **+** `external-authentication/identity-links:delete` |
| `external-authentication:sessions:read` | `external-authentication/sessions:view` |
| `external-authentication:sessions:revoke` | `external-authentication/sessions:revoke` |
| `external-authentication:policies:manage` | `external-authentication/policies:view` **+** `external-authentication/policies:update` |
| `external-authentication:roles:assign` | `external-authentication/policies/default-roles:update` |
| `external-authentication:provider-trust:unsafe` | `external-authentication/provider-trust:override` |
| `external-authentication:permissions:delegate` | `external-authentication/permission-grants:delegate` |
| `external-authentication:permissions:delegate-unrestricted` | `external-authentication/permission-grants:delegate-unrestricted` |
| `read:dashboard` | `dashboard:view` |
| `read:diagnostics:console-logs` | `diagnostics/console-logs:view` |
| `read:diagnostics:structured-logs` | `diagnostics/structured-logs:view` |
| `read:diagnostics:opentelemetry` | `diagnostics/opentelemetry:view` |
| `ingest:diagnostics:opentelemetry` | *removed* — unused |
| `read:resilience` | `resilience/*:view` |
| `read:resilience:retries` | `resilience/retries:view` |
| `read:resilience:strategies` | `resilience/strategies:view` |
| `exec:resilience` | `resilience/*:execute` |
| `exec:resilience:simulate-response` | `resilience/simulation:execute` |
| `read:alterations` | `alterations:view` |
| `run:alterations` | `alterations:execute` |
| `read:labels` | `labels:view` |
| `create:labels` | `labels:create` |
| `update:labels` | `labels:update` |
| `delete:labels` | `labels:delete` |
| `read:tenants` | `tenants:view` |
| `write:tenants` | `tenants:write` |
| `delete:tenants` | `tenants:delete` |
| `execute:tenants:refresh` | `tenants:refresh` |
| `read:installed-features` | `system/features:view` |
| `actions:shells:reload` | `system/shells:reload` |
| `ai:chat` | `ai/chat:execute` |
| `ai:tools:view` | `ai/tools:view` |
| `ai:capabilities:view` | `ai/capabilities:view` |
| `ai:tools:manage` | *removed* — unused |
| `ai:proposals:view` | *removed* — unused |
| `ai:proposals:approve` | *removed* — unused |
| `ai:proposals:apply` | *removed* — unused |
Note that `read:*` and `exec:*` become materially *more* powerful: today they authorize only the twelve and one endpoints that happen to list them, whereas `*:view` and `*:execute` work as their names always implied. Operators should review any role holding them.
## Naming rules
- Resource segments are lowercase, hyphenated, plural where they denote a collection.
- Verbs are lowercase, hyphenated, imperative.
- Depth reflects a distinction endpoints actually make, or a subtree an administrator would plausibly grant as a unit — not the module layout for its own sake.
- A resource is owned by exactly one module, which declares its constant and descriptor together in `Permissions/<Module>Permissions.cs`.
- Permission strings may never contain a comma, because the persistence converter joins collections with commas.
## Module-owner review outcomes
Resolved 2026-08-23.
1. **Workflow-definition upsert** — `POST /workflow-definitions` maps to a single `write` verb. Confirmed; see D17.
2. **External Authentication descriptors** — the six `/descriptors/*` endpoints get their own resource, `external-authentication/descriptors:view`, rather than folding into `connections:view`. One resource rather than six sub-nodes, because one legacy permission governs all six — the same principle that gives `workflows/descriptors/*` nine separate resources, since those were separately permissioned already. A read-only support role can now see which adapters and policies are installed without seeing connection configuration.
3. **`/external-authentication/user-options`** — stays on `external-authentication/identity-links:view`. It is a user *search* endpoint backing the link picker, returning a minimal projection (id and display name) scoped to the tenant, and the linking UI cannot function without it. **Recorded consequence:** holding identity-link rights therefore confers tenant-wide user enumeration in that reduced projection, without `identity/users:view`. Moving it to `identity/users:view` would either over-grant full user read on migration or break linking. Requiring both is the honest answer but needs conjunctive requirements, which the model does not support — see the note below.
4. **`roles:assign` descriptor** — corrected to describe what it actually guards: removing policy references to a role during role deletion. Setting `defaultRoleIds` is guarded by `RoleAuthorizationService.CanAssignRolesAsync`, the ordinary subset rule, so no escalation is possible either way. Whether it *should* additionally require this permission is filed separately as [#7977](https://github.com/elsa-workflows/elsa-core/issues/7977).
5. **`Broker/Logout.cs`** — the two endpoints declare differently. `Logout` is authenticated-only: it reads the external session claim from the principal, so it needs an identity but no permission. `ContinueLogout` is `AllowAnonymous`, matching every other broker callback — the route `handle` carries the authority, and the identity provider redirects the browser there during upstream logout, potentially after the Elsa session is gone. **`ContinueLogout` inheriting the authenticated default today is a probable live bug**, filed as [#7976](https://github.com/elsa-workflows/elsa-core/issues/7976).
### Two model implications from these outcomes
**A third declaration state is required.** `Logout` is deliberately authenticated-without-permission, which FR-019 and the coverage gate cannot currently express — they accept only "a permission" or "anonymous". An explicit authenticated-only marker is needed so the gate distinguishes a deliberate choice from an author's omission.
**Conjunctive requirements are not expressible.** An endpoint declares one resource and one verb, so "needs link rights *and* user read" cannot be stated declaratively — outcome 3 above is the first case to want it, and `ExternalAuthenticationRoleDeletionDependencyContributor` already does it imperatively across three permissions. Not needed for this work; recorded because the next such case should not be solved ad hoc.

View file

@ -0,0 +1,112 @@
# Contract: Catalog and Introspection Endpoints
**Status**: Draft for review
Three read-only endpoints support role authoring and client-side rendering. All live in `Elsa.Identity` under the standard Elsa route prefix (`elsa/api` by default), but they do not share an access requirement:
| Endpoint | Access | Purpose |
| --- | --- | --- |
| `GET /identity/permissions` | `identity/roles:view` | The catalog a role editor renders |
| `GET /identity/permissions/reach` | `identity/roles:view` | What a wildcard grant currently covers |
| `GET /identity/me/permissions` | authenticated only | The caller's own effective grants |
The first two are permission-guarded because they describe what roles *can* contain. The third is deliberately not: any authenticated principal may ask what it holds.
## `GET /identity/permissions` — the catalog
Returns every registered resource with its metadata and supported verbs. This is what a role editor renders; no client should hard-code permission strings.
**Requires**: `identity/roles:view` — if you may see roles, you may see what roles can contain.
```json
{
"coreVerbs": ["view", "create", "update", "write", "delete", "execute"],
"resources": [
{
"resource": "workflows/definitions",
"displayName": "Workflow definitions",
"description": "Author, publish, and run workflow definitions.",
"category": "Workflows",
"supportedVerbs": ["view", "write", "delete", "execute", "publish", "retract"],
"nonCoreVerbs": ["publish", "retract"],
"verified": true
},
{
"resource": "acme/widgets",
"displayName": "acme/widgets",
"description": "No descriptor registered; inferred from an endpoint declaration.",
"category": "Unverified",
"supportedVerbs": ["view"],
"nonCoreVerbs": [],
"verified": false
}
]
}
```
`verified: false` marks an implicit descriptor auto-registered for a third-party permission that resolved to no declared descriptor. The module keeps working and the gap stays visible — see [research.md](../research.md) D9.
`coreVerbs` is the recommended set modules should reuse; `nonCoreVerbs` flags a resource's module-specific verbs so a reviewer can spot needless synonyms. Neither restricts what a module may declare — see [permissions.md](permissions.md).
The wildcard `*` is deliberately absent from both lists. It is not a verb a user selects from a menu; it is the "any verb" grant, written as `workflows/definitions:*`, and it is the only construct on this axis with forward reach.
## `GET /identity/permissions/reach` — wildcard reach report
Answers "what does this grant actually cover right now", which is the mitigation for forward reach on the resource axis.
**Requires**: `identity/roles:view`
**Query**: `?resource=workflows/*`
```json
{
"resource": "workflows/*",
"covers": [
"workflows/definitions",
"workflows/definitions/versions",
"workflows/instances",
"workflows/descriptors/activities"
],
"count": 22
}
```
The response is a point-in-time snapshot. A wildcard grant also covers resources registered later; the report says what is registered now, and the role editor should present it as such rather than as a fixed list.
## `GET /identity/me/permissions` — the caller's effective grants
Returns the union of grants across all roles held by the calling principal, in the current tenant context. Clients use this to hide sections, disable actions, and show read-only states without probing endpoints.
**Requires**: authentication only.
```json
{
"grants": [
{ "resource": "workflows/definitions", "verbs": ["view", "publish"] },
{ "resource": "workflows/instances", "verbs": ["view", "execute"] },
{ "resource": "dashboard", "verbs": ["view"] },
{ "resource": "secrets", "verbs": [] },
{ "resource": "identity/users", "verbs": [] }
]
}
```
Three properties matter here:
**Every registered resource is present, including those with an empty `verbs` array.** A client can then distinguish "explicitly denied" from "unknown to this server", which is what makes it safe to drive rendering from this response. This follows the original proposal's stated requirement, with an empty array standing where their contract had `scope: 0`.
**Verbs are resolved, not wildcarded.** A principal granted `workflows/*:*` sees each covered resource listed with its concrete supported verbs, rather than a literal `"*"`. Clients then need no matching logic: the check is `verbs.includes(required)`.
**This replaces the integer `scope` field** the proposed contract specified. The bitwise check `(userScope & requiredScope) === requiredScope` becomes array containment, which is the same semantics — no verb ever implied another in either model — and avoids an administrator's grant rendering as `4294967295`.
**The source of truth is always server-side.** This response exists for rendering; it is not an authorization decision, and every protected endpoint re-evaluates independently.
## Staleness
The three endpoints have different freshness characteristics, and clients should not cache them alike.
**The catalog and the reach report are registry snapshots.** They describe what the server has registered, not what the caller holds, so they are unaffected by role changes and token issuance. They change only when installed modules change, which for most deployments means on restart.
**`GET /identity/me/permissions` reflects the caller's token**, which carries permission claims issued at sign-in. A role change therefore takes effect on the next token issuance, bounded by the access-token lifetime, or sooner where the optional security stamp is enabled. Clients that surface role administration should refresh after a change rather than assuming this response is live.
In all cases the source of truth is server-side: these responses exist for rendering, and every protected endpoint re-evaluates independently.

View file

@ -0,0 +1,178 @@
# Implementation Plan: Authorization Model
**Status**: Draft — pending approval
**Tracking**: [#7974](https://github.com/elsa-workflows/elsa-core/issues/7974)
**Branch**: `013-rbac-authorization-model` | **Date**: 2026-08-23 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/013-rbac-authorization-model/spec.md`, grounded in [research.md](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 0004](../../docs/adr/0004-separate-external-identity-from-elsa-authorization.md) 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](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 0009](../../docs/adr/0009-match-unlinked-identities-with-trusted-user-matchers.md)). 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
```text
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
```text
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](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 `docs/migrations/authorization-model.md`, following the shape of `docs/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`, `IPermissionEvaluator` and its implementation.
- Promote the descriptor registry from `Elsa.ExternalAuthentication` into `Elsa.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.cs` carrying 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_descriptor` warning 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 `NotReadOnlyPolicy` calls 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 empty `verbs` array.
- 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 0007](../../docs/adr/0007-publish-audit-ready-security-notifications.md).
### 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](https://github.com/elsa-workflows/elsa-core/issues/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. |

View file

@ -0,0 +1,718 @@
# Authorization model: assessment and design record
## Context
A request was raised for a five-layer role-based access control model: Modules → Roles →
Functionalities → Scopes (bitmask) → Role assignments. The proposal was treated as a suggestion; the
task was to capture the underlying requirements and decide what Elsa should actually do.
Two questions were put directly: **does a coarse "module"/domain gate make sense as its own layer**,
and **is a bitmask scope design a good idea**. The short answers, expanded below, are *no — not as a
second runtime gate; the requirement belongs in layers Elsa already has* and *yes in substance, no in
the form proposed*.
**Decisions taken (2026-08-20):**
- The **foundation lands in elsa-core**; product-level section taxonomies and role editors are
built on top of it by downstream applications.
- **Users remain one-per-tenant.** A person operating in two tenants has two user records. We do
not adopt `UserRoleAssignment { userId, roleId, tenantId }`, and login gains no tenant selection.
- **We push back on the per-role module toggle** and offer the three homes it already has in Elsa.
- **Scope is a closed flags enum; the resource axis stays open strings.**
---
## 1. What Elsa already has
Far more than the ticket assumes. The endpoint-declaration layer is essentially complete.
| Piece | Where | State |
|---|---|---|
| `Role { Name, ICollection<string> Permissions }` | [Role.cs](src/modules/Elsa.Identity/Entities/Role.cs) | Tenant-scoped via `Entity.TenantId` |
| `User { Name, ICollection<string> Roles }` | [User.cs](src/modules/Elsa.Identity/Entities/User.cs) | Single `TenantId`; roles by ID |
| Roles → permission claims | [DefaultAccessTokenIssuer.cs](src/modules/Elsa.Identity/Services/DefaultAccessTokenIssuer.cs), [DefaultElsaTokenService.cs](src/modules/Elsa.Identity/Services/DefaultElsaTokenService.cs) | Union of role permissions, baked into the JWT as one `permissions` claim each |
| Endpoint declaration | [Endpoints.cs](src/common/Elsa.Api.Common/Abstractions/Endpoints.cs) `ConfigurePermissions` | **151 of 160 endpoint files already declare permissions** |
| Escalation guard | [RoleAuthorizationService.cs](src/modules/Elsa.Identity/Services/RoleAuthorizationService.cs) | Caller may only grant permissions they hold |
| Permission catalog | [ExternalAuthenticationContracts.cs](src/modules/Elsa.ExternalAuthentication/Contracts/ExternalAuthenticationContracts.cs) `IPermissionDescriptorProvider` / `IPermissionDescriptorRegistry`, `PermissionDescriptor(Name, DisplayName, Description, **Category**)` | Exists, but **only one provider is registered** — External Authentication's |
| Deployment grant boundary | `PermissionGrantBoundary` in [DefaultPermissionGrantResolver.cs](src/modules/Elsa.ExternalAuthentication/Services/DefaultPermissionGrantResolver.cs), `PermissionGrantOptions.{Allowed,Denied}Permissions` | Exists, external-auth-scoped |
| Per-tenant capability composition | CShells `IShellFeature`, [ShellInstalledFeatureProvider.cs](src/common/Elsa.Features/Services/ShellInstalledFeatureProvider.cs), `GET /features/installed` | Exists |
| Tenant scoping | `Entity.TenantId`, [SetTenantIdFilter.cs](src/modules/Elsa.Persistence.EFCore.Common/EntityHandlers/SetTenantIdFilter.cs), `""` = default, `"*"` = agnostic (ADR 0009) | Exists for EF Core |
| Richest scope model in the repo | `ConnectionScope(ConnectionScopeKind { Host, DefaultTenant, Tenant }, TenantId)` in [ExternalAuthenticationModels.cs](src/modules/Elsa.ExternalAuthentication/Models/ExternalAuthenticationModels.cs) | The precedent to follow for role scoping |
Governing ADRs: [0004](docs/adr/0004-separate-external-identity-from-elsa-authorization.md) —
Elsa's permission vocabulary is deliberately **open**, composed through grant sources;
[0009](docs/adr/0009-match-unlinked-identities-with-trusted-user-matchers.md) — **Elsa is the
only authority that expands Roles into `permissions` claims**;
[0007](docs/adr/0007-publish-audit-ready-security-notifications.md) — typed security notifications
over `INotificationSender`, no audit persistence in the producing module.
### The real defects in today's model
1. **No matcher.** `"*"`, `"read:*"` and `"exec:*"` are *literal claim values*, not patterns.
`read:*` works only on the 12 endpoints that happen to list it — out of ~40 read endpoints.
Granting `read:*` today gives inconsistent, unpredictable coverage. This is the single
strongest argument for the original proposal.
2. **No implication.** `read:workflow-definitions` does not imply
`read:workflow-definitions:versions`. Every relationship is hand-enumerated at the call site.
3. **No catalog.** ~56 permission strings are inline literals across 174 call sites, in three
competing naming schemes (`read:secrets`, `external-authentication:connections:read`,
`ai:tools:view`), plus outliers (`exec:` vs `execute:`, PascalCase `ManageWorkflowRuntime`).
A typo silently creates an unreachable endpoint. No UI can render a sensible role editor.
4. **Fails open.** Omitting `ConfigurePermissions` inherits the FastEndpoints default — there is
no Elsa-level fallback and no startup guard.
5. **Four parallel enforcement mechanisms** with no single choke point: FastEndpoints
`Permissions()`, ASP.NET policies (3 sites), mid-handler `AuthorizeAsync` (15 sites), and
hand-rolled claim greps (~10 sites, one of which uses `OrdinalIgnoreCase` while the rest use
`Ordinal`).
6. **JWT bloat.** One claim per permission. A `"*"`-free admin carries ~57 claims on every request.
7. **Stale grants.** Permissions are baked into the token at login. Revoking a role has no effect
until the token expires. The ticket assumes per-request resolution; Elsa does not do that.
### Tenancy gaps the ticket assumes away
The ticket's "roles configurable per tenant, never shared" is not currently safe to promise:
- **`User.Name`, `Role.Name`, `Application.{ClientId,Name}` have globally unique indexes, not
per-tenant composite indexes** ([Configurations.cs](src/modules/Elsa.Persistence.EFCore/Modules/Identity/Configurations.cs)).
Two tenants cannot both have a role called `Admin`. This is a hard blocker.
- **Default `IUserStore`/`IRoleStore` are memory-backed and tenancy-blind**
([MemoryStore.cs](src/modules/Elsa.Common/Services/MemoryStore.cs)). Isolation exists only on the
EF Core path, and only when `TenantsOptions.IsEnabled`.
- **`GET /identity/users` and `/identity/roles` pass an empty filter** — they rely entirely on the
ambient EF global query filter.
- **`UserManager.CreateUserAsync` never sets `TenantId`** — it relies on the EF saving handler.
- **`Elsa.Secrets` is entirely tenancy-blind** — `Secret` is a POCO with no `TenantId`. The ticket
wants per-tenant secrets access control; the data is not tenant-partitioned at all.
- **`Elsa.Persistence.VNext*` has no tenancy plumbing whatsoever.**
- **There is no host/root administrator concept** in `Elsa.Identity`.
---
## 2. Assessment of the proposal
### Layer 1 — Modules: the requirement is real, the mechanism is wrong
The proposal justifies the module layer with two arguments:
- *"Easy to define broad roles without carefully zeroing out every functionality grant."*
This only bites if the role editor pre-populates every functionality with a non-zero default.
With deny-by-default grants, "Dashboards only" is simply *grant Dashboards, grant nothing else*.
This is a UI problem being solved in the authorization model.
- *"Any future endpoint under a disabled module is automatically blocked."*
Already true under deny-by-default. A new endpoint declaring `Workflows:View` is blocked for any
role without a Workflows grant. The module layer adds nothing here.
Against it:
- **`modules` and `grants` are keyed by the same six names.** Two sources of truth for one
taxonomy, which can and will drift (module on with no grants; grants with module off).
- **Two independent gates make 403s hard to diagnose.** An admin grants `Workflows: Manage`, still
gets 403, and now has to know to check a second screen.
- The only thing it genuinely adds over grants is a *kill switch that overrides the union* — i.e.
an explicit deny. Denies over a union of roles are order-dependent and confusing, and are a much
larger semantic commitment than the ticket acknowledges.
**Recommendation — split the requirement across the two layers Elsa already has:**
| Underlying requirement | Where it belongs in Elsa |
|---|---|
| "This tenant doesn't have Secrets at all" (plan/provisioning) | Shell features — the module isn't installed, so the endpoints don't exist. **404, not 403.** Already works. |
| "This deployment forbids anyone from ever holding X" | `PermissionGrantBoundary` allow/deny lists — promote from `Elsa.ExternalAuthentication` to `Elsa.Identity` |
| "Author a broad role quickly, grouped by section" | `PermissionDescriptor.Category` — the descriptor registry already carries this. The role editor renders section groups with a whole-section toggle that **writes grants**. Same UX, one source of truth, zero runtime redundancy. |
If the requester still insists on a hard per-role gate after seeing this, it should be an explicit
deny set with documented union semantics — and I'd keep it out of v1.
### Layer 2 — Functionality + bitmask scope: right instinct, fix the details
What they have correctly identified is that Elsa's vocabulary lacks **implication** on the verb
axis and **grouping** on the resource axis. Both are genuine defects (see §1). The bitwise check
`(userScope & requiredScope) === requiredScope` is correct and should be kept verbatim.
Concrete problems with the proposed encoding:
- **`Manage = 31` includes an unnamed bit (16).** An aggregate defined as a literal that contains
an undocumented member is a maintenance trap.
- **`All = 127` spans two unnamed bits (16 and 64).** `All` and `Manage|Settings` (63) differ by a
bit with no meaning. Aggregates must be *derived from named members*, never written as literals.
- **A stored aggregate freezes at grant time.** Add `Export = 64` later and a role holding `31`
does not get it. That is the *safe* behavior and I'd keep it — but it must be an explicit,
documented decision, because administrators read "Manage" as "everything".
- **`Settings = 32` sits outside `Manage`**, so "Manage" doesn't manage settings. Defensible,
surprising, must be documented.
- **A fixed global verb set is a closed vocabulary**, which collides head-on with ADR 0004's open
vocabulary and with Elsa being a framework third parties extend.
- **Elsa's real verbs are not CRUD.** Today's vocabulary includes `publish`, `retract`, `exec`,
`run`, `cancel`, `replay`, `refresh`, `reload`, `trigger`, `complete`, `rotate`, `revoke`,
`test`, `use`, `import`, `export`, `ingest`, `approve`, `apply`. Collapsing
`publish:workflow-definitions` into `Edit` destroys the author-vs-publisher separation
customers ask for.
**Recommendation — take the good half and split the axes by openness:**
- **Verb/scope axis: closed, platform-defined `[Flags] PermissionScope`.** This is where the
customer's idea pays off, and a closed set is genuinely appropriate here.
- **Resource/functionality axis: open, string-keyed, contributed by modules** via
`IPermissionDescriptorProvider`. This preserves ADR 0004 and third-party extensibility.
Canonical permission string stays `{resource}:{scope}` so all 174 existing call sites, the claim
format, and the audit trail keep working. Grants are stored compactly as `(resource, scopeMask)`.
The matcher becomes:
```
granted(resource, mask) satisfies required(resource, requiredMask)
iff resourceMatches(granted.resource, required.resource) // '*' + hierarchical prefix
&& (mask & requiredMask) == requiredMask // the proposed check, verbatim
```
This fixes the `read:*` defect *by construction*, gives implication ordering for free, and
collapses the admin JWT from ~57 permission claims to roughly one per resource.
### Layer 3/5 — Roles and assignments per tenant
`Role` already has `TenantId`, but "never shared across tenants" conflicts with wanting a
platform-level administrator. Elsa's `"*"` agnostic sentinel and External Authentication's
`ConnectionScope(Host | DefaultTenant | Tenant)` are the right precedent: support host-scoped roles
assignable only by host administrators, plus tenant-scoped roles. Also requires fixing the global
unique indexes on `Role.Name` / `User.Name`.
`UserRoleAssignment { userId, roleId, tenantId }` implies **one person holding different roles in
different tenants**. Elsa today has a single `User.TenantId`, resolves the tenant *from the user*
([CurrentUserTenantResolver.cs](src/modules/Elsa.Identity/Multitenancy/CurrentUserTenantResolver.cs),
[ClaimsTenantResolver.cs](src/modules/Elsa.Identity/Multitenancy/ClaimsTenantResolver.cs)), and has
no tenant selection at login ([Login/Endpoint.cs](src/modules/Elsa.Identity/Endpoints/Login/Endpoint.cs)).
**Decided: we do not adopt this.** Users stay one-per-tenant; a person operating in two tenants has
two user records, and `User.Roles` remains a flat list resolved within the user's own tenant. This
avoids making users tenant-agnostic, adding tenant selection at login, carrying a per-session
tenant in the token, and reworking grant resolution to be per-`(user, tenant)` — by far the largest
structural change the ticket implied, and one carried by a single line in their data model.
What still has to be true for "roles configurable per tenant" to hold is the tenancy hardening in
Phase 3 — the global unique indexes and the tenancy-blind memory stores are real blockers today.
### `GET /me/permissions` — yes, unambiguously
Genuinely missing, cheap, and it unblocks their UI. It should return the resolved grants *plus*
the descriptor catalog *plus* which sections are actually installed (from `IInstalledFeatureProvider`).
That hands them their "modules map" derived from the real capability layer, with no duplicate
toggle to drift out of sync.
### Audit — yes, and there's a pattern to follow
ADR 0007 already established typed, redacted security notifications over `INotificationSender`
with no audit persistence in the producing module. Role and assignment mutations should publish
the same shape.
### The gap the ticket doesn't mention: revocation latency
Permissions live in the JWT. Removing a role does nothing until the token expires. For an RBAC
feature bought for compliance, "I revoked their access and they deleted workflows for another
30 minutes" is a real finding. Fix with a **security stamp**: a monotonic counter on the user,
bumped on any role/grant/membership change, embedded as a claim and validated per request against
a cached value (`Elsa.Caching` already provides distributed invalidation).
---
## 3. Recommended work
### Phase 1 — Foundation (Elsa-wide value, no breaking change)
1. **Promote the permission catalog into core.** Move `IPermissionDescriptorProvider` /
`IPermissionDescriptorRegistry` / `PermissionDescriptor` from
`Elsa.ExternalAuthentication` to `Elsa.Api.Common` (or `Elsa.Identity`); leave type-forwarding
shims. Extend the record to `(Resource, Scope, DisplayName, Description, Category)`.
Every module with endpoints contributes a provider, following the existing
[ExternalAuthenticationPermissions.cs](src/modules/Elsa.ExternalAuthentication/Permissions/ExternalAuthenticationPermissions.cs)
shape. This alone removes `unknown_permission_descriptor` warnings for every core permission.
2. **Add `[Flags] PermissionScope`** in `Elsa.Api.Common`, with aggregates derived from named
members (`Manage = View | Create | Update | Delete | Execute`), never literals, and no `All`
constant that spans unnamed bits.
3. **Single `IPermissionEvaluator` + one `IAuthorizationHandler`** replacing FastEndpoints'
exact-match check. This becomes the one auditable choke point and also serves the SignalR
hubs, the mid-handler checks and `RoleAuthorizationService`.
4. **`RequirePermission(resource, scope)`** as a typed overload alongside the existing
`ConfigurePermissions(params string[])` in
[Endpoints.cs](src/common/Elsa.Api.Common/Abstractions/Endpoints.cs) — incremental migration,
151 call sites keep compiling. Collapse the six copy-pasted method bodies while there.
5. **Fail closed.** A startup guard (and a test) asserting every discovered `ElsaEndpoint*`
declares either a permission or `AllowAnonymous`.
6. **`GET /identity/me/permissions`** returning resolved grants + catalog + installed sections.
7. **Normalize the vocabulary** to one `{resource}:{verb}` scheme, with the old strings kept as
aliases in the descriptor so existing role documents keep working.
### Phase 2 — Structured grants
8. `Role.Grants: ICollection<PermissionGrant(Resource, ScopeMask)>` alongside the existing
`Permissions` list; dual-read during migration, expand grants → strings at token issuance so
nothing downstream changes on day one. Note the EF converter joins collections with commas
([Configurations.cs](src/modules/Elsa.Persistence.EFCore/Modules/Identity/Configurations.cs)),
so grants need their own serialization.
9. Compact the `permissions` claim to one entry per resource.
10. **Security stamp** for revocation (above).
11. Promote `PermissionGrantBoundary` to a deployment-level allow/deny in `Elsa.Identity`.
12. Publish role/assignment audit notifications per ADR 0007.
### Phase 3 — Tenancy hardening (required for "roles per tenant" to be true)
Scoped down by the one-user-per-tenant decision: no cross-tenant assignments, no tenant selection
at login. What remains is closing the gaps that make the current per-tenant promise unsafe.
13. **Per-tenant composite unique indexes** on `User.Name`, `Role.Name`, `Application.ClientId`,
`Application.Name` in [Configurations.cs](src/modules/Elsa.Persistence.EFCore/Modules/Identity/Configurations.cs),
plus migrations across all six EF providers. Today two tenants cannot both have an `Admin` role.
14. **Tenant-aware `MemoryStore`** ([MemoryStore.cs](src/modules/Elsa.Common/Services/MemoryStore.cs))
so the default `IUserStore`/`IRoleStore` isolate, not just the EF path.
15. **Explicit tenant filtering** in `GET /identity/users` and `/identity/roles` (they pass an empty
filter today) and in `UserManager.CreateUserAsync` (never sets `TenantId`) — belt and braces
over the ambient EF query filter, which only applies when `TenantsOptions.IsEnabled`.
16. **Tenant scoping for `Elsa.Secrets`** — `Secret` is a POCO with no `TenantId`. Per-tenant
secrets access control is unenforceable until the data is partitioned. Sizeable on its own;
may warrant a separate ticket.
17. **Host-scoped vs tenant-scoped roles**, modeled on `ConnectionScope(Host | DefaultTenant | Tenant)`
from External Authentication, using the `"*"` agnostic sentinel. Answers "who administers the
platform", which the proposal's *never shared across tenants* rule leaves no room for.
### Not Elsa's job
The six product sections (Workflows, Instances, Dashboards, Secrets, Connections, User Management)
are a product-level taxonomy, not the platform's — Elsa has ~20 modules with endpoints. They belong in the
downstream application as a **deployment-defined grouping over descriptor categories**, together with the role
editor UI.
---
## Verification
- Unit: `PermissionScope` aggregate derivation; `IPermissionEvaluator` matcher table
(`*`, hierarchical prefixes, verb matching, absence denies) — *superseded in detail by D13; `tasks.md` carries the authoritative verification* —
alongside the existing [RoleAuthorizationServiceTests.cs](test/unit/Elsa.Identity.UnitTests/Services/RoleAuthorizationServiceTests.cs).
- Guard test: enumerate every `ElsaEndpoint*` type and assert a declared permission or
`AllowAnonymous`; assert every declared permission resolves to a registered descriptor.
- Integration: `GET /identity/me/permissions` for a role holding partial scopes; a revoked role
denying on the next request once the security stamp lands.
- Regression: existing role documents with legacy permission strings still authorize
([DefaultAccessTokenIssuerRegistrationTests.cs](test/unit/Elsa.Identity.UnitTests/Services/DefaultAccessTokenIssuerRegistrationTests.cs),
[LegacyIdentityEndpointTests.cs](test/integration/Elsa.ExternalAuthentication.IntegrationTests/Compatibility/LegacyIdentityEndpointTests.cs)).
- Multi-tenant: two tenants each with a role named `Admin` (fails today); a role listed in tenant A
is invisible to tenant B against both the EF and memory stores.
- Record the outcome as an ADR and a `specs/0NN-authorization-model/` spec, matching the
repo's spec-driven convention.
---
## Decisions log (grilling session, 2026-08-23)
Supersedes anything above that conflicts.
**D1 — Clean break on the vocabulary.** Canonical form is `{resource}:{verb}`, reversing today's
`{verb}:{resource}`. Today's scheme is ad hoc and carries three competing conventions; we design
the ideal model rather than preserve it.
**D2 — Upgrade is a loud, one-time migration, not a permanent alias layer.** Legacy strings stop
matching. A startup validator scans stored roles and logs every unrecognized permission by role
name; `docs/migrations/authorization-model.md` carries the full old→new table, following the
convention of `docs/migrations/external-authentication-persistence.md`. Fails closed, loudly.
`*` survives as the escape hatch (see D3), so the admin role cannot be locked out.
*Resolved 2026-08-23:* the requesting deployment does have hand-authored roles in production and accepts migrating, so no compatibility layer is required.
**D3 — `All = ~0u`, not a finite literal, and not a wrapper type.** Forward-widening falls out of
the arithmetic: `All & <future bit> == <future bit>` passes, while a named aggregate like
`Manage = View|Create|Update|Delete|Execute` stays frozen. One integer, both behaviors, no
sentinel and no discriminated union. The proposal's `All = 127` is the right idea with the wrong
literal. Superuser `*` stops being special-cased entirely — it is just `("*", All)`, collapsing the
15 hand-rolled `PermissionNames.All` sites into one evaluator call.
**D4 — Named aggregates are authoring macros, expanded server-side on write.** `manage` never
reaches storage; the create/update role endpoint expands it into its constituent verbs. This
removes the freeze-vs-widen ambiguity from the stored model entirely and makes a role document
say exactly what was granted. The descriptor registry owns the macro table so it is discoverable
via the catalog endpoint rather than reimplemented per client. "I clicked Manage" is recovered in
the ADR-0007 audit notification, not by weakening storage.
**D5 — Storage does not change.** *(The mask referenced below is withdrawn by D13; grants are matched as strings.)* `Role.Permissions` stays `ICollection<string>` holding flat
`{resource}:{verb}` entries. The scope mask is a runtime representation computed by grouping
parsed entries per resource. Note the EF converter joins with commas
(`string.Join(",", v)` in `Modules/Identity/Configurations.cs`), so no permission string may ever
contain a comma — which rules out storing compound masks as text in that column.
**D6 — The resource axis is hierarchical, with prefix matching.** `/` separates depth, `:` splits
resource from verb: `workflows/definitions:view`, `workflows/*:view`, `*:*`. The hierarchy is
already latent in the endpoint layout and in the current vocabulary's inconsistent gestures at it.
### Consequence: the module-layer rebuttal changes shape
D6 is what makes the pushback honest. On a flat axis, "grant the whole Workflows section" is ~15
hand-enumerated grants that silently miss anything added later — which is exactly the pain behind
the proposed module layer, and that fix would have been reasonable. With prefix matching,
`workflows/*:view` is one grant covering definitions, instances, executions and all ten descriptor
endpoints, including future ones.
So the answer to the request is no longer "you don't need that." It is: **you get that natively,
in a single grant, on an axis that composes with scope instead of overriding it.** Their
`Workflows: View` becomes `workflows/*:view` — same expressiveness, one source of truth, and a 403
explainable from a single grant list.
Accepted cost: prefix matching gives grants implicit forward reach on the resource axis, the same
double-edge as the verb wildcard. Mitigation is inspectability — the descriptor catalog lets
the role editor show "this grant currently covers these 15 resources."
### Corrections to the assessment above
- **JWT bloat was overstated.** The admin case is a single `*:*` claim, not ~57. Compaction is an
optimization, not a design driver, and should not be sold as one.
- **The proposed `/me/permissions` shape survives unchanged.** The runtime mask projects to an
integer as they specified. One wrinkle to tell them: `All` projects as `-1` / `4294967295`, which
is correct under their JS check (32-bit signed: `-1 & 8 === 8`) but looks alarming.
- **Phase 1 item 7 is smaller than written** — declare canonical forms in descriptors; the rewrite
is handled by D2's migration, not by an alias layer.
**D7 — Revocation: shorten token lifetime by default; security stamp is opt-in.** `AccessTokenLifetime`
defaults to 1 hour, so a revoked role currently stays live that long. Default guidance becomes a
short access-token lifetime (the refresh endpoint already exists); an optional per-user security
stamp, cached per node under a short TTL, is available where tighter bounds are required.
*Correction:* the assessment above claimed `Elsa.Caching` provides distributed invalidation. It does
not — `ChangeTokenSignalInvoker` is a per-process `ConcurrentDictionary`, and the only cross-node
primitive in the repo is a distributed *lock* (`IDistributedLockProvider`), not an invalidation
broadcast. The stamp design therefore must not depend on cross-node invalidation, and must not
make Redis a prerequisite for correct RBAC.
**D8 — Endpoint declaration follows the External Authentication pattern, with one refinement.**
Constants and descriptors colocated per module (as in `Permissions/ExternalAuthenticationPermissions.cs`),
endpoints reference the constants. Refinement: **one constant per *resource*, not per resource+verb
pair** — `N` constants instead of `N×M`, with the verb supplied by the enum and therefore
type-checked rather than buried in an opaque string.
**D9 — Big-bang rewrite in-repo, graceful degradation for third parties.** All 151 endpoint files
migrate at once, split one PR per module, guarded by a startup test asserting every declared
resource resolves to a registered descriptor. `ConfigurePermissions(params string[])` stays
`[Obsolete]` but functional so third-party modules keep compiling. An unresolvable third-party
string auto-registers an implicit descriptor, logs a warning, and is marked unverified in the
catalog — following the existing `unknown_permission_descriptor` precedent in
`DefaultPermissionGrantResolver` rather than failing the host at boot. The fail-closed guard
therefore applies to in-repo endpoints only; that asymmetry is deliberate.
**D10 — No host-scoped principal; the cross-tenant persona is out of scope.** The ticket's "instance
administrator" is a persona of a higher-level application that manages Elsa
instances (comparable to Valence Control), so cross-tenant administration is solved above Elsa.
Elsa does not manage other Elsa instances and will not grow a host principal to imply that it does.
Machine-to-machine access needs nothing new: `Application` already carries `TenantId` and `Roles`,
and `DefaultApiKeyProvider` expands those into permission claims.
*Note:* a user with `TenantId = "*"` is currently unrepresentable — `"*"` is reserved (ADR 0009), so
`CurrentUserTenantResolver` yields an ID absent from the tenant dictionary and
`DefaultTenantResolverPipelineInvoker` logs "could not be found in the tenant store" and returns
null. Recorded because it is non-obvious, not because we intend to change it.
**D11 — Harden tenancy anyway; defer secrets tenancy to its own issue.** Whether a deployment isolates
per Elsa *tenant* or per Elsa *instance* is unconfirmed, but the Phase 3 hardening is justified on
its own merits: globally-unique `Role.Name` / `User.Name` indexes are a latent bug for any
multi-tenant deployment, and the default memory-backed `IUserStore`/`IRoleStore` do no tenant
filtering at all. Sequenced last. `Elsa.Secrets` tenancy is excluded — `Secret` is a POCO with no
`TenantId`, making it a genuine feature rather than a hardening fix; filed separately as elsa-workflows/elsa-core#7972.
**D12 — No macros. Supersedes D4.** Since D4 established that aggregates expand before storage, a
macro has zero runtime semantics: stored grants, evaluation and audit are identical whether the
server expanded `manage` or the client sent the verbs. It is a UI affordance, and implementing it
server-side would commit Elsa to defending a per-resource editorial taxonomy across every module
author, forever. Separately, the proposed `Manage` existed to compensate for a flat resource
axis — pressure that D6 already removed, since `workflows/*` collapses 15 resources into one grant.
Descriptors still declare the verbs each resource supports; that is needed regardless, to render a
role editor, validate submitted grants, and power the "this grant covers these 15 resources"
inspection D6 relies on. What is *not* added is a second field classifying verbs as operational.
Final model: **hierarchical resource + explicit verbs + `*` as the only thing with forward reach.**
Three concepts, no taxonomy to defend.
*Residual risk:* scripted role provisioning (Terraform, CI, curl) is more verbose without
`"scope": "manage"`. Judged insufficient to reintroduce macros — such clients can read the catalog
endpoint — but this is the argument that will return if role setup is ever automated.
---
## D13 — The verb axis is open strings, not a closed enum. Supersedes D3.
**Challenge raised 2026-08-23:** does a closed scope enumeration make sense in a modular, extensible
system, when a module may introduce endpoints for which none of the defined scopes fit?
It does not, and the evidence was already in the draft vocabulary. Fitting the 150-endpoint census
to seven verbs required *adding* `Publish` and `Operate` because publish/retract and
cancel/replay/pause/reload do not fit CRUD; forcing `test` into `Execute`, `rotate` and `revoke`
into `Update`, `ingest` into `Create`, `archive` into `Delete`; collapsing `approve` and `apply`
into one verb; and inventing three sub-resources (`secrets/values`,
`external-authentication/provider-trust`, `.../permission-grants/unrestricted`) purely to express
what the enum could not. Every one of the five "editorial judgements needing module-owner review"
was the same artefact. That is 16 first-party modules in one repository; a third party with
`sign`, `escalate`, `acknowledge` or `quarantine` has nowhere to put them.
**The decisive point is that the mask was not buying what D3 claimed.** The closed enum was
justified on implication — an aggregate covering narrower verbs. D12 removed aggregates, and
without them no verb implies another, in this model or the proposal's. Its own worked example
states it: `hasAccess(2, 8) // false — Edit does not cover View`. So the bitwise AND never
expressed "broader covers narrower"; it expressed "a grant may carry several verbs and a
requirement may need several." That is set containment, which a set of strings satisfies
identically.
With implication gone, the enum's remaining benefits were compactness — already surrendered by D5,
which stores one string per resource-verb pair — and resembling the original proposal.
### The model
```
permission := {resource}:{verb}
resource := hierarchical, '/'-separated; '*' matches a subtree
verb := flat string; '*' matches any verb
satisfies := resourceMatches(granted, required) && verbMatches(granted, required)
```
One matching rule shape on both axes. No enumeration, no mask, no `~0u`, no arithmetic. `*:*`
remains superuser and `workflows/*:*` still widens forward, now by the same wildcard rule that
governs resources rather than by a separate numeric convention.
D12 stands unchanged: no aggregates. `manage` may appear as a *verb* where a module genuinely has
one coarse permission, which is a name rather than an expansion of other verbs.
### Accepted cost: vocabulary fragmentation
Without a closed set, modules will coin `read`/`view`/`get`/`list` for the same idea. Mitigated by
convention rather than enforcement, consistent with Principle III: Elsa ships a **recommended core
verb set** — `view`, `create`, `update`, `delete`, `execute` — as constants that modules should
reuse, and the catalog marks non-core verbs so they surface for review. This is exactly how the
resource axis already works.
### Consequences
- The five open editorial decisions dissolve into plain names: `ai/proposals:approve`,
`ai/proposals:apply`, `workflows/tasks:complete`,
`external-authentication/connections:archive`, `secrets:use`. No invented sub-resources.
- `GET /identity/me/permissions` returns `verbs: ["view","publish"]` rather than `scope: 33`. This
deviates from the proposed integer contract, and is an improvement: that contract was about to
carry `4294967295` for an administrator, previously flagged as correct but alarming.
- The proposed bitwise check is preserved semantically as set containment.
**D14 — `secrets:use` and the AI proposal verbs stay out of the published vocabulary.** Neither
guards any endpoint today: no endpoint returns a secret value (resolution happens at workflow
runtime through `ISecretResolver`), and `ai:proposals:*` plus `ai:tools:manage` are referenced
nowhere outside their own declaration. The vocabulary describes what exists. Under D13 both are
trivially re-addable as plain verbs when their endpoints ship, with no structural change — which is
precisely why forward-declaring them now buys nothing.
---
## Vocabulary review, 2026-08-23
**D15 — Three root groupings kept: `workflows/`, `identity/`, `system/`.** `workflows/*` is the single
grant that replaces the proposed Workflows module toggle, which is the whole reason the hierarchy
earns its keep. *The scripting caveat originally recorded here is withdrawn by D21, which removes the
scripting execute resources entirely.*
**D16 — Synonym drift corrected in the first draft.** `alterations:run` became `execute`; both uses of
`manage` (`identity-links`, `policies`) became `update`. Both were introduced by the model author
inside a single document, which is direct evidence that the core-verb convention needs active policing
rather than documentation alone. Verified as genuinely distinct and retained: `refresh` (targeted, takes
definition IDs) versus `reload` (wholesale); `revoke` versus `delete` on sessions; `archive` versus
`delete` on connections.
**D17 — `write` replaces `update` where an API does not separate create from update.** A resource
declares **either** `create` + `update` **or** `write`, never both. `update` was rejected for upsert
endpoints because it misdescribes the grant: `POST /workflow-definitions` creates when no definition ID
is supplied, so a role holding "update" could create records. `upsert` was rejected as mechanism-named
jargon where the grant should express intent. Elsa is already bimodal along exactly this line —
`labels`, `identity/users`, `identity/roles`, `external-authentication/connections` separate the
operations; `workflows/definitions`, `workflows/instances`, `secrets`, `tenants` do not — so the
vocabulary reflects the API shape rather than imposing uniformity on it. Never-both is what prevents
`write` becoming an aggregate and keeps FR-009 true.
**D18 — `policies/default-roles` split out; `delegate`/`delegate-unrestricted` stay verbs.** The
default-role list is a distinct thing being administered — the roles granted to a user auto-created for
an unknown external identity — so it earns a resource. "Unrestricted" is a *mode* of one action on one
configuration, not a thing, so it stays a verb. The tier relationship (`mayDelegate = unrestricted ||
hasDelegate`) lives in `DefaultPermissionDelegationAuthorizer` and is deliberately not modeled;
FR-009's absence of verb implication is correct, not a gap.
*Finding, not caused by this work:* the `roles:assign` descriptor overstates its reach. Setting
`defaultRoleIds` is guarded by `RoleAuthorizationService.CanAssignRolesAsync`, the ordinary
subset-of-your-own-permissions rule; `roles:assign` itself is enforced only when removing policy
references during role deletion. The anti-escalation rule holds, so this is not a hole, but the
descriptor should be corrected and the module owner asked whether the gap is intentional.
**D19 — Default access-token lifetime drops from 1 hour to 15 minutes.** `RefreshAsync` calls
`IssueTokensAsync`, which re-reads roles and issues both a new access and a new refresh token, so
refresh already rotates and each refresh reflects current grants — the access-token lifetime *is* the
revocation bound. Cost is roughly four refreshes per user per hour, two store reads each. Refresh-token
lifetime is unchanged at 2 hours; altering session length is a separate UX decision.
**D20 — No Milestone 3 migration scaffold. Supersedes the shim in Complexity Tracking.** The two
compatibility mechanisms operate on different sides and together cover the migration window: the
permanent obsolete `ConfigurePermissions(string[])` path translates legacy *endpoint declarations*
through the migration table, while a legacy *stored grant* still fails to match, which is the intended
break. With the seeded admin holding `*`, module pull requests can land in any order without trunk
regressing. Keeping the evaluator ignorant of legacy strings also protects the component we most want
clean as the single auditable choke point.
**D21 — The C#/Python expression permissions are dropped, not translated.** Challenge raised: if a
principal may execute a workflow, everything that workflow does is permitted, so does an "execute
script" resource make sense at all?
It does not, and inspecting `WorkflowDefinitionScriptAuthorizationService` showed the permission was
conflating two concerns across three enforcement sites. On the **execution** path (`Execute`,
`Dispatch`, `BulkDispatch`) the gate was incoherent: a workflow runs under the server's authority, not
the caller's, so the check never constrained what a script could do — it only decided whether this
caller could trigger a definition someone else authored. `workflows/definitions:execute` already
answers that, and the gate failed badly: an author adding a C# expression silently revoked an
operator's ability to run a workflow they had been running for months. On the **authoring** path
(`Post`, `Import`, `ImportFiles`, `Publish`, `BulkPublish`) the gate was meaningful — saving a C#
expression means running arbitrary host code — but it is a constraint on the *write* path, not an
`execute` verb on a scripting resource, so the name mis-describes it. A third site,
`Endpoints/Scripting/ExpressionDescriptors/List`, uses the same permissions to filter which expression
types the editor offers.
Relocating the authoring gate under `workflows/definitions` would have moved the sharp edge from
`execute` to `write` rather than removing it, so a mis-scoped permission is not carried into the new
vocabulary. Both permissions are removed; per-author script trust is redesigned in
[#7975](https://github.com/elsa-workflows/elsa-core/issues/7975).
**Consequence, which is a deliberate reduction in control and must be prominent in the migration
document:** the host switch (`AllowHostCodeExecution`, surfaced as `IsBrowsable`) becomes the single
control. Where host code is disabled nothing changes. Where it is enabled, any author who may write
definitions may use C# and Python, and the editor offers those types to every such author. Deployments
that enabled host code while permitting only some authors to use it lose that granularity until #7975
lands.
`workflows/scripting/javascript:view` is retained — it serves editor type definitions and is read-only.
**D22 — A subtree wildcard matches the named node and all descendants.** `workflows/definitions/*`
covers `workflows/definitions` itself as well as `.../versions` and `.../labels`. This was unspecified
until the module-owner review exposed it. Inclusive matching is how an administrator reads "grant this
subtree", and it behaves consistently whether or not the parent is itself a registered resource;
withholding a parent while granting children remains possible by naming the children. The alternative —
descendants only — would make `workflows/definitions/*:view` grant versions and labels while denying the
definitions list, which would be reported as a bug.
**D23 — Module-owner review findings (2026-08-23).**
*Census correction.* External Authentication was under-sampled: the original extraction took one route
per file, but that module packs up to eight endpoint classes into a single file, so only 6 of 34
endpoints were seen. A full re-census confirmed no other module does this. The tree survived, with the
corrections below.
*Corrections applied.*
- `external-authentication/identity-links` was `view, update`; `links:manage` actually covers list,
create, replace and delete, so it becomes `view, write, delete`.
- `ingest:diagnostics:opentelemetry` dropped as declared-but-unused, consistent with D14. Missed in the
first pass.
- The migration mapping is separated from the resource tree and carries full legacy strings, one row per
permission. The earlier compressed notation (`` `read:`/`write:`/`delete:workflow-definitions` ``) was
neither mechanically checkable nor unambiguous for whoever writes the migration document; a
completeness script against it produced 24 false positives. All 57 literal permissions now verify.
- `read:*` and `exec:*` had no mapping at all despite being real grants in customer role documents. They
become `*:view` and `*:execute` — which makes them materially *more* powerful, since today they
authorize only the twelve and one endpoints that happen to list them. Operators must review any role
holding them.
*Confirmed correct, recorded so it is not "fixed" later.* External Authentication connections have no
hard delete: `DELETE /connections/{connectionId}` maps to the archive permission and pairs with
`restore`, while enable and disable map to update. The absence of `delete` on that resource is
deliberate.
*Migration expands, it does not rename.* Several new sub-resources are granularity increases, so a
single legacy permission maps to multiple new ones — `read:workflow-definitions` becomes both
`workflows/definitions:view` and `workflows/definitions/versions:view`; `links:manage` becomes three
verbs. A migration that substitutes one-for-one will silently narrow existing roles.
*Code finding.* `Broker/Logout.cs` declares two endpoint classes, `Logout` and `ContinueLogout`, neither
of which declares a permission or `AllowAnonymous`; both inherit FastEndpoints' authenticated-without-
permission default. Both will fail the Milestone 3 fail-closed gate and need an explicit declaration.
*Five questions remain for module owners*, recorded at the end of the vocabulary contract: the
workflow-definition upsert verb, whether the six External Authentication descriptor endpoints deserve
their own resource, whether `/user-options` being guarded by `links:manage` is intentional, the
misleading `roles:assign` descriptor, and the `Logout` declarations.
**D24 — Read-only mode is not a permission and keeps its own enforcement. Corrects the assessment
above.** The original defect list counted four parallel enforcement mechanisms, one of them the 15
mid-handler `AuthorizeAsync(..., NotReadOnlyPolicy)` calls in the workflow API, and Phase 1 proposed
that the single evaluator serve "the mid-handler checks" among others. That was wrong.
`NotReadOnlyPolicy` enforces deployment read-only mode — whether this instance accepts mutations at
all — which is orthogonal to whether a principal holds a permission. A workflow author with full
grants is still refused while the deployment is read-only, and correctly so. Folding it into the
permission evaluator would conflate two independent axes and make read-only mode expressible as a
grant, which it must not be.
The consolidation therefore covers four *permission-checking* mechanisms: FastEndpoints permissions,
named ASP.NET policies (3 sites), hand-rolled claim inspections (15 files), and SignalR hub checks
(4 hubs). The count is unchanged; the composition is not. FR-016 and FR-017 are worded accordingly,
and `tasks.md` T040 carries the exclusion explicitly so no one implements it from the task list alone.
**D25 — Module-owner review outcomes (2026-08-23).** Five questions resolved; full text at the end of
`contracts/permissions.md`.
- **EA descriptors get their own resource**, `external-authentication/descriptors:view`, as a single
node rather than six. One legacy permission governs all six, which is the same principle that gives
`workflows/descriptors/*` nine separate resources — those were separately permissioned already. The
tree reflects the API in both cases.
- **`/user-options` stays on `identity-links:view`.** It is a user search backing the link picker,
returning id and display name scoped to the tenant. *Recorded consequence:* identity-link rights
therefore confer tenant-wide user enumeration in that reduced projection, without
`identity/users:view`. Moving it would either over-grant full user read on migration or break linking.
- **`roles:assign` descriptor corrected** to describe what it guards — removing policy references
during role deletion. No escalation was possible either way, since the subset rule holds regardless.
Whether it *should* additionally guard `defaultRoleIds` is filed as #7977 rather than settled
inside a vocabulary migration.
- **The two Logout endpoints declare differently.** `Logout` is authenticated-only; `ContinueLogout` is
anonymous. **`ContinueLogout` inheriting the authenticated default is a probable live bug** — the
identity provider redirects the browser there during upstream logout, potentially after the Elsa
session is gone, so the inherited requirement can 401 a callback that should succeed. Filed as
#7976. The fail-closed gate surfaced it; it was not introduced by this work.
- **T028 splits four ways** along resource-group seams (31 / 20 / 15 / 12 files) rather than landing as
one 78-file pull request — the same unreviewable-diff problem the dropped Milestone 3 shim was
invented to avoid, in a different form.
**D26 — Two model gaps surfaced by D25, one closed and one recorded.**
*Closed:* FR-019 now accepts a **third declaration state**, authenticated-only. `Logout` needs an
identity but no grant, which the original two-state rule could not express — it would have forced
either a fabricated permission or a gate exemption, and an exemption list is a hole in a fail-closed
guarantee.
*Recorded, not built:* **conjunctive requirements are not expressible.** An endpoint declares one
resource and one verb, so "needs link rights *and* user read" cannot be stated declaratively.
`/user-options` is the first case to want it, and `ExternalAuthenticationRoleDeletionDependencyContributor`
already does it imperatively across three permissions. Not needed for this work; recorded so the next
case is not solved ad hoc.
**D27 — Review findings on PR #7978 (2026-08-23).** Automated review surfaced several genuine gaps;
recorded because two changed the model rather than the prose.
**A bare `*` parses as `*:*`.** FR-021 forbids a superuser sentinel, while D2 requires a stored `*` to
keep authorizing so no instance can lock itself out — and the seed default is `["*"]`. Those look
contradictory. They are reconciled at the *parse* layer, not the evaluation layer: a permission string
with no `:` consisting solely of `*` normalizes to resource `*`, verb `*`. The evaluator never sees a
sentinel, so FR-021 holds, and the migration table's `*` → `*:*` is a normalization rather than a
behavioral change.
**Wildcards are validated structurally, not against the catalog.** `workflows/*` matches no single
descriptor and `*` is deliberately absent from every resource's supported verbs, so naive descriptor
validation would reject the very grants US1 is built on. Concrete resources and verbs are validated
against the registry; wildcard segments are accepted whenever syntactically well formed, **including
when they currently match nothing** — a grant naming a module that is not yet installed must survive,
because installing it later is what gives the grant meaning. New FR-012a, and T022a/T022b, which also
close a real gap: the role write paths persist `request.Permissions` after only the caller-subset
check, and no task had wired registry validation into them.
**Counts were wrong in three places.** The tree carries **47 resources and 23 verbs** (6 core, 17
module-specific); the PR body said 45/21 and the tracking issue 44/21. The figures drifted as
`external-authentication/descriptors` and `policies/default-roles` were added. Corrected everywhere,
and the post-design constitution re-check now records the 17 module-specific verbs explicitly as a
Principle VII note rather than leaving the count unexamined.
**Three sequencing and coverage gaps in `tasks.md`.** T025 asserted endpoint-to-descriptor resolution
during Phase 2, when endpoints still declare legacy strings — scoped to descriptor consistency, with
resolution left to the cutover gate T041. The security stamp changes the Identity schema but its
migrations sat in Phase 5, which would have made Phase 4 non-shippable — T047a now carries them.
`RoleAuthorizationService` gains wildcard containment semantics with no test — T038a added.
**The catalog and reach report are registry snapshots, not token projections.** The staleness guidance
had told clients all three responses reflect the caller's token, which would have produced incorrect
caching for two of them.

View file

@ -0,0 +1,201 @@
# Feature Specification: Authorization Model
**Feature Branch**: `013-rbac-authorization-model`
**Created**: 2026-08-23
**Status**: Draft — pending approval
**Tracking**: [#7974](https://github.com/elsa-workflows/elsa-core/issues/7974)
**Input**: A customer request for role-based access control, assessed in [research.md](research.md), reframed against Elsa's existing identity, permission, and multitenancy infrastructure, [domain language](../../CONTEXT.md), and [architecture decisions](../../docs/adr).
## 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 0004](../../docs/adr/0004-separate-external-identity-from-elsa-authorization.md) 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 0009](../../docs/adr/0009-match-unlinked-identities-with-trusted-user-matchers.md) 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](https://github.com/elsa-workflows/elsa-core/issues/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](https://github.com/elsa-workflows/elsa-core/issues/7972).
- Studio and other clients live outside this repository and consume the catalog and introspection endpoints rather than hard-coded strings.

View file

@ -0,0 +1,145 @@
# Tasks: Authorization Model
**Input**: Design documents from `/specs/013-rbac-authorization-model/`
**Prerequisites**: `spec.md`, `plan.md`, `research.md`, `contracts/permissions.md`, `contracts/rest-api.md`
**Tests**: Required. The fail-closed coverage gate (T041) is itself a deliverable and must land in the same milestone as the cutover.
**Organization**: Phases map to the milestones in `plan.md`. Every task is tagged with the user story from `spec.md` it serves. All paths are relative to the `elsa-core` repository.
**Sizing note**: Phase 3 is one pull request per module. Module endpoint-file counts, from a full census: Workflows.Api 78, ExternalAuthentication 13, Identity 11, Secrets 10, Labels 7, Diagnostics.OpenTelemetry 7, Tenants 6, Dashboard.Api 5, Alterations 5, Resilience 3, Diagnostics.StructuredLogs 3, Bpmn.Interchange 3, AI.Host 3, Shells.Api 2, Diagnostics.ConsoleLogs 2, Http.Webhooks 1, Expressions.JavaScript 1.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel — touches different files and has no dependency on another incomplete task in the same phase.
- **[Story]**: Maps to a user story in `spec.md` (US1–US9).
---
## Phase 1: Model and Evaluator
**Purpose**: The permission model and the single decision point. Entirely additive — nothing changes behavior, and no endpoint is touched.
- [ ] T001 [P] [US1] Define `Permission` as a `(Resource, Verb)` pair with canonical parse and format in `src/common/Elsa.Api.Common/Authorization/Permission.cs`. Reject strings containing a comma, per the persistence constraint.
- [ ] T002 [P] [US1] Declare the recommended core verbs — `view`, `create`, `update`, `write`, `delete`, `execute` — as constants in `src/common/Elsa.Api.Common/Authorization/CoreVerbs.cs`.
- [ ] T003 [US1] Implement `PermissionMatcher` in `src/common/Elsa.Api.Common/Authorization/PermissionMatcher.cs`: exact match on both axes; a trailing `*` on the resource axis matching the named node **and all descendants at any depth**; `*` on the verb axis matching any verb.
- [ ] T004 [US1] Define `IPermissionEvaluator` and its implementation in `src/common/Elsa.Api.Common/Authorization/`, resolving a principal's grants from the `permissions` claim and evaluating them through `PermissionMatcher`.
- [ ] T005 [US2] Add `PermissionRequirement` and `PermissionAuthorizationHandler` in `src/common/Elsa.Api.Common/Authorization/`, replacing the FastEndpoints exact-match permission check as the enforcement path.
- [ ] T006 [US3] Promote `PermissionDescriptor`, `IPermissionDescriptorProvider`, `IPermissionDescriptorRegistry` and `DefaultPermissionDescriptorRegistry` from `Elsa.ExternalAuthentication` into `src/common/Elsa.Api.Common/Permissions/`, leaving type-forwarding shims so External Authentication keeps compiling.
- [ ] T007 [US3] Extend `PermissionDescriptor` with the verbs a resource supports, so the catalog can drive a role editor and validate submitted grants.
- [ ] T008 [P] [US1] Unit-test the matcher table in `test/unit/Elsa.Api.Common.UnitTests/Authorization/PermissionMatcherTests.cs`: exact match on each axis; subtree wildcard covering the node itself and descendants; verb wildcard; `*:*`; absence denying; a wildcard covering a newly registered resource and a newly registered verb.
- [ ] T009 [P] [US1] Unit-test `IPermissionEvaluator` for union-across-roles semantics and for the absence of verb implication (FR-009).
---
## Phase 2: Catalog Coverage
**Purpose**: Every module declares its resources and verbs. Still additive.
Each module task creates `Permissions/<Module>Permissions.cs` following the pattern already proven in `src/modules/Elsa.ExternalAuthentication/Permissions/ExternalAuthenticationPermissions.cs` — constants and descriptors colocated — refined to **one constant per resource**, with verbs supplied separately. Resources and verbs come from `contracts/permissions.md`.
- [ ] T010 [P] [US3] `Elsa.Workflows.Api` — 20 resources spanning `workflows/definitions`, `.../versions`, `.../labels`, `workflows/instances`, `activity-executions`, `runtime`, `bookmark-queue/dead-letters`, `events`, `tasks`, `tests`, the nine `descriptors/*`, and `scripting/javascript`.
- [ ] T011 [P] [US3] `Elsa.Identity` — `identity/users`, `identity/roles`, `identity/applications`.
- [ ] T012 [P] [US3] `Elsa.Secrets` — `secrets`. Retire the unused `use:`, `import:` and `export:` constants.
- [ ] T013 [P] [US3] `Elsa.ExternalAuthentication` — `connections`, `descriptors`, `identity-links`, `sessions`, `policies`, `policies/default-roles`, `provider-trust`, `permission-grants`. Correct the `roles:assign` descriptor text to describe what it actually guards.
- [ ] T014 [P] [US3] `Elsa.Labels` — `labels`.
- [ ] T015 [P] [US3] `Elsa.Tenants` — `tenants`.
- [ ] T016 [P] [US3] `Elsa.Alterations` — `alterations`.
- [ ] T017 [P] [US3] `Elsa.Dashboard.Api` — `dashboard`.
- [ ] T018 [P] [US3] `Elsa.Resilience` — `resilience/retries`, `resilience/strategies`, `resilience/simulation`.
- [ ] T019 [P] [US3] `Elsa.Diagnostics.ConsoleLogs`, `Elsa.Diagnostics.StructuredLogs`, `Elsa.Diagnostics.OpenTelemetry` — the three `diagnostics/*` resources. Retire the unused `ingest:` constant.
- [ ] T020 [P] [US3] `Elsa.AI.Host` — `ai/chat`, `ai/tools`, `ai/capabilities`. Retire the unused `ai:proposals:*` and `ai:tools:manage` constants.
- [ ] T021 [P] [US3] `Elsa.Shells.Api` and `Elsa.Workflows.Api` platform resources — `system/shells`, `system/features`.
- [ ] T022 [US3] Reduce `src/common/Elsa.Api.Common/PermissionNames.cs` to the claim type and the whole-vocabulary grant; move the workflow-runtime and bookmark-queue constants to the Workflows.Api declarations. Remove the dead `AdminRoleName`/`ReaderRoleName`/`WriteRoleName` fields from `EndpointSecurityOptions.cs`.
- [ ] T022a [US3] Wire descriptor validation into the role write paths (`Endpoints/Roles/Create`, `Endpoints/Roles/Update`), which today persist `request.Permissions` after only the caller-subset check. Reject a concrete resource with no registered descriptor and a concrete verb outside that resource's supported verbs; accept structurally valid wildcard segments, including ones that currently match nothing. Covers FR-012a.
- [ ] T022b [P] [US3] Unit-test role-authoring validation: unknown concrete resource rejected, unsupported concrete verb rejected, `workflows/*:view` accepted, `*:*` accepted, and a wildcard matching no installed module accepted.
- [ ] T023 [US3] Implement `GET /identity/permissions` in `src/modules/Elsa.Identity/Endpoints/Permissions/List/Endpoint.cs`, returning core verbs, every registered resource with supported verbs, category and display metadata, and a `nonCoreVerbs` marker. Contract: `contracts/rest-api.md`. Requires `identity/roles:view`.
- [ ] T024 [US1] Implement `GET /identity/permissions/reach` in `src/modules/Elsa.Identity/Endpoints/Permissions/Reach/Endpoint.cs`, reporting the resources a wildcard grant currently covers. This is the mitigation for forward reach on the resource axis.
- [ ] T025 [P] [US3] Integration-test the catalog endpoint for descriptor consistency only: every registered resource exposes display metadata, a category and a non-empty supported-verb list, verbs outside the core set are marked, and no two modules register the same resource. **Endpoint-to-descriptor resolution is deliberately not asserted here** — in Phase 2 endpoints still declare legacy strings, so that assertion belongs to the cutover gate T041.
---
## Phase 3: Cutover
**Purpose**: The breaking change. One pull request per module, landing in any order.
No migration scaffold is required — the obsolete declaration path (T027) translates legacy *endpoint declarations*, while a legacy *stored grant* still fails to match, and the seeded admin `*` satisfies every endpoint throughout. See `research.md` D20.
- [ ] T026a [US2] Add an explicit authenticated-only declaration to the base classes in `src/common/Elsa.Api.Common/Abstractions/Endpoints.cs`, so FR-019's third state is expressible and the coverage gate can distinguish a deliberate choice from an omission.
- [ ] T026 [US8] Add `RequirePermission(string resource, string verb)` to the six base classes in `src/common/Elsa.Api.Common/Abstractions/Endpoints.cs`, collapsing the six copy-pasted `ConfigurePermissions` bodies into one shared implementation.
- [ ] T027 [US8] Keep `ConfigurePermissions(params string[])` as `[Obsolete]` but functional: resolve legacy strings through the migration table; register an implicit descriptor marked unverified and log a warning for anything unresolvable, rather than failing the host at boot. Follows the existing `unknown_permission_descriptor` precedent in `DefaultPermissionGrantResolver`.
- [ ] T028 [US1] Migrate `Elsa.Workflows.Api` — `WorkflowDefinitions` (31 files) to `RequirePermission`.
- [ ] T028a [P] [US1] Migrate `Elsa.Workflows.Api` — `WorkflowInstances`, `ActivityExecutions`, `ActivityExecutionSummaries` (20 files).
- [ ] T028b [P] [US1] Migrate `Elsa.Workflows.Api` — `RuntimeAdmin`, `BookmarkQueueDeadLetters`, `Bookmarks`, `Events`, `Tasks`, `Tests`, `Features` (15 files).
- [ ] T028c [P] [US1] Migrate `Elsa.Workflows.Api` — the nine descriptor folders and `Scripting` (12 files).
- [ ] T029 [P] [US1] Migrate `Elsa.ExternalAuthentication` endpoints (13 files, 34 endpoint classes — several classes per file).
- [ ] T030 [P] [US1] Migrate `Elsa.Identity` endpoints (11 files).
- [ ] T031 [P] [US1] Migrate `Elsa.Secrets` endpoints (10 files).
- [ ] T032 [P] [US1] Migrate `Elsa.Labels` (7) and `Elsa.Tenants` (6) endpoints.
- [ ] T033 [P] [US1] Migrate `Elsa.Diagnostics.OpenTelemetry` (7), `Elsa.Diagnostics.StructuredLogs` (3) and `Elsa.Diagnostics.ConsoleLogs` (2) endpoints.
- [ ] T034 [P] [US1] Migrate `Elsa.Dashboard.Api` (5) and `Elsa.Alterations` (5) endpoints.
- [ ] T035 [P] [US1] Migrate `Elsa.Resilience` (3), `Elsa.Bpmn.Interchange` (3) and `Elsa.AI.Host` (3) endpoints.
- [ ] T036 [P] [US1] Migrate `Elsa.Shells.Api` (2), `Elsa.Http.Webhooks` (1) and `Elsa.Expressions.JavaScript` (1) endpoints.
- [ ] T037 [US2] Declare the two endpoints in `src/modules/Elsa.ExternalAuthentication/Endpoints/Broker/Logout.cs` separately: `Logout` as authenticated-only (it reads the external session claim from the principal), `ContinueLogout` as `AllowAnonymous` (the route handle carries the authority, matching every other broker callback). `ContinueLogout` inheriting the authenticated default today is a probable live bug — the identity provider redirects the browser there during upstream logout, potentially after the Elsa session is gone. Fix tracked as #7976.
- [ ] T038 [US1] Replace the hand-rolled `PermissionNames.ClaimType` claim inspections with `IPermissionEvaluator` calls across `Elsa.ExternalAuthentication` (5 files), `Elsa.Workflows.Api` (2), `Elsa.Identity` (2), `Elsa.Api.Common` (1) and `Elsa.AI.Host` (1). Note `AIHttpContextIdentity` currently compares case-insensitively while every other site is ordinal; the evaluator standardises this.
- [ ] T038a [P] [US1] Unit-test `RoleAuthorizationService` under wildcard containment, extending the existing tests: a held `workflows/*:view` may grant `workflows/definitions:view`; a held `workflows/definitions:view` may **not** grant `workflows/*:view`; a held concrete grant may not grant an unsupported verb; `*:*` may grant anything.
- [ ] T039 [US1] Route the four SignalR hub permission checks through `IPermissionEvaluator` — `WorkflowInstanceHub`, `ElsaConsoleLogStreamHubAuthorizer`, `StructuredLogsHub`, `OpenTelemetryHub` — replacing hard-coded arrays such as `["*", "read:*", "read:workflow-instances"]`.
- [ ] T040 [US1] Replace the three `Policies(IdentityPolicyNames.SecurityRoot)` usages in `Elsa.Identity` (`Secrets/Hash`, `Roles/Create`, `Applications/Create`) and remove the obsolete policy. **Scope note**: per FR-017, the 15 mid-handler `AuthorizeAsync(..., NotReadOnlyPolicy)` calls in Workflows.Api are *not* in scope — read-only mode is a separate axis from permissions and keeps its own check.
- [ ] T041 [US2] Add the fail-closed coverage gate in `test/unit/Elsa.Api.Common.UnitTests/Authorization/EndpointCoverageTests.cs`: enumerate every in-repository `ElsaEndpoint*` type by reflection and assert each declares exactly one of a permission resolving to a registered descriptor, `AllowAnonymous`, or authenticated-only. No exemption list.
- [ ] T042 [US5] Update `DefaultAccessTokenIssuer` and `DefaultElsaTokenService` to emit new-format `{resource}:{verb}` claims, and update `DefaultApiKeyProvider`, `AdminApiKeyProvider`, `LocalHostPermissionRequirement` and `DefaultExternalAuthenticationTokenIssuer` to match.
- [ ] T043 [US5] Add a startup validator that scans stored roles and logs every permission that does not resolve, identified by role name. Fails closed and loudly; the seeded `*` grant is unaffected so an instance cannot be locked out.
- [ ] T044 [P] [US5] Regression-test that legacy stored grants no longer authorize, that `*` still authorizes everything, and that a legacy *endpoint declaration* still resolves through T027.
---
## Phase 4: Introspection, Revocation, and Audit
- [ ] T045 [US4] Implement `GET /identity/me/permissions` in `src/modules/Elsa.Identity/Endpoints/Me/Permissions/Endpoint.cs`. Every registered resource is present, including those the caller cannot access, carrying an empty verb list. Wildcard grants are resolved to concrete verbs per covered resource. Contract: `contracts/rest-api.md`.
- [ ] T046 [US6] Lower the default `AccessTokenLifetime` in `src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs` from 1 hour to 15 minutes. Refresh already rotates both tokens and re-reads roles, so no client change is required; leave `RefreshTokenLifetime` at 2 hours.
- [ ] T047 [US6] Add an optional per-principal security stamp: a monotonic value bumped on any role, grant or membership change, carried as a claim and compared against a per-node cached value under a configurable interval. Must not depend on cross-node cache invalidation, which Elsa does not have.
- [ ] T047a [US6] Ship the stamp's persistence with it: if the stamp lives on `User`, this task includes the Identity migrations for all five EF providers, so Phase 4 remains independently shippable and does not depend on T053 in Phase 5. Prefer a store that avoids an entity-schema change if one fits.
- [ ] T048 [US7] Publish typed security notifications for role create, update and delete, and for user role assignment and removal, through `INotificationSender` per ADR 0007. This feature owns no audit persistence.
- [ ] T049 [US1] Promote the deployment grant boundary (`PermissionGrantOptions.AllowedPermissions` / `DeniedPermissions`, currently in `ExternalAuthenticationOptions`) into `Elsa.Identity` so it applies to all grant paths, and make it wildcard-aware.
- [ ] T050 [P] [US4] Integration-test `/me/permissions` for a role holding partial verbs, for a wildcard grant resolving to concrete verbs, and for denied resources appearing with an empty list.
- [ ] T051 [P] [US6] Integration-test that a revoked role stops authorizing on the next token issuance, and immediately within the stamp interval when the stamp is enabled.
---
## Phase 5: Tenancy Hardening
**Purpose**: Close the gaps that make "roles are per tenant" unsafe today. Independently justified as latent-defect fixes; sequenced last so the unresolved isolation-boundary question does not block delivery.
- [ ] T052 [US9] Replace the global unique indexes on `User.Name`, `Role.Name`, `Application.ClientId` and `Application.Name` with per-tenant composite indexes in `src/modules/Elsa.Persistence.EFCore/Modules/Identity/Configurations.cs`.
- [ ] T053 [US9] Generate Identity migrations for all five providers — Sqlite, SqlServer, PostgreSql, MySql, Oracle.
- [ ] T054 [US9] Make `src/modules/Elsa.Common/Services/MemoryStore.cs` tenant-aware so the default `IUserStore` and `IRoleStore` isolate, rather than isolation existing only on the Entity Framework path when `TenantsOptions.IsEnabled`.
- [ ] T055 [US9] Apply explicit tenant filters in `Endpoints/Users/List` and `Endpoints/Roles/List`, which currently pass an empty filter, and set `TenantId` explicitly in `UserManager.CreateUserAsync` rather than relying on the saving handler.
- [ ] T056 [P] [US9] Integration-test that two tenants can each hold a role named `Admin`, and that roles and users listed in one tenant are invisible in another, against both the Entity Framework and in-memory stores.
---
## Phase 6: Documentation and Release Readiness
- [x] T057 [US5] Write `docs/migrations/authorization-model.md` from the mapping table in `contracts/permissions.md`, following the shape of `docs/migrations/external-authentication-persistence.md`. It must state prominently that **the migration expands rather than renames** — several legacy permissions map to more than one new permission, and a one-for-one substitution silently narrows roles. **Done 2026-08-24** — `docs/migrations/authorization-model.md` leads with the three things that are not a simple rename, and tells operators to confirm `*` still works first.
- [x] T058 [US5] Document in the same file that `read:*` and `exec:*` become materially **more** powerful as `*:view` and `*:execute`, and that any role holding them needs human review rather than an automated rewrite. **Done 2026-08-24**
- [x] T059 [US5] Document in the same file the removal of `exec:csharp-expressions` and `exec:python-expressions` as a **deliberate reduction in control**: where host code is enabled, any author who may write definitions may use C# and Python. Link #7975. **Done 2026-08-24** — linked to #7975.
- [x] T060 Record the model in `docs/adr/0012-two-axis-authorization-model.md`: both axes open, wildcards as the only forward reach, no aggregates and no verb implication, and the rejection of a closed verb enumeration. **Done 2026-08-24** — `docs/adr/0012-two-axis-authorization-model.md`.
- [x] T061 [P] Update `doc/wiki/identity-tenancy-security.md`, replacing the Secrets-only route table with a pointer to the catalog endpoint as the authoritative source. **Done 2026-08-24**
- [x] T062 Resolve the five module-owner questions and fold the answers into the vocabulary. **Done 2026-08-23** — outcomes recorded at the end of `contracts/permissions.md` and as D25/D26 in `research.md`. Produced #7976 and #7977, and added FR-019's third declaration state (T026a).
- [ ] T063 Run `dotnet build Elsa.sln` and the full test suite, confirm T041 passes with zero exemptions beyond documented anonymous endpoints, and verify the quickstart scenario end to end.
---
## Dependencies
- **Phase 1 blocks everything.** No descriptor work starts before the matcher and evaluator pass their tests.
- **Phase 2 blocks Phase 3**: an endpoint cannot declare a resource that has no descriptor, because T041 asserts resolution.
- **T062 preceded Phase 2** and is complete, so the vocabulary is settled before any module declares constants against it.
- **T026 and T027 block T028–T036.** Within that range the module tasks are independent and land in any order.
- **T041 lands with the last module migration**, not before, or trunk fails while migration is in flight.
- **Phase 5 is independent of Phases 3 and 4** and may run in parallel with either.
- **T057–T059 must land in the same release as Phase 3**, since they document its breaking changes.
## Parallel guidance
Phase 2 is almost entirely parallel — sixteen module tasks touching disjoint files. Phase 3 is parallel after T026 and T027, with T028 (Workflows.Api, 78 files) the critical path and a candidate for splitting by endpoint folder. Phase 5 touches persistence and common services and should not run concurrently with itself.