Skip to content

feat(security): the tenant scope — the level between operator and workspace - #385

Merged
BechsteinDigital merged 5 commits into
mainfrom
feat/tenant-scope
Sep 3, 2026
Merged

feat(security): the tenant scope — the level between operator and workspace#385
BechsteinDigital merged 5 commits into
mainfrom
feat/tenant-scope

Conversation

@BechsteinDigital

@BechsteinDigital BechsteinDigital commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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 knew platform and workspace, 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 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. 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_key claim. The scope carries no workspace binding, so WorkspaceScopeEvaluator.HasWorkspaceAccess refuses it for workspace work. The claims transformation exits early for it, exactly as it already does for workspace sessions: a tenant membership is called admin too, 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 test SessionWithoutAScopeClaim_StillReceivesTheProjectedPermissions is 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 TenantKey may 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.

  • No plugin.create, 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. workspace.create would write Workspace.TenantId — the very field the write backstop cannot check. It arrives when the endpoint carries the tenant binding itself.
  • User writes stay with the operator for the same reason they do in a workspace ([Security][Critical] Scope workspace-admin user operations to prevent cross-tenant account takeover #102): they act on the global BackendUser and so reach into every tenant the person also belongs to.

Login. 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 — 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 2a9013e6 and ee25018c: WorkspaceSessionPermissions sat as an optional parameter on the private login handler, but no route ever bound it. The handler 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.

Verification

Every new guard was proven red before being trusted:

  • claims transformation — with the tenant clause removed, both new tests fail, the four existing ones stay green
  • persistence filter — against real Postgres, with TenantFilterActive forced to false, all three new tests fail and the two existing ones stay green
  • the login regression — with the route reverted it fails alone, the other 16 stay green

Full run on the branch:

Callora.Core.Tests      1815 passed, 0 failed, 0 skipped
Callora.Analyzers.Tests   34 passed, 0 failed, 0 skipped

Not in this PR, and deliberately

  • The three-state plugin governance at the tenant (org-installed / approved / restricted, the Slack Enterprise Grid shape)
  • A key for "assign an available plugin to my workspace" — distinct from plugin.create
  • PluginContext.PluginConfiguration still hands plugins the host's appsettings rather than the workspace-resolving IPluginConfigReader
  • Admin screens for any of it

These are additive within screens. This PR is the part that is not.

BechsteinDigital and others added 5 commits September 3, 2026 21:32
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
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