Skip to content

Scopes, tenancy and workspaces

Most MSFLib data belongs to someone: a tenant, a workspace inside it, sometimes an individual account. A scope is the small immutable object that says who a call is acting for. It is built once at the endpoint boundary and passed down, so services, config lookups and storage keys all agree on the same identity.

Three tiers recur throughout:

Tier Identifier Provided by
Tenant tenant_id msflib-tenancy
Workspace workspace_id msflib-workspaces
Account (user) account_id msflib-account and msflib-auth

ScopeEnvelope

ScopeEnvelope lives in msflib.scope. It is a frozen dataclass whose dimensions map a dimension name to a string or None. Dimension values are always strings ("42", not 42), because the same envelope is compiled into storage keys and filters.

from msflib.scope import ScopeEnvelope, build_context_scope

scope = build_context_scope(tenant_id=1, workspace_id=None, account_id=5)
scope.dimensions
# {'tenant_id': '1', 'workspace_id': None, 'account_id': '5'}

An absent key and a key with value None mean different things. Absent means "not specified". Present with None means "explicitly null", which for workspace_id is the workspace-less bucket (see below). scope.get_dim(key) returns the value or None; scope.has_dim(key) tells the two cases apart.

You add other dimensions (conversation_id, project_id, ...) by building a new envelope, since envelopes are immutable:

scope = ScopeEnvelope(dimensions={**scope.dimensions, "conversation_id": "c1"})

Getting the scope in an endpoint

get_scope_dependencies returns a DependencyNamespace of FastAPI dependencies that build the envelope from the caller's resolved identity. You pass in your site's own get_current_* dependencies (from auth.get_account_dependencies, auth.get_user_dependencies and tenancy.deps.get_tenant_dependencies).

from fastapi import Depends
from msflib.scope import ScopeEnvelope, get_scope_dependencies

scopes = get_scope_dependencies(
    get_current_tenant=get_current_tenant,
    get_current_workspace=get_current_workspace,
    get_current_account=get_current_account,
)


@router.get("/items")
def list_items(scope: ScopeEnvelope = Depends(scopes.get_account_scope)):
    return item_service.list_for_scope(scope)

The namespace exposes:

  • get_account_scope: every tier the caller has. Use for member-facing reads and writes.
  • get_workspace_scope: tenant and workspace, no account. Use for admin surfaces over workspace-scoped rows. Present only when a workspace dependency was passed.
  • get_tenant_scope: tenant only, for platform-level surfaces.

Which identity callables to pass depends on how your site models membership:

  • A membership entity distinct from the account (a per-workspace row with status and role): pass get_current_workspace and get_current_active_user. The user dependency is what verifies membership; resolving the workspace alone does not.
  • No such entity: pass get_current_account, plus get_current_workspace if your accounts have a workspace attribute, or a workspace dependency that resolves from the request (path or header). Nothing then checks membership, because there is no membership row.
  • No tenant concept yet: omit get_current_tenant. The scopes then carry tenant_id=None.

Passing both get_current_account and get_current_active_user raises ValueError, as does passing neither account nor the workspace-and-user pair.

Compiling a scope

ScopeCompiler turns an envelope into a key string, segments or a mapping, validating it against a named operation profile first.

from msflib.scope import ScopeCompiler

compiler = ScopeCompiler()
compiler.compile_scope_string(scope, operation="retrieval.search")
# 't/1/a/5/w/-/c/c1'
compiler.compile_scope_map(scope, operation="retrieval.search")
# {'tenant_id': '1', 'account_id': '5', 'workspace_id': '-', 'conversation_id': 'c1'}
compiler.compile_scope_map(scope, operation="retrieval.search", use_sentinel=False)
# {..., 'workspace_id': None, ...}

Built-in profiles include retrieval.search, memory.put, memory.get, checkpoint.invoke, config.read and audit.query. A profile lists required dimensions, required-but-nullable dimensions and optional ones. In every built-in profile tenant_id is required, so compiling an envelope without a tenant raises ScopeValidationError; workspace_id and account_id are nullable and compile to the sentinel - when null. Register your own profiles on the compiler (register_profile) for your own operations. Modules do this for theirs: msflib.workspace_config.scope_profiles defines workspace_config.storage_key, where tenant, workspace and account are all required-but-nullable, so a tenant-tier row can have a null workspace and a workspace-tier row a null tenant.

Behaviour may change (#267)

Scope profiles currently require tenant_id but allow a null workspace_id, so the two dimensions are not treated symmetrically, and the drivelink node has a workspace_id but no tenant_id. Whether the profiles should treat the two the same is undecided.

You can also evaluate access rules over a scope with the constraint classes exported from msflib.scope (DimensionMatchConstraint, MembershipConstraint, RoleConstraint, ConstraintEvaluator). Built-in profiles run in log-only enforcement, except audit.query, which enforces.

Behaviour may change (#270)

Scope constraints on the AI API currently default to log-only (SCOPE_CONSTRAINTS_ENFORCE), so a failing constraint is logged rather than rejected. Whether enforcement should be the default is undecided.

Tenants

msflib-tenancy provides the Tenant table (name, unique slug, status, a soft owner_account_id), TenantAction, and TenancySettings (namespace TENANCY, with DEFAULT_TENANT_SLUG and DEFAULT_TENANT_NAME, defaulting to "default" and "Default Tenant").

Today the library runs single-tenant: one default tenant, resolved by slug. Rows that belong to a tenant carry a tenant_id foreign key to it (workspaces, for example). Three entry points matter to a host app:

  • TenantAction.ensure_default_tenant(session, settings=...) creates the default tenant if it is missing. Call it at startup. It is idempotent.
  • resolve_default_tenant_id(session, settings=..., create_if_missing=True) returns its id, cached per process and database.
  • get_tenant_dependencies(session_dep=..., settings=...) from msflib.tenancy.deps returns a namespace with get_current_tenant, which loads the default tenant row for a request and answers 500 if it was never seeded. Pass that to get_scope_dependencies. Pass the same settings.scope("TENANCY") you seed with: with a customised DEFAULT_TENANT_SLUG, omitting settings= makes it look for the slug default and answer 500.

Importing the Tenant table class is done explicitly (from msflib.tenancy.models.tenant import Tenant), because the package root does not re-export it.

If the scope carries a tenant_id, that value wins over any default lookup. A malformed tenant id raises ValueError rather than silently falling back to the default tenant (see msflib.tenancy.resolver.resolve_scoped_tenant_id).

Resolving the caller's tenant from a request header or token, to choose among several tenants, is not implemented. get_current_tenant is the seam where you would add it.

The workspace-less bucket

workspace_id=None is a concrete, narrow bucket: rows that belong to no workspace. It is not "any workspace" and it is never a wildcard. Code that filters on scope matches it exactly:

  • The built-in profiles treat it as required-but-nullable and compile it to the - sentinel.
  • The knowledge module's KnowledgeScope renders workspace_id=None as IS NULL.
  • ai_core's vector store profile lookup compares workspace_id == None explicitly, and its comments call this the catch-all workspace.

When you write your own filters, do the same: pass None through to an IS NULL match. Dropping the clause when the id is None would widen the query to every workspace and leak data across them.

The profile schema comments warn that null semantics for dimensions marked optional vary by consumer. Some, like ai_core's retrieval filter for conversation and sub-thread ids, deliberately let None widen the match. Check the consuming module before assuming either way.

Access is through membership

The tenant tables carry no roles or membership, and the scope dependencies never grant access on their own. Workspace access is decided by workspace membership and role checks from the auth and workspaces modules, for example the workspace_role_check(["admin"]) guard used by the workspace-config router. I found no code that gives a tenant-level principal blanket access to workspaces; if you need a tenant administrator who can act inside workspaces, model it explicitly (for instance by making that account a member of the workspace) rather than assuming the tenant tier implies it.

Behaviour may change (#268)

There is currently no tenant-admin role and no tenant-level workspace access, and no test pins this behaviour. Whether tenant administrators should get such access is undecided.

Pitfalls

  • Build the scope from the authenticated caller at the endpoint, not from request parameters. A client-supplied workspace_id that nothing verifies is how data crosses workspaces.
  • get_workspace_scope does not by itself prove the account belongs to the workspace on sites without a membership row.
  • Dimension values are strings. Convert back with int(...) where a column is an integer, and handle None first.
  • ScopeEnvelope is immutable and its mapping fields are read-only proxies. Make a new one instead of mutating.