feat(security): the tenant scope — the level between operator and workspace - #385
Merged
Conversation
ADR-014 §18 names a TenantAdmin who manages a customer's workspaces while workspace admins work per workspace. The code had no such level: BackendAuthScopes knows platform and workspace, nothing between them. So "the agency runs the instance, its customers manage their own workspaces" was not expressible — you were either an operator seeing every tenant, or locked into exactly one workspace. TenantMembership is its own aggregate rather than a nullable WorkspaceId on WorkspaceMembership. A nullable one would have made every existing "is a member of this workspace" query silently ambiguous: rows without a workspace would count everywhere nobody expects them. That is the failure class the comment in BackendClaimsTransformation records — one namespace meaning two things, invisible at the query. No assignable roles at this level, unlike the workspace. The questions here are "administers the tenant" or "sees it"; a second set of tables for a distinction nobody has needed yet would be stock. The extension point is the same one the workspace uses, should it be needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iSX3geDv7PNRmiusRr1xd
BackendAuthScopes.Tenant plus the tenant_key claim. The scope carries no workspace
binding, so WorkspaceScopeEvaluator.HasWorkspaceAccess refuses it for workspace
work — a tenant session administers a customer, it does not stand in for a
workspace session.
The claims transformation has to exit early for it, for the same reason it already
does for workspace sessions: a tenant membership is called "admin" too, and a
platform RBAC role may carry that name. Without the exit every TenantAdmin of every
tenant would inherit that role's platform permissions — precisely what the level
exists to prevent.
Written as an enumeration ("workspace or tenant"), not a negation ("not platform"),
because authenticated principals without a scope claim exist and the projection IS
their path. A negation would take their permissions away silently — a failure that
looks like a permission problem and is not one. The existing test
SessionWithoutAScopeClaim_StillReceivesTheProjectedPermissions is what makes that
concrete, and its remark now says so.
Both new tests were proven red first: with the tenant clause removed they fail and
the four existing ones stay green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iSX3geDv7PNRmiusRr1xd
A tenant caller is bound to no workspace. Without a second dimension it is simply "not workspace-scoped" — which the PLAT-267 filter reads as bypass, the same as an operator. For a customer who does not own the instance that is exactly the reach into foreign tenants the filter exists to prevent: every customer of an agency would read every other customer's rows. The tenant filter is a positive list, not a bypass. Visible is only what means something at tenant level — plugin activation and entitlement, both of which already carry TenantKey, plus the workspace and membership tables that describe the level itself. Everything else (media, flows, jobs, webhooks, custom fields, plugin documents) is work inside a workspace and stays empty for a tenant session. Whoever wants to work in there signs in to the workspace. Decided this way round because the other one would make the tenant scope a second operator under a different name. The write backstop is deliberately tighter than the read filter: only rows carrying the caller's own TenantKey may be written, a platform-wide entitlement (TenantKey == null) included in the refusal — granting that to oneself would grant it to everyone. Its limit is named in the code: entities that reference the tenant by foreign key rather than by key (Workspace.TenantId) are out of its reach, because it compares values, not relationships. The endpoint has to carry that. Proven red first against real Postgres: with TenantFilterActive forced to false all three new tests fail and the two existing ones stay green. Full core suite afterwards: 1797 passed, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iSX3geDv7PNRmiusRr1xd
Large enough that a customer manages their own house, small enough that they never reach the house next door or the building itself. No plugin.create and no plugin.delete. Those mean "artifact on the host" — one binary, one version, one schema for every tenant on the instance. A customer holding them would pull third-party code into the process the other customers run in. plugin.read is enough to see what is available; assigning it to one's own workspace is a separate question and gets its own key. Workspaces read-only, and that is a decision rather than an omission. workspace.create would write Workspace.TenantId — the very field the write backstop cannot check, because it compares values and not relationships. Until the endpoint enforces the tenant binding itself, the permission would be a way to create a workspace under someone else's tenant. It arrives when the endpoint carries it. User writes stay with the operator for the same reason they do in a workspace (#102): they act on the global BackendUser — credentials, erasure, subject access — and so reach into every tenant the person also belongs to. 10 tests, all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iSX3geDv7PNRmiusRr1xd
… the token The login resolves a tenant-scoped session for a member of a TenantMembership when no workspace is named. A named workspace still wins, even for a tenant member: whoever names a workspace wants to work in it, and the tenant level administers rather than works. Were it the other way round, somebody holding both memberships would land in a session that no longer sees their media and flows — down-scoped without asking for it. Nobody is up-scoped either: a tenant membership opens no workspace the person does not belong to. That is its own decision with its own check, and until it is taken, membership decides. A tenant membership named like an operator role is refused for the same reason a workspace one is: the membership role becomes the session's role claim, and WorkspaceScopeEvaluator.IsOperator reads the name. Fixed on the way, from my own commits 2a9013e and ee25018: WorkspaceSessionPermissions sat as an optional parameter on the private handler, but no route ever bound it. The handler therefore always got its null default and AdminLoginResolver fell back to the fixed core set — a workspace admin never saw the keys of their workspace's plugins. The composition had tests; the path to it did not, and in production the path is what counts. The service is now bound at the route, where [FromServices] actually applies, and the comment moved with it. The regression test goes over HTTP and reads the issued token — one that called the service directly would have been green before the fix too. Proven red: with the route reverted it fails alone, the other 16 stay green. Full run afterwards: 1815 + 34 passed, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iSX3geDv7PNRmiusRr1xd
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ADR-014 §18 names a
TenantAdminwho manages a customer's workspaces while workspace admins work per workspace. The code had no such level:BackendAuthScopesknewplatformandworkspace, nothing between them. So the case this exists for — an agency runs the instance, its customers run their tenants — was not expressible. You were either an operator seeing every tenant, or locked into exactly one workspace.This adds the missing level. It does not yet add screens; it makes the admin rebuild able to have a customer level at all, which is the thing that cannot be retrofitted later without redoing every screen context and the whole permission projection.
What is in it
TenantMembership— its own aggregate, not a nullableWorkspaceIdonWorkspaceMembership. A nullable one would have made every existing "is a member of this workspace" query silently ambiguous: rows without a workspace would count everywhere nobody expects them. No assignable roles at this level, unlike the workspace — the questions here are "administers the tenant" or "sees it", and a second set of tables for a distinction nobody has needed yet would be stock.BackendAuthScopes.Tenant+tenant_keyclaim. The scope carries no workspace binding, soWorkspaceScopeEvaluator.HasWorkspaceAccessrefuses it for workspace work. The claims transformation exits early for it, exactly as it already does for workspace sessions: a tenant membership is calledadmintoo, and a platform RBAC role may carry that name. Written as an enumeration (workspace or tenant), not a negation (not platform), because authenticated principals without a scope claim exist and the projection is their path — the existing testSessionWithoutAScopeClaim_StillReceivesTheProjectedPermissionsis what makes that concrete.The persistence backstop. This is the security-critical part. A tenant caller is bound to no workspace, so without a second dimension it is simply "not workspace-scoped" — which the PLAT-267 filter reads as bypass, the same as an operator. For a customer who does not own the instance that is exactly the reach into foreign tenants the filter exists to prevent.
The tenant filter is a positive list, not a bypass. Visible is only what means something at tenant level — plugin activation and entitlement, both of which already carry
TenantKey, plus the workspace and membership tables that describe the level itself. Everything else (media, flows, jobs, webhooks, custom fields, plugin documents) is work inside a workspace and stays empty for a tenant session. Decided this way round because the other one would make the tenant scope a second operator under a different name.The write backstop is deliberately tighter than the read filter: only rows carrying the caller's own
TenantKeymay be written, a platform-wide entitlement (TenantKey == null) included in the refusal. Its limit is named in the code rather than left to be discovered: entities that reference the tenant by foreign key rather than by key (Workspace.TenantId) are out of its reach, because it compares values and not relationships.TenantRolePermissions— large enough that a customer manages their own house, small enough that they never reach the house next door or the building itself.plugin.create, noplugin.delete. Those mean artifact on the host — one binary, one version, one schema for every tenant on the instance. A customer holding them would pull third-party code into the process the other customers run in.plugin.readis enough to see what is available; assigning it to one's own workspace is a separate question and gets its own key.workspace.createwould writeWorkspace.TenantId— the very field the write backstop cannot check. It arrives when the endpoint carries the tenant binding itself.BackendUserand so reach into every tenant the person also belongs to.Login. A tenant-scoped session for a member of a
TenantMembershipwhen no workspace is named. A named workspace still wins, even for a tenant member — otherwise somebody holding both memberships would land in a session that no longer sees their media and flows, down-scoped without asking. Nobody is up-scoped either: a tenant membership opens no workspace the person does not belong to.Fixed on the way
From my own commits
2a9013e6andee25018c:WorkspaceSessionPermissionssat as an optional parameter on the private login handler, but no route ever bound it. The handler always got itsnulldefault andAdminLoginResolverfell back to the fixed core set — a workspace admin never saw the keys of their workspace's plugins. The composition had tests; the path to it did not, and in production the path is what counts. The service is now bound at the route, where[FromServices]actually applies.Verification
Every new guard was proven red before being trusted:
TenantFilterActiveforced tofalse, all three new tests fail and the two existing ones stay greenFull run on the branch:
Not in this PR, and deliberately
plugin.createPluginContext.PluginConfigurationstill hands plugins the host's appsettings rather than the workspace-resolvingIPluginConfigReaderThese are additive within screens. This PR is the part that is not.