Skip to content

A plugin installation brings its role with it - #377

Merged
BechsteinDigital merged 1 commit into
mainfrom
feat/plugin-role-provisioning
Sep 2, 2026
Merged

A plugin installation brings its role with it#377
BechsteinDigital merged 1 commit into
mainfrom
feat/plugin-role-provisioning

Conversation

@BechsteinDigital

Copy link
Copy Markdown
Owner

Why

Declaring permission keys made them grantable. It did not put them anywhere.

The last step was the operator's: install a plugin, open role administration, click its keys together
into a role by hand. Miss it, and the plugin is installed, its screens answer 403, and the only
account that can use it is the super admin — with nothing anywhere saying why.

That is the same failure class already on record twice in this codebase (BackendPermissionKeyValidator,
BackendPermissionInventory): Absicherung wirksam, Vergabe unmöglich. This closes the third instance.

What

Every plugin that has permissions at all now gets one role, <pluginId>.admin, holding all of them.

Both supply paths, one role. A plugin declares its keys in the manifest or contributes them
through IHostAdminApiExtensionContributor — and of the four plugins installed here exactly one uses
the first. Reading only the manifest would have left the other three without a role, silently: an empty
key list is indistinguishable from "has no permissions".

One role, not three. "View", "manage", "announcements only" are cuts the plugin has to know; here
only the set of keys is known. A guessed split would be worse than none — it looks considered, so
nobody checks it. Finer roles come from the manifest once it can carry them; until then they are one
click away from this one.

Created once, then never touched. Take a key out, add another, rename it — that survives every
restart. A tuning decision silently reverted at the next start is worse than a missing permission: the
missing one is visible, the reverted one is not, and it is invisible exactly when it leaves somebody
able to do something they should no longer be able to do. What a plugin update declares on top becomes
a log line — deliberately the weaker delivery, because it can be missed but cannot overwrite a
decision.

Found by identity, not by name. (ProvisionedByPluginId, ProvisionedAs) — renaming the role in the
UI does not put a second one beside it at the next start. A role a human already created under that
name is left alone and reported: it belongs to them, and adopting it would mean deleting it with the
plugin.

Two entry points, one provisioner. A hosted service registered after plugin rehydration for what
is already installed, and a PluginLifecycleChangedEvent subscriber for what arrives later — the same
shape the custom-field, snippet and sensitive-field syncs already use. The ordering is load-bearing and
has its own gate: registered earlier, the contributor-path plugins get no role and the start still
looks clean.

Uninstall removes nothing. A user has exactly one role; deleting it would take away every access
they have, including the parts that have nothing to do with the plugin. A role whose plugin is gone
grants keys that match nothing — harmless and visible.

A comment that was wrong

The index comment claimed its filter is what lets several hand-made roles coexist. Measured: it is not.
Postgres treats NULLs in a unique index as distinct, and the test passes with the filter removed. The
filter exists so the index covers only provisioned rows. Both the comment and the test now say that,
and the test stays — it would notice the day somebody switches the index to NULLS NOT DISTINCT.

What this does not fix

A workspace-scoped session carries the permissions in WorkspaceRolePermissions.ForRole and nothing
else — a hardcoded core-key list — and BackendClaimsTransformation returns early for workspace scope.
So a provisioned role takes effect for platform-scoped operators; a workspace administrator still
cannot hold a plugin key at all.

That gap predates this mechanism and is a separate decision: which plugin keys a workspace admin may
carry (plausibly: those of the plugins activated for their workspace). It is documented in the manifest
guide rather than quietly widened here.

Verification

dotnet build Callora.Host.sln -p:SkipAdminFrontend=true   # succeeded
dotnet test  Callora.Host.sln -p:SkipAdminFrontend=true
  Analyzers  34/34
  Core     1757/1757

13 new tests, including 5 against a real Postgres. Proven red first:

  • hosted service moved before rehydration → ordering gate fails
  • create-once guard removed → drift test fails
  • name-collision check removed → collision test fails

Declaring permission keys made them grantable; it did not put them anywhere. The last step was left to
the operator: install a plugin, open role administration, click its keys together by hand. Miss it and
the plugin is installed, its screens answer 403, and the only account that can use it is the super
admin — with nothing anywhere saying why. The same failure class this codebase has now hit three
times: enforcement works, granting does not.

Every plugin that has permissions at all now gets one role, <pluginId>.admin, holding all of them.

Both supply paths, one role. A plugin declares its keys in the manifest or contributes them through
IHostAdminApiExtensionContributor, and of the four plugins installed here exactly one uses the first.
Reading only the manifest would have left the other three without a role, silently — an empty key list
is indistinguishable from "has no permissions".

One role, not three. "View", "manage", "announcements only" are cuts the plugin has to know; here only
the set of keys is known. A guessed split would be worse than none: it looks considered, so nobody
checks it.

Created once, then never touched. Take a key out, add another, rename it — that survives every
restart. A tuning decision silently reverted at the next start is worse than a missing permission: the
missing one is visible, the reverted one is not, and it is invisible exactly when it leaves somebody
able to do something they should no longer be able to do. What a plugin update declares on top becomes
a log line, deliberately the weaker delivery — it can be missed, but it cannot overwrite a decision.

The role is found by (ProvisionedByPluginId, ProvisionedAs), not by name, so renaming it in the UI
does not put a second one beside it at the next start. A role a human already created under that name
is left alone and reported: it belongs to them, and adopting it would mean deleting it with the
plugin.

Two entry points, one provisioner: a hosted service registered after plugin rehydration for what is
already installed, and a PluginLifecycleChangedEvent subscriber for what arrives later — the same
shape the custom-field, snippet and sensitive-field syncs already use. Uninstall removes nothing: a
user has exactly one role, and deleting it would take away every access they have, including the parts
that have nothing to do with the plugin.

Corrected while writing it: the index comment claimed its filter is what lets several hand-made roles
coexist. Measured — it is not. Postgres treats NULLs in a unique index as distinct, and the test
passes without the filter. The filter is there so the index covers only provisioned rows, and the
comment now says that.

Not closed by this, and worth knowing: a workspace-scoped session carries the permissions in
WorkspaceRolePermissions.ForRole and nothing else — a hardcoded core-key list. A provisioned role
therefore works for platform-scoped operators; a workspace administrator still cannot hold a plugin
key. That gap predates this mechanism.

13 new tests. Proven red: move the hosted service before rehydration and the ordering gate fails;
remove the create-once guard and the drift test fails; remove the name check and the collision test
fails. Full suite 1757 + 34 green.
@BechsteinDigital
BechsteinDigital merged commit 26bde92 into main Sep 2, 2026
9 checks passed
@BechsteinDigital
BechsteinDigital deleted the feat/plugin-role-provisioning branch September 2, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant