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 14:19:22 +00:00
# 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.
refactor(auth): remove the vestigial per-author script permission plumbing (#7990)
* refactor(auth): remove the vestigial per-author script permission plumbing
#7975 is closed won't-do: authoring a workflow is a trusted act, and a
per-author gate would not change what a script can do once it runs. The host
switch stays the control, and it is per language, so an untrusted author gets
a host with the switch off rather than a permission.
That settles what the code was still half-carrying. WorkflowDefinitionScriptAuthorizationService
took a ClaimsPrincipal it never read, and could return a MissingPermission
reason nothing produced; two call sites branched on that reason to send a 403
that could not happen. The expression-descriptor endpoint kept a map from
expression type to per-author permission whose values went unused even before
the permissions were retired -- it only ever tested membership, and the
decision was always IsBrowsable. Each of these reads as an authorization gate
to anyone scanning the file, and none of them is one.
The principal, the unreachable reason, and both dead branches are gone. The
map becomes a set of the expression types the host can switch off, which is
what it was actually being used as. Behaviour is unchanged: the only failure
is a language the host disabled, which is a property of the deployment and
so a 400 naming the switch, never a 403.
PermissionNames loses ExecuteCSharpExpressions and ExecutePythonExpressions,
which existed only for that map and the test mirroring it. Five other legacy
constants there are also unreferenced but belong to other modules; they are
left alone rather than swept up here.
Two tests asserting the host-and-user case were exact duplicates of the
host-only case once the principal stopped mattering, so they go with it.
The migration guide said deployments lose per-author granularity "until
#7975 lands" and advised disabling host code until then. That promise is
withdrawn and replaced with the actual guidance.
Closes #7975
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(wiki): drop the retired exec:* permissions from the scripting guide
Review found doc/wiki/expressions-and-scripting.md still telling operators
that API callers "must have the exec:csharp-expressions permission" to
author, publish, dispatch or execute workflows containing C#, and the same
for Python. Those permissions no longer exist, so the instruction cannot be
followed and describes a gate that is not there.
Both sections now say what is actually true: the host switch is the whole
control, there is no per-caller permission because a workflow runs under the
server's authority rather than the caller's, and an untrusted author gets a
host with the switch off. The switches are noted as independent, since
enabling Python while leaving C# off is a real posture.
My earlier sweep searched for the issue number rather than the permission
strings, which is why this file was missed.
Refs #7975
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:58:16 +00:00
Deployments that enabled host code while trusting only *some* authors lose that granularity, and this is now the
intended posture rather than a gap awaiting a fix: [#7975 ](https://github.com/elsa-workflows/elsa-core/issues/7975 )
was closed as won't-do. Authoring a workflow is a trusted act, and a per-author gate would not have changed what a
script can do once it runs. If some of your authors are not trusted with host code, give them a host with the
switch off; the switch is per language, so C# and Python can be decided separately.
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 14:19:22 +00:00
## 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.
fix(external-auth)!: wildcard-aware permission grant boundary, and startup smoke tests for both hosts (#7985)
* fix(external-auth)!: match permission grant boundaries as patterns
The deployment allow/deny boundary and the delegation authorizer compared
permission strings with ordinal equality, so under the {resource}:{verb}
vocabulary they could not see wildcards. A deny list naming
'workflows/*:delete' did not deny 'workflows/definitions:delete', and a grant
of 'workflows/*:delete' outflanked a deny naming that leaf.
The bypass was reachable. ElsaRolePermissionGrantSource passes a role's
permissions to the boundary verbatim, survivors land in the issued token as
permission claims, and PermissionEvaluator does expand wildcards there. So an
ordinary role plus a deny list was enough, on every external sign-in, with no
privileged actor involved. Restoring the ordinal boundary under the new tests
fails seven of them.
Deny is now matched in both directions, allow one-directionally, both through
PermissionMatcher. A grant that is not a well-formed permission is dropped
with a warning rather than carried into a token it cannot authorize anything
in.
Five non-endpoint checks -- delegation, role-reference removal, unsafe
settings confirmation, the recovery override and the boundary itself -- also
still compared against the legacy ExternalAuthenticationPermissions
constants. Those carry two colons, so Permission.TryParse rejects them and no
principal can hold one, while the migration guide tells operators to replace
exactly those strings. All five now route through IPermissionEvaluator, and
the module registers AddElsaAuthorization itself instead of depending on host
ordering.
Non-core verbs move to ExternalAuthenticationVerbs, declared beside the
resources they apply to so a delegation check cannot spell one differently
from the endpoint it guards.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style: apply IDE code cleanup to the diagnostics and identity modules
Redundant namespace qualifiers and usings removed, and primary-constructor
and record syntax applied, across Elsa.Diagnostics.ConsoleLogs,
Elsa.Diagnostics.StructuredLogs, Elsa.Expressions.JavaScript and
Elsa.Identity. Produced by a solution-wide IDE cleanup that ran alongside the
authorization work; separated from it so the permission changes can be
reviewed on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(hosts): boot both hosts and assert their gated routes challenge
This repo runs two parallel feature systems, the classic Features/ path and
the CShells ShellFeatures/ path, and every module has to register in both.
Nothing exercised either. The unit and integration suites construct services
directly, so a module registered in one path and not the other, or a service
missing from one container, passes every test and fails only when a host
starts. Three bugs in #7980 were found by running these two hosts by hand,
two of them shell-versus-classic divergences.
Each host is booted through WebApplicationFactory, running its real Program
with full feature registration, and asked for a handful of routes it is
expected to serve behind a permission. A 404 means the module was never
registered, a 5xx means the endpoint was found but its dependencies could not
be constructed, and a 200 means no gate ran; only 401 passes. All routes are
reported together, so a feature system that stops registering a group of
modules reads as one failure rather than a queue of identical ones.
Removing AddExternalAuthenticationServices from the shell feature -- the
divergence this is built to catch -- fails the shell host on all five of its
routes while the classic host stays green.
The assertions go through HTTP rather than the container on purpose. The
hosts have different topologies: the classic host's root provider holds
everything and registers 125 routes, while CShells gives each shell its own
provider and mounts routes per shell, leaving 6 in the root. A container or
route-table assertion would have to encode that difference and would break
whenever CShells changed internally. Behaviour at the edge is host-agnostic,
and it is what actually has to match.
Each host gains a namespaced entry-point marker because both already declare
a Program in the global namespace, which a test project referencing both
cannot tell apart.
Coverage is off for this project: it references both hosts, so every module
either pulls in would enter its denominator without adding real coverage, and
coverlet cannot instrument a graph that size. TreatAsLocalProperty keeps CI's
/p:CollectCoverage=true from overriding that.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: fail closed on an unparseable grant boundary
Two findings from review, both real.
The grant boundary parsed its allow and deny lists and silently dropped what
would not parse. An allow list of nothing but malformed entries therefore
reduced to an empty set, and an empty allow list means unrestricted -- so a
typo turned the boundary off entirely and let external grant sources put
permissions straight into issued tokens. The deny side had the mirror of it:
a malformed entry quietly stopped denying what it named.
A boundary that does not parse now admits nothing, and
ExternalAuthenticationOptionsValidator rejects the configuration at startup,
so the mistake reaches an operator rather than a token. Failing startup is
what makes the runtime behaviour safe to be strict about: it cannot be hit by
someone mid-edit, only by validation having been bypassed.
ConnectionEndpointSupport.HasPermission was a sixth ad-hoc permission check,
missed when the other five were converted. It compared claim values against
the legacy ExternalAuthenticationPermissions constants at four call sites --
policy management on create and update, session revocation, and unsafe
settings confirmation -- and those constants carry two colons, so nothing can
hold one once a deployment follows the migration guide. It now routes through
IPermissionEvaluator like the rest, resolved from the request with a fallback
to the shared evaluator, the same way EndpointSecurity does it.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style(external-auth): filter permission patterns with Where
Addresses a review nit on ValidatePermissionPatterns. Behaviour is unchanged:
a null list still iterates nothing, only malformed entries are reported, and
the message text is identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: apply the grant boundary to role permissions too
Token issuance concatenated the user's Elsa role permissions raw alongside
the boundary-filtered external grants. A permission the boundary had just
excluded during grant resolution therefore reappeared in the issued token
from the same roles, which made the deny list unenforceable for anything a
role carried and left ElsaRolePermissionGrantSource filtering nothing that
was not added back a moment later. The bypass did not even need that grant
source configured: role permissions reached the token regardless of which
sources a connection selected.
Both origins now pass the same boundary. Re-applying it at issuance also
picks up a boundary that changed since sign-in, since refreshing reissues.
This is a behaviour change for deployments that configured a boundary
expecting it to bound only claim-mapped permissions: an external login may
now carry fewer permissions than before. Deployments with no boundary
configured, the default, are unaffected -- every well-formed permission
passes. The migration guide describes both directions.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 03:25:29 +00:00
## External authentication grant boundaries
`ExternalAuthentication:PermissionGrants:AllowedPermissions` and `DeniedPermissions` bound which permissions an
external identity provider connection may confer. Both lists are now matched as **permission patterns** rather than
by exact string, so they read the way a role does.
- **Denied** is matched in both directions. `workflows/*:delete` denies `workflows/definitions:delete` , and a
connection granting `workflows/*:delete` is denied by a deny list naming only `workflows/definitions:delete` .
Before this release both comparisons were exact, so either spelling slipped past the other and a deployment's
deny list did not hold. If you carried a deny list across the upgrade, re-read it: it may now deny more than it
2026-08-27 09:45:54 +00:00
used to, which is the intent. A consequence to plan for: any non-empty deny list refuses every wildcard grant
that could reach a denied permission, and `*` (which parses to `*:*` ) reaches all of them — so a role holding
`*` , including the seeded administrator role, will not survive external issuance. Operators using
`DeniedPermissions` must give externally-authenticating administrators enumerated grants instead of `*` .
fix(external-auth)!: wildcard-aware permission grant boundary, and startup smoke tests for both hosts (#7985)
* fix(external-auth)!: match permission grant boundaries as patterns
The deployment allow/deny boundary and the delegation authorizer compared
permission strings with ordinal equality, so under the {resource}:{verb}
vocabulary they could not see wildcards. A deny list naming
'workflows/*:delete' did not deny 'workflows/definitions:delete', and a grant
of 'workflows/*:delete' outflanked a deny naming that leaf.
The bypass was reachable. ElsaRolePermissionGrantSource passes a role's
permissions to the boundary verbatim, survivors land in the issued token as
permission claims, and PermissionEvaluator does expand wildcards there. So an
ordinary role plus a deny list was enough, on every external sign-in, with no
privileged actor involved. Restoring the ordinal boundary under the new tests
fails seven of them.
Deny is now matched in both directions, allow one-directionally, both through
PermissionMatcher. A grant that is not a well-formed permission is dropped
with a warning rather than carried into a token it cannot authorize anything
in.
Five non-endpoint checks -- delegation, role-reference removal, unsafe
settings confirmation, the recovery override and the boundary itself -- also
still compared against the legacy ExternalAuthenticationPermissions
constants. Those carry two colons, so Permission.TryParse rejects them and no
principal can hold one, while the migration guide tells operators to replace
exactly those strings. All five now route through IPermissionEvaluator, and
the module registers AddElsaAuthorization itself instead of depending on host
ordering.
Non-core verbs move to ExternalAuthenticationVerbs, declared beside the
resources they apply to so a delegation check cannot spell one differently
from the endpoint it guards.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style: apply IDE code cleanup to the diagnostics and identity modules
Redundant namespace qualifiers and usings removed, and primary-constructor
and record syntax applied, across Elsa.Diagnostics.ConsoleLogs,
Elsa.Diagnostics.StructuredLogs, Elsa.Expressions.JavaScript and
Elsa.Identity. Produced by a solution-wide IDE cleanup that ran alongside the
authorization work; separated from it so the permission changes can be
reviewed on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(hosts): boot both hosts and assert their gated routes challenge
This repo runs two parallel feature systems, the classic Features/ path and
the CShells ShellFeatures/ path, and every module has to register in both.
Nothing exercised either. The unit and integration suites construct services
directly, so a module registered in one path and not the other, or a service
missing from one container, passes every test and fails only when a host
starts. Three bugs in #7980 were found by running these two hosts by hand,
two of them shell-versus-classic divergences.
Each host is booted through WebApplicationFactory, running its real Program
with full feature registration, and asked for a handful of routes it is
expected to serve behind a permission. A 404 means the module was never
registered, a 5xx means the endpoint was found but its dependencies could not
be constructed, and a 200 means no gate ran; only 401 passes. All routes are
reported together, so a feature system that stops registering a group of
modules reads as one failure rather than a queue of identical ones.
Removing AddExternalAuthenticationServices from the shell feature -- the
divergence this is built to catch -- fails the shell host on all five of its
routes while the classic host stays green.
The assertions go through HTTP rather than the container on purpose. The
hosts have different topologies: the classic host's root provider holds
everything and registers 125 routes, while CShells gives each shell its own
provider and mounts routes per shell, leaving 6 in the root. A container or
route-table assertion would have to encode that difference and would break
whenever CShells changed internally. Behaviour at the edge is host-agnostic,
and it is what actually has to match.
Each host gains a namespaced entry-point marker because both already declare
a Program in the global namespace, which a test project referencing both
cannot tell apart.
Coverage is off for this project: it references both hosts, so every module
either pulls in would enter its denominator without adding real coverage, and
coverlet cannot instrument a graph that size. TreatAsLocalProperty keeps CI's
/p:CollectCoverage=true from overriding that.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: fail closed on an unparseable grant boundary
Two findings from review, both real.
The grant boundary parsed its allow and deny lists and silently dropped what
would not parse. An allow list of nothing but malformed entries therefore
reduced to an empty set, and an empty allow list means unrestricted -- so a
typo turned the boundary off entirely and let external grant sources put
permissions straight into issued tokens. The deny side had the mirror of it:
a malformed entry quietly stopped denying what it named.
A boundary that does not parse now admits nothing, and
ExternalAuthenticationOptionsValidator rejects the configuration at startup,
so the mistake reaches an operator rather than a token. Failing startup is
what makes the runtime behaviour safe to be strict about: it cannot be hit by
someone mid-edit, only by validation having been bypassed.
ConnectionEndpointSupport.HasPermission was a sixth ad-hoc permission check,
missed when the other five were converted. It compared claim values against
the legacy ExternalAuthenticationPermissions constants at four call sites --
policy management on create and update, session revocation, and unsafe
settings confirmation -- and those constants carry two colons, so nothing can
hold one once a deployment follows the migration guide. It now routes through
IPermissionEvaluator like the rest, resolved from the request with a fallback
to the shared evaluator, the same way EndpointSecurity does it.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style(external-auth): filter permission patterns with Where
Addresses a review nit on ValidatePermissionPatterns. Behaviour is unchanged:
a null list still iterates nothing, only malformed entries are reported, and
the message text is identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: apply the grant boundary to role permissions too
Token issuance concatenated the user's Elsa role permissions raw alongside
the boundary-filtered external grants. A permission the boundary had just
excluded during grant resolution therefore reappeared in the issued token
from the same roles, which made the deny list unenforceable for anything a
role carried and left ElsaRolePermissionGrantSource filtering nothing that
was not added back a moment later. The bypass did not even need that grant
source configured: role permissions reached the token regardless of which
sources a connection selected.
Both origins now pass the same boundary. Re-applying it at issuance also
picks up a boundary that changed since sign-in, since refreshing reissues.
This is a behaviour change for deployments that configured a boundary
expecting it to bound only claim-mapped permissions: an external login may
now carry fewer permissions than before. Deployments with no boundary
configured, the default, are unaffected -- every well-formed permission
passes. The migration guide describes both directions.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 03:25:29 +00:00
- **Allowed** is matched one way: an allow entry must cover the whole grant. `workflows/*:delete` admits
`workflows/definitions:delete` , but an allow list naming only `workflows/definitions:delete` refuses a
`workflows/*:delete` grant rather than admitting the part that overlaps.
The boundary now also applies to permissions the user's **own Elsa roles** carry, not only to those an external
claim mapping confers. Previously token issuance concatenated role permissions raw, so a permission the boundary
excluded during sign-in reappeared in the issued token from the same roles — which made the deny list
unenforceable for anything a role happened to carry. If you configured a boundary expecting it to bound the whole
token, it now does. If you configured one expecting it to bound only claim-mapped permissions, an external login
may now carry fewer permissions than before; widen the list, or move the restriction into the roles themselves.
Deployments with no boundary configured, which is the default, are unaffected.
A boundary that does not parse is now a **startup failure** rather than a silently ignored setting. An allow list
whose entries are all malformed used to reduce to an empty list, which means unrestricted, so a typo turned the
boundary off. Fix the entries the startup error names; the mapping table below gives the new spelling.
Rewrite both lists into the new `{resource}:{verb}` vocabulary using the [full mapping ](#full-mapping ). A value that
is not a well-formed permission matches nothing, and a grant that is not well-formed is dropped at sign-in with a
`malformed_permission` warning instead of being carried into a token.
The same matching now governs the delegation check: an actor may configure a mapping only for permissions their own
grants cover, so holding `workflows/*:delete` lets them delegate `workflows/definitions:delete` , while holding just
that leaf does not let them delegate the subtree.
refactor(auth)!: retire the legacy permission constants and duplicate descriptor types (#7987)
* refactor(auth)!: retire the legacy permission constants and duplicate descriptors
Completes the cutover started in #7980. Seven `<Module>Permissions` classes
holding `verb:resource` strings are removed: AIPermissions, ConsoleLogs,
Dashboard, ExternalAuthentication, OpenTelemetry, Secrets and StructuredLogs.
AIPermissions was not in #7982's list, which was written before the cutover
finished; it is dead by the same measure as the rest.
Removed rather than marked obsolete, which #7982 asked to be an explicit
decision. Every string these classes held carries two colons, so it does not
parse under the new grammar and authorizes nothing. Keeping them obsolete
would leave code that compiles, still reads as a permission check, and
silently grants no access -- a warning that is easy to suppress in front of a
runtime failure that is invisible. A compile error names the call site and
can be fixed against the migration guide's mapping table. Classes their own
modules still reference, WorkflowPermissions and IdentityPermissions among
them, are untouched.
External Authentication's parallel descriptor system is collapsed onto the
core types: its own PermissionDescriptor record, its IPermissionDescriptorProvider
and IPermissionDescriptorRegistry, and DefaultPermissionDescriptorRegistry.
That was not only tidiness. The module's registry was fed exclusively by its
legacy names, so after the cutover every well-formed grant failed the
`unknown_permission_descriptor` check and the warning fired constantly for
correct configuration. The resolver now consults the core catalog, which is
keyed by resource and lists the verbs each accepts, and a wildcard is treated
as advertised because it names a pattern rather than a resource to look up.
The descriptor endpoint serves the core catalog too: choosing what an
external mapping may confer means choosing from everything Elsa declares.
The module contributes its resource descriptors explicitly rather than
relying on the host's assembly scan, for the same reason it registers
AddElsaAuthorization itself.
The two naming tests now pin the new resource name instead of the legacy
string. The convention worth holding was always that the module is called
'diagnostics/console-logs', not that a retired constant kept its old value.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): match the permission descriptor client model to the catalog
Moving the descriptor endpoint onto the core catalog changed its shape from a
single permission string to a resource plus the verbs that resource accepts,
and the Refit client model kept the old one. It still deserialized and still
compiled, handing callers a blank Name and no way to reach the verbs -- the
data went missing without anything failing.
The client model now mirrors the served descriptor, and a contract test
compares the two property sets so the next divergence is a test failure
rather than an empty field. NonCoreVerbs is excluded: the server derives it
from SupportedVerbs, so a client holding the verbs can compute it.
Found by review, not by the suites: nothing here throws.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:04:32 +00:00
## The legacy permission constant classes are gone
The `<Module>Permissions` classes holding `verb:resource` strings — `AIPermissions` , `ConsoleLogsPermissions` ,
2026-08-27 10:06:52 +00:00
`DashboardPermissions` , `ExternalAuthenticationPermissions` , `OpenTelemetryPermissions` , `SecretsPermissions` ,
`StructuredLogsPermissions` and `UserTasksPermissions` — are removed rather than marked obsolete. Referencing one is now a compile
refactor(auth)!: retire the legacy permission constants and duplicate descriptor types (#7987)
* refactor(auth)!: retire the legacy permission constants and duplicate descriptors
Completes the cutover started in #7980. Seven `<Module>Permissions` classes
holding `verb:resource` strings are removed: AIPermissions, ConsoleLogs,
Dashboard, ExternalAuthentication, OpenTelemetry, Secrets and StructuredLogs.
AIPermissions was not in #7982's list, which was written before the cutover
finished; it is dead by the same measure as the rest.
Removed rather than marked obsolete, which #7982 asked to be an explicit
decision. Every string these classes held carries two colons, so it does not
parse under the new grammar and authorizes nothing. Keeping them obsolete
would leave code that compiles, still reads as a permission check, and
silently grants no access -- a warning that is easy to suppress in front of a
runtime failure that is invisible. A compile error names the call site and
can be fixed against the migration guide's mapping table. Classes their own
modules still reference, WorkflowPermissions and IdentityPermissions among
them, are untouched.
External Authentication's parallel descriptor system is collapsed onto the
core types: its own PermissionDescriptor record, its IPermissionDescriptorProvider
and IPermissionDescriptorRegistry, and DefaultPermissionDescriptorRegistry.
That was not only tidiness. The module's registry was fed exclusively by its
legacy names, so after the cutover every well-formed grant failed the
`unknown_permission_descriptor` check and the warning fired constantly for
correct configuration. The resolver now consults the core catalog, which is
keyed by resource and lists the verbs each accepts, and a wildcard is treated
as advertised because it names a pattern rather than a resource to look up.
The descriptor endpoint serves the core catalog too: choosing what an
external mapping may confer means choosing from everything Elsa declares.
The module contributes its resource descriptors explicitly rather than
relying on the host's assembly scan, for the same reason it registers
AddElsaAuthorization itself.
The two naming tests now pin the new resource name instead of the legacy
string. The convention worth holding was always that the module is called
'diagnostics/console-logs', not that a retired constant kept its old value.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): match the permission descriptor client model to the catalog
Moving the descriptor endpoint onto the core catalog changed its shape from a
single permission string to a resource plus the verbs that resource accepts,
and the Refit client model kept the old one. It still deserialized and still
compiled, handing callers a blank Name and no way to reach the verbs -- the
data went missing without anything failing.
The client model now mirrors the served descriptor, and a contract test
compares the two property sets so the next divergence is a test failure
rather than an empty field. NonCoreVerbs is excluded: the server derives it
from SupportedVerbs, so a client holding the verbs can compute it.
Found by review, not by the suites: nothing here throws.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:04:32 +00:00
error, which is deliberate: every string they held is unparseable under the `{resource}:{verb}` grammar, so
keeping them would leave code that still compiles, still reads as a permission check, and silently authorizes
nothing. A compile error names the site and can be fixed against the mapping table below; an obsolete constant
gives a warning that is easy to suppress and a runtime failure that is not visible at all.
Replace each with the module's `<Module>ResourcePermissions` constant and a verb. Classes still referenced by
their own modules — `WorkflowPermissions` , `IdentityPermissions` and the rest — are untouched.
feat(external-auth)!: require a permission to author policy default roles (#7992)
* feat(external-auth)!: require a permission to author policy default roles
Setting the defaultRoleIds of an unlinked-identity policy was guarded only by
the subset rule -- you could not grant roles carrying permissions you did not
hold -- so any actor able to edit a connection could decide what auto-created
users receive. The permission named for that decision,
external-authentication/policies/default-roles:update, was enforced in one
place: removing policy references while deleting a role.
The asymmetry is what makes this look like a check that was never wired
rather than a deliberate carve-out. Its sibling, policies:update, is already
enforced on the write path at both the create and update sites, through the
same RequiresPolicyManagement condition that covers the very policy the roles
live inside.
Demonstrated rather than argued: with the guard stubbed out, a caller holding
only connections:create and policies:update creates a connection whose policy
assigns "workflow-user", and the response is 201. The subset rule does not
object, because it answers a different question -- it prevents escalation, not
delegation of the decision.
The two checks are now reported independently for that reason. The permission
asks whether this actor may decide default roles at all; the subset rule asks
whether these particular roles stay inside what they already hold. It applies
only when roles are actually being set, so clearing the list, or a policy that
assigns none, needs nothing extra.
Breaking for roles holding the legacy policies:manage but not roles:assign
that set default roles today. Anyone who held roles:assign already maps to the
new permission and is unaffected. Documented in the migration guide.
Closes #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): gate default roles on the set changing, not on it existing
Review reproduced the over-reach through the real endpoints: validation runs
on every update, on enabling a connection, and on read-only validate, so
keying the permission off default roles being present meant that once anyone
set them, an administrator without the permission could no longer edit an
unrelated field on that connection, enable it, or validate it.
The permission now applies when the set changes -- adding, removing, or
clearing all count as deciding what auto-created users receive; leaving a
stored set alone does not. Order is not treated as meaningful, so reordering
is not a change.
The test that was supposed to cover this asserted only that a message was
absent, which passes for any failure response and made it vacuous exactly
when it mattered: it passed with the over-reach still in place, because the
request was failing 405 on the wrong verb. It now uses PUT and asserts
success, and reverting the fix makes it fail with the 400 review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): treat abandoning a create-user policy as a role change
The permission check sat inside the create-user branch, so it only ran when
the candidate policy still created users. Switching a stored fallback to one
that does not -- 'reject', or match-user with a different noMatchAction --
skipped it entirely and dropped the policy's automatic role assignments
without the permission that governs them. Review reproduced it.
The effective default roles of a policy that does not create users are none,
so computing that first and comparing outside the branch makes abandonment a
change like any other. The subset rule stays inside the branch, because it
only has something to say about roles actually being assigned.
The new test expresses abandonment through noMatchAction rather than the
policy type, since the fixture's registry only knows match-user. Re-scoping
the check to create-user candidates makes it fail with OK instead of the
expected BadRequest, which is the bypass.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): take the default-role baseline from the registry
A configuration-owned connection has no database row, so comparing against
the store alone made its configured default roles look newly assigned on
every validation. Validation needs only connections:view, so a caller with
exactly that could not validate such a connection at all -- review
reproduced it.
The baseline now comes from the registry, which answers for both ownerships
and is the question actually being asked: what does this connection assign
today. The store remains a fallback for a record the registry does not know.
The new test gives the fixture's configuration connection an unlinked policy
with default roles and validates it as a view-only caller. Reverting to the
store-only baseline makes it fail with the permission error, which is the
symptom review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:31:44 +00:00
## Setting default roles now requires its own permission
Authoring the `defaultRoleIds` of an unlinked-identity policy now requires
`external-authentication/policies/default-roles:update` . Previously only the subset rule applied — you could
not grant roles carrying permissions you did not hold, but any actor who could edit a connection could decide
what auto-created users receive.
The permission was enforced only when removing policy references during role deletion, while its sibling
`external-authentication/policies:update` was already enforced on the write path. That asymmetry is what this
closes, and it makes "may configure connections, may not decide what auto-created users receive" expressible.
**Who this affects.** Anyone who held legacy `external-authentication:roles:assign` already maps to the new
permission and is unaffected. The break is for roles holding `external-authentication:policies:manage`
(→ `policies:view` + `policies:update` ) but *not* `roles:assign` , which set default roles today. Grant them
`external-authentication/policies/default-roles:update` , or move that responsibility to a role that has it.
The permission is required when the default-role set **changes** — adding, removing, clearing, or switching
the policy away from one that creates users, which drops its roles just as surely. Leaving a stored set alone
needs nothing extra, so an administrator without the permission can still edit other fields on a connection
whose default roles someone else configured, enable it, or validate it.
2026-08-27 10:06:52 +00:00
## User tasks join the structured vocabulary
User Tasks was still declaring access through the legacy channel after the rest of the codebase had moved, so its
nine permissions are re-authored in this release: `read:user-tasks` and its siblings become verbs on a `user-tasks`
resource, and participant lookup becomes the sub-resource `user-tasks/participants` .
**This is a breaking change for anyone granting the legacy strings**, which is everyone who granted User Tasks
anything — they were the only spelling that ever worked. Rewrite them from the table below. The strings do not
merely stop matching new-style grants; they were being compared as exact claim values, so nothing else had ever
matched them either.
**Pattern grants now reach these endpoints for the first time.** Under the legacy declaration a claim had to equal
the required string character for character, so `*:view` , `user-tasks:*` and `user-tasks/*:view` all failed against
every User Tasks endpoint even though they read as though they covered it. A bare `*` worked, because it was
special-cased. If you worked around this by granting the exact legacy strings alongside a pattern, the pattern is
now doing the work and the legacy strings can go.
**`manage:user-tasks` becomes `user-tasks:supervise` , not `user-tasks:manage` .** The permission never was an
aggregate — it grants tenant-wide oversight (read every task, assign, reschedule, cancel, see blocked tasks, retry
a failed resolution) and confers none of `claim` , `complete` , `assign` , `cancel` or `invite` . It is renamed for the
same reason `workflows/runtime:control` is not called `manage` : a name that reads like an aggregate invites being
granted as one.
**Recorded consequence:** because participant lookup is a sub-resource, `user-tasks/*:view` now grants it along
with task read, where legacy `read:user-tasks` did not. Participant lookup returns a tenant-scoped directory of
users and groups, so a role that should read tasks without enumerating the directory must name `user-tasks:view`
rather than the subtree.
If you implement `IUserTaskAccessPolicy` or construct `UserTaskActor` yourself: `UserTaskActor.HasPermission` now
takes a `Permission` (or a resource and verb) instead of a single string, and matches through `PermissionMatcher`
rather than by equality, so pattern grants reach your policy too. `UserTaskActor.Permissions` is compared ordinally
rather than case-insensitively, matching the rest of the model.
refactor(identity)!: retire the SecurityRoot policy in favour of endpoint permissions (#8003)
* refactor(identity)!: retire the SecurityRoot policy in favour of endpoint permissions
Completes T040. ADR 0010 already decided SecurityRoot was overloaded and that
endpoints should be authorized by their own permissions; this removes the last
of it.
Roles/Create and Applications/Create carried Policies(SecurityRoot) alongside
an existing RequirePermission, so the policy was redundant there and the line
is simply dropped.
Secrets/Hash carried only the policy. By default SecurityRoot resolved to
RequireAuthenticatedUser(), so any signed-in caller could exercise the password
hasher. It now declares identity/users:create, on the grounds that hashing a
secret is a step in provisioning a credential. This is a tightening: callers
who could hash before and hold no user-creation permission will now be refused.
The policy, its two registration paths and the IdentityPolicyNames constant are
removed. ConfigureAuthorizationOptions stays public and now defaults to a no-op
so hosts that add their own policies are unaffected.
BREAKING CHANGE: the SecurityRoot authorization policy and the
IdentityPolicyNames class are removed. Hosts referencing either should rely on
endpoint permissions, and use DefaultAdminUserFeature for initial bootstrap.
Note: SecurityRoot was the only attachment point for LocalHostPermissionRequirement,
so the localhost permission grant is now inert. The requirement type and the
EnableLocalHostPermissionGrantForSecurityRoot toggles are left in place rather
than deleted, but they no longer gate anything -- see the PR for why that path
was already incoherent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(identity)!: delete the localhost bootstrap grant and its machinery
Follows the SecurityRoot removal in the previous commit. SecurityRoot was the
only attachment point for LocalHostPermissionRequirement, so the localhost
permission grant is now removed outright rather than left inert:
LocalHostPermissionRequirement, LocalHostRequirement (already dead -- registered
as a handler but consumed by no policy), LocalHostPermissionRequirementOptions
and the two feature toggles all go.
The grant was the weakest of the three bootstrap mechanisms Elsa already has. It
trusted network position, which stops meaning anything behind a reverse proxy,
inside a container, or across a port-forward; it granted unauthenticated access,
so the bootstrap action carried no identity; it covered only localhost, so it
did nothing for a deployed environment; and it could not perform its headline
job, because it granted identity/users:create while POST /identity/users does
not carry the policy that injected it.
The replacements already exist and both work in deployed environments:
UseDefaultAdmin(...) seeds an admin role and user at startup, idempotently, and
UseAdminApiKey(...) accepts an out-of-band key. What the localhost grant did
usefully provide was a hint that something needed configuring, so
IdentityBootstrapDiagnostic replaces that: when the user store is empty and
neither mechanism is configured, startup logs an error naming both, instead of
every endpoint answering 403 with no explanation.
BREAKING CHANGE: LocalHostRequirement, LocalHostPermissionRequirement,
LocalHostPermissionRequirementOptions and the
Enable/DisableLocalHostPermissionGrantForSecurityRoot toggles are removed. Use
UseDefaultAdmin or UseAdminApiKey to bootstrap an instance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(identity): scope the hash endpoint's documentation to users, and pin the declarations
The hash endpoint's remarks said the callers that need it are "the ones standing up
users and applications", while the endpoint requires identity/users:create alone. An
application provisioner reading that would have been sent into a 403.
The documentation was the part that was wrong. `POST /identity/applications` generates
and hashes the client secret and the API key itself and returns both the plaintext and
the hash, so identity/applications:create is already sufficient to create an application
and the hash endpoint is not on that path at all. Say so, in the endpoint and in the
migration guide, rather than widening a grant nobody needs.
Adds EndpointPermissionTests over the three endpoints that carried the retired
SecurityRoot policy: the two that only lost a redundant policy line must keep the
permission they already declared, and Secrets/Hash must keep the one it gained. The
coverage gate only asks whether an endpoint declares something, so either half could
otherwise change unnoticed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(identity): state the hash endpoint's user-only scope in the summary, and complete the removal list
Moves the user-only scoping into the endpoint's <summary>, which is the part that reaches
the generated API description, rather than leaving it to a paragraph further down. The
remark now says outright that no application-provisioning flow reaches this endpoint and
none is documented to, with the reason: POST /identity/applications generates the client
secret and the API key itself, hashes both, and returns each plaintext alongside its hash.
The migration guide's removal list was partial — it named the requirements and the two
toggles but not the handlers, the options type, the EnableLocalHostPermissionGrant property
on either feature, or the already-obsolete DisableLocalHostRequirement() alias. A reader
hitting a compile error on any of those would not have found it in the guide. It also now
records that ConfigureAuthorizationOptions survives as a no-op default.
Adds the store-failure case to IdentityBootstrapDiagnosticTests: the broad catch is
load-bearing — an unmigrated database must not stop the host from starting — and nothing
was holding it in place. Disposes the test service provider, and folds the repeated arrange
blocks in DefaultAuthenticationFeatureTests into fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:28:29 +00:00
## The SecurityRoot policy and the localhost grant are gone
`SecurityRoot` is removed, and with it every type and switch that existed only to serve it:
`IdentityPolicyNames` , `LocalHostRequirement` and `LocalHostRequirementHandler` ,
`LocalHostPermissionRequirement` and `LocalHostPermissionRequirementHandler` ,
`LocalHostPermissionRequirementOptions` , the `EnableLocalHostPermissionGrant` property on both
`DefaultAuthenticationFeature` types, and the `EnableLocalHostPermissionGrantForSecurityRoot` /
`DisableLocalHostPermissionGrantForSecurityRoot` toggles along with the already-obsolete
`DisableLocalHostRequirement()` alias. Calls to any of them are now compile errors; delete them, since with no
policy left to grant into there is nothing for them to configure. The
`ConfigureAuthorizationOptions` hook on `DefaultAuthenticationFeature` stays, now defaulting to a no-op rather
than to registering the policy, so a host that adds policies of its own keeps working unchanged. ADR 0010 had already decided endpoints should be
authorized by their own permissions; this finishes it.
Two of the three endpoints that used the policy (`Roles/Create`, `Applications/Create` ) already declared a
permission, so nothing changes for them. ** `POST /identity/secrets/hash` is a tightening**: `SecurityRoot`
resolved by default to `RequireAuthenticatedUser()` , so any signed-in caller could exercise the password
hasher. It now requires `identity/users:create` . The scope is user-only on purpose: application provisioning
does not go through it, because `POST /identity/applications` generates and hashes the client secret and API
key itself and returns both, so `identity/applications:create` alone remains sufficient to create an
application.
**If you relied on the localhost permission grant to bootstrap an instance**, configure one of these instead —
both work in a deployed environment, not just on localhost, and both attach an identity to whatever the
caller then does:
- **A seeded administrator.** `UseDefaultAdmin(username, password, roleName, permissions)` , or the
`DefaultAdminUser` configuration section. It creates the admin role and user at startup and is idempotent,
so it is safe to leave configured.
- **An admin API key.** `UseAdminApiKey(key)` or the `AdminApiKey` setting. Disabled unless configured.
The localhost grant trusted network position, which stops meaning anything behind a reverse proxy, inside a
container, or across a port-forward — and it granted *unauthenticated* access, so the bootstrap action had no
identity to audit. It was also already unable to do the thing it existed for: it granted
`identity/users:create` , but `POST /identity/users` did not carry the policy that injected it.
If neither is configured and no users exist, startup now logs an error naming both options, rather than
leaving every endpoint to answer 403 without explanation.
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 14:19:22 +00:00
## 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.
2026-08-27 09:45:49 +00:00
One caveat: the composite indexes only cover rows whose `TenantId` is non-null (SQL Server filters null rows out of the index; SQLite, PostgreSQL and MySQL treat nulls as distinct — Oracle alone still rejects null-tenant duplicates). `TenantId` is only assigned when multitenancy is enabled, so in a single-tenant deployment every row keeps null and user, role and application name uniqueness becomes application-enforced rather than schema-enforced: the pre-save existence checks block sequential duplicates, but the database no longer backstops concurrent ones. Likewise, rows written before the upgrade keep a null `TenantId` , and in a multi-tenant deployment's default tenant those legacy rows and new `""` -tenant rows are distinct index keys, so the index cannot catch a name collision between them. See the same caveat, with the reasoning, in [secrets-tenancy.md ](secrets-tenancy.md ).
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 14:19:22 +00:00
## 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` |
refactor(auth): remove the vestigial per-author script permission plumbing (#7990)
* refactor(auth): remove the vestigial per-author script permission plumbing
#7975 is closed won't-do: authoring a workflow is a trusted act, and a
per-author gate would not change what a script can do once it runs. The host
switch stays the control, and it is per language, so an untrusted author gets
a host with the switch off rather than a permission.
That settles what the code was still half-carrying. WorkflowDefinitionScriptAuthorizationService
took a ClaimsPrincipal it never read, and could return a MissingPermission
reason nothing produced; two call sites branched on that reason to send a 403
that could not happen. The expression-descriptor endpoint kept a map from
expression type to per-author permission whose values went unused even before
the permissions were retired -- it only ever tested membership, and the
decision was always IsBrowsable. Each of these reads as an authorization gate
to anyone scanning the file, and none of them is one.
The principal, the unreachable reason, and both dead branches are gone. The
map becomes a set of the expression types the host can switch off, which is
what it was actually being used as. Behaviour is unchanged: the only failure
is a language the host disabled, which is a property of the deployment and
so a 400 naming the switch, never a 403.
PermissionNames loses ExecuteCSharpExpressions and ExecutePythonExpressions,
which existed only for that map and the test mirroring it. Five other legacy
constants there are also unreferenced but belong to other modules; they are
left alone rather than swept up here.
Two tests asserting the host-and-user case were exact duplicates of the
host-only case once the principal stopped mattering, so they go with it.
The migration guide said deployments lose per-author granularity "until
#7975 lands" and advised disabling host code until then. That promise is
withdrawn and replaced with the actual guidance.
Closes #7975
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(wiki): drop the retired exec:* permissions from the scripting guide
Review found doc/wiki/expressions-and-scripting.md still telling operators
that API callers "must have the exec:csharp-expressions permission" to
author, publish, dispatch or execute workflows containing C#, and the same
for Python. Those permissions no longer exist, so the instruction cannot be
followed and describes a gate that is not there.
Both sections now say what is actually true: the host switch is the whole
control, there is no per-caller permission because a workflow runs under the
server's authority rather than the caller's, and an untrusted author gets a
host with the switch off. The switches are noted as independent, since
enabling Python while leaving C# off is a real posture.
My earlier sweep searched for the issue number rather than the permission
strings, which is why this file was missed.
Refs #7975
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:58:16 +00:00
| `exec:csharp-expressions` | *removed* — the host switch is the control; see #7975 |
| `exec:python-expressions` | *removed* — the host switch is the control; see #7975 |
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 14:19:22 +00:00
| `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` |
2026-08-27 10:06:52 +00:00
| `read:user-tasks` | `user-tasks:view` |
| `claim:user-tasks` | `user-tasks:claim` |
| `complete:user-tasks` | `user-tasks:complete` |
| `assign:user-tasks` | `user-tasks:assign` |
| `update:user-tasks` | `user-tasks:update` |
| `cancel:user-tasks` | `user-tasks:cancel` |
| `invite:user-tasks` | `user-tasks:invite` |
| `manage:user-tasks` | `user-tasks:supervise` |
| `lookup:user-task-participants` | `user-tasks/participants:view` |
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 14:19:22 +00:00
| `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 |