Tenant isolation
The internal Workspace is the tenant boundary shown to customers as an Organization. Files, usage, subscription state, entitlements, members, notifications, audit, credentials, and lifecycle records are organization-bound.
Security · Organizations and tenancy
The model
- One database is shared by every organization. The internal name for an organization is
Workspace. - Each row owned by an organization has a
WorkspaceId. - A user can belong to several organizations, and each request is for one of them.
- Platform operators are not members of the organizations they administer.
The risk in this model is a query that forgets its organization: it returns another customer's data and nothing fails. The template moves that responsibility from each query to the database connection, using OrmLite connection filters, and makes forgetting it fail.
Classify every table
Every table is one of three kinds, recorded in features.json and in its class:
| Kind | Meaning | In code |
|---|---|---|
tenantOwned | Every row belongs to one organization | Implements IHasWorkspaceId |
global | Shared by every organization, such as plans | No WorkspaceId |
platformOperational | Run by the platform and not about one organization, such as Stripe events | No WorkspaceId |
[UniqueConstraint(nameof(WorkspaceId), nameof(IdempotencyKey))]
[CompositeIndex(nameof(WorkspaceId), nameof(Status), nameof(CreatedDate))]
public class StoredFile : SaasAuditBase, IHasWorkspaceId
{
[PrimaryKey] public string Id { get; set; } = Guid.NewGuid().ToString("N");
[References(typeof(Workspace))] public string WorkspaceId { get; set; } = default!;
// ...
}Two rules keep the classification useful:
- A table's rows all belong to an organization, or none do. A nullable
WorkspaceIdmeans a table holds two kinds of row, and a row that forgets its organization is silently written with none. The audit log is two tables for this reason:SaasAuditEventfor what happened in an organization, andPlatformAuditEventfor what operators did that is not about one. - Do not own a table indirectly. A table that only reaches its organization through a parent row cannot be filtered. Give it its own
WorkspaceId.
Every tenant-owned table also has an index, unique constraint, or primary key that starts with WorkspaceId, because every query on it is filtered by it. Business keys are unique within an organization, not globally.
The request says which organization it is for
A user can have different organizations open in different browser tabs, so an API for an organization says which one in its Request DTO by implementing IRequireWorkspace:
[ValidateIsAuthenticated]
[Route("/saas/files/{Id}", "DELETE")]
public class DeleteStoredFile : IDelete, IReturn<EmptyResponse>, IRequireWorkspace
{
public string WorkspaceId { get; set; } = default!;
[ValidateNotEmpty] public string Id { get; set; } = "";
}The WorkspaceId is a claim, not a credential. It only says which organization the request is for. Whether the caller can use it comes from their persisted membership, which is checked on every request.
| Request | Result |
|---|---|
No WorkspaceId | 400 WorkspaceIdRequired |
| An organization the caller is not an active member of, or that does not exist | 403 WorkspaceAccessDenied |
| Sent with an API key for another organization | 403 WorkspaceAccessDenied |
Sent with an API key, without a WorkspaceId | The key's organization |
The browser keeps each tab's organization and passes it with every call. See Organizations and tenancy.
Connections are confined to the organization
The AppHost configures every connection opened for a request with SaasDb.ForRequest(). This covers Db in services and the connections opened by AutoQuery.
public override void Configure()
{
DbConnectionRequestFilters.Add((db, req) => db.ForRequest(req));
}The connection is disposed if ForRequest() throws, for example when the user isn't a member of the organization.
public static IDbConnection ForRequest(this IDbConnection db, IRequest request)
{
// Audit columns record who made the request: the signed-in user, or the user of its API key
db.SetUserId(request.GetUserId());
// Every connection a request opens shares one scope, which starts without an organization.
// Setting the organization on the scope confines all of them.
if (!request.Items.TryGetValue(nameof(WorkspaceScope), out var existing) || existing is not WorkspaceScope scope)
request.Items[nameof(WorkspaceScope)] = scope = new WorkspaceScope();
db.ForWorkspace(scope);
// An API key is for the organization it was created for
var apiKeyWorkspaceId = request.GetApiKey()?.RefIdStr;
// An API for an organization says which one in its Request DTO. The first connection opened for it
// checks the user is a member of that organization, and that it's in a state the API can be used in.
if (request.Dto is IRequireWorkspace requireWorkspace && !scope.HasMember)
{
if (!apiKeyWorkspaceId.IsNullOrEmpty())
{
// A request sent with an API key doesn't need to say which organization, but can't say another
if (requireWorkspace.WorkspaceId.IsNullOrEmpty())
requireWorkspace.WorkspaceId = apiKeyWorkspaceId!;
else if (requireWorkspace.WorkspaceId != apiKeyWorkspaceId)
throw new HttpError(403, "WorkspaceAccessDenied", "This API key is for another organization.");
}
// Fails unless the user is a member of the organization, and it's in a state the API can be used in
var (workspace, member) = request.TryResolve<ISaasManager>().AssertMembership(db, requireWorkspace.WorkspaceId,
WorkspaceAccessPolicy.For(requireWorkspace.GetType(), request.Verb));
// Confines every connection of the request, and is what its Services use: Db.GetWorkspaceScope()
scope.Confine(workspace, member);
}
// Any other request sent with an API key can only use the key's organization
if (!apiKeyWorkspaceId.IsNullOrEmpty())
scope.Confine(apiKeyWorkspaceId!);
return db;
}The filters are declared once in a FilterSet, which reads the organization from the request's scope for each statement and throws until it has one. ForWorkspace(scope) uses it with the request's scope:
public static readonly FilterSet<WorkspaceScope> WorkspaceFilters = FilterSet.Create<WorkspaceScope>(f => {
f.Ensure<IHasWorkspaceId>(x => x.WorkspaceId, s => s.AssertWorkspaceId());
f.Filter<Workspace>((x, s) => x.Id == s.AssertWorkspaceId());
// API keys aren't filtered until the request's organization is resolved
f.Filter<ApiKeysFeature.ApiKey>((x, s) => s.WorkspaceId == null || x.RefIdStr == s.WorkspaceId);
});
db.SetItem(ScopeItem, scope).UseFilters(WorkspaceFilters.For(scope));Tables that implement IHasWorkspaceId, and Workspace itself, are therefore fail closed:
| When | Behavior |
|---|---|
| A request that is not for an organization | Any query or write on a tenant-owned table throws |
| A request for an organization the caller is a member of | Selects, updates, and deletes only match that organization's rows |
Inserting a row without a WorkspaceId | The row gets the connection's organization |
| Writing a row for another organization | Throws, instead of silently changing no rows |
A service queries tenant-owned tables without repeating the organization, and reads the verified organization and membership from the connection's scope:
var widget = Db.SingleById<Widget>(request.Id)
?? throw new HttpError(404, "WidgetNotFound", "The widget was not found.");
Db.Insert(new Widget { Name = request.Name }); // WorkspaceId is set by the connection
var scope = Db.GetWorkspaceScope(); // scope.Workspace, scope.Member, scope.UserId, scope.IsAdminConsequences worth knowing:
- A lookup by ID is safe. It returns
nullfor a row in another organization, so return404for inaccessible resource IDs: distinguishing “exists elsewhere” would leak tenant information. - An API that forgets
IRequireWorkspacefails the request instead of returning another organization's data. This includes AutoQuery APIs over tenant-owned tables, which implement it too. - A scope cannot move. Once a request is confined to an organization it cannot be pointed at another, because the queries that already ran were confined to the first.
- The check has happened before the service runs. A request filter in
Configure.Saas.csopens the request's connection for everyIRequireWorkspaceAPI, whenever the service first usesDb.
Organization state
An organization can be suspended by an operator, read-only, or pending deletion. That is checked with the caller's membership, from the access each API needs, so no service checks organization status itself:
WorkspaceAccess | For | Suspended | Read-only or pending deletion |
|---|---|---|---|
Account | The organization itself: dashboard, billing, members, notifications, export, deletion | Allowed | Allowed |
Read | Reading product data. The default for GET APIs | 403 WorkspaceSuspended | Allowed |
Write | Changing product data. The default for other APIs | 403 WorkspaceSuspended | 423 WorkspaceReadOnly |
An API only says what it needs when that is not the default for its HTTP method:
[WorkspaceAccess(WorkspaceAccess.Account)]
public class LeaveWorkspace : IPost, IReturn<EmptyResponse>, IRequireWorkspaceAccount keeps the customer able to fix things: they can always see why their organization is unavailable, pay, export their data, and cancel a deletion. This is deliberately not a connection rule, because reads legitimately write, such as reading usage maintaining its periods. Confinement decides which rows are reachable, and this decides whether the request may proceed. Role and entitlement checks are still separate.
Working across organizations
Code that is not limited to one organization opts out explicitly with AcrossWorkspaces(), which returns the same connection and transaction without the organization filter. It still records who is writing.
| Code | Connection |
|---|---|
| Checking the caller is a member of the organization a request is for | ISaasManager.AssertMembership() uses db.AcrossWorkspaces() for the lookup, then ForRequest() confines the request |
Account-level APIs that are not for one organization, such as GetMyWorkspaces, SwitchWorkspace, and CreateOrganization | Db.AcrossWorkspaces() |
| Checks that are unique across organizations, such as the organization slug | Db.AcrossWorkspaces() for that query only |
| Platform APIs that list or search customers | PlatformDb, after the capability check |
| Platform APIs that act on one customer | RequireCustomer(workspaceId) confines the request to that customer |
The last row matters. An operator's request about one customer is confined to that customer, so the rest of the API cannot read or write anyone else's rows. This is also what limits a support session to the organization its grant is for. Only genuine listings across customers are unconfined.
ArchitectureGuardTests limits AcrossWorkspaces(), OpenAcrossWorkspaces() and WithoutFilters() to an allow-list of files, so each new use is reviewed.
Code on a confined connection never writes the WorkspaceId condition itself. Writing it anyway would hide a connection that isn't confined, whose queries would otherwise fail closed, so a guard test only allows a WorkspaceId condition in the files that work across organizations. Code that's given a connection and relies on it being confined, like SaasManager's usage methods, calls db.AssertConfinedTo(workspace.Id) first, so it fails instead of reading every organization's rows when it's given one that isn't.
What the connection does not cover
- Raw SQL. Filters apply to typed OrmLite APIs. Complete statements passed to
ExecuteSql,SqlList, orSqlScalarare not changed, so they must include theWorkspaceIdand set their own audit columns. The two usage counters inSaasManagerthat also track a peak do this; reservations use the typedUpdateAdd(), which is filtered and audited like any other typed API. They are written withSql.Fmt(), which sends every interpolated value as a database parameter. A guard test limits raw SQL to an allow-list of files, and fails when a statement is not written withSql.Fmt(). - Connections you open yourself. A background job or command is not run for a request, so it opens its own connection. Use
dbFactory.OpenForWorkspace(workspaceId, "job-name")when the work is for one organization. UsedbFactory.OpenAcrossWorkspaces("job-name")and keep explicitWorkspaceIdconditions when it processes several. A guard test fails when a connection is opened withdbFactory.Open(), which is not confined. See Background processing. - Plugins that open a connection without the request. A connection is only confined when it is opened for the request:
HostContext.AppHost.GetDbConnection(req)is,dbFactory.Open()is not. The API Keys feature opens the request's connection for its own APIs. - Stored files. The file store takes a key and returns bytes, so keys are created and checked per organization. See File storage.
- Authorization. Confinement decides which organization's rows are reachable. Role and entitlement checks are still required.
Platform access
Customer 360 is an explicit platform path with capability filtering and redacted projections. Support access is time-bound to one operator and one organization. Do not reuse platform queries in customer APIs or expose full persistence models when a redacted projection exists.
Testing strategy
TenantIsolationTests runs against every table implementing IHasWorkspaceId: it seeds two organizations and asserts that a confined connection cannot read, update, or delete the other organization's rows, that inserts get the connection's organization, and that a connection without an organization throws. A new tenant-owned table is covered by implementing the interface.
ArchitectureGuardTests fails when a table is not classified, when a tenant-owned table has no index that starts with WorkspaceId, when a Request DTO with a WorkspaceId does not implement IRequireWorkspace, and when an API that API keys can call is not for an organization.
In addition, for every new tenant-owned resource:
- create organizations A and B;
- give one user different roles in each;
- create resources with overlapping names/business keys;
- attempt list, read, update, delete, export, job, and API-key access across tenants;
- test inactive membership and deleted/suspended organization state;
- inspect error bodies, timing, audit, and logs for information leakage.
The tests do not cover an API whose Request DTO forgets IRequireWorkspace. It fails on its first request, so call each new API once against a running application. See Testing.

Related documentation
Authorization and roles
Next SaaS separates customer organization permissions from platform operator permissions. A role in one system never implies a role in the other.
API credentials and abuse controls
ServiceStack ApiKeysFeature supplies credentials; SaaS filters bind their lifecycle and access to the organization they were created for.