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