elsa-core/docs/migrations/authorization-model.md
Sipke Schoorstra 3921715060
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>
2026-08-24 16:19:22 +02:00

11 KiB

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 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