Multi-tenancy¶
sfmapi is multi-tenant from migration 0001. v0 ships in single-user
mode (SFMAPI_AUTH_MODE=none) where every request resolves to the
default tenant — but every table, every workspace path, and every
service signature already carries tenant_id. Switching to real auth
is a config flip plus an API key rollout.
What’s in place from day 1¶
tenant_id CHAR(26) NOT NULL DEFAULT 'default'on every domain table (project,dataset,image_source,image,upload,maskset,mask,job,task,reconstruction,submodel).Workspace paths are tenant-prefixed:
workspaces/{tenant_id}/projects/{pid}/....A FastAPI
current_tenant()dependency injects thetenant_idstring into every route signature.All service functions take
tenant_idas the first kwarg and apply the filter in their queries; no route trusts a path parameter for tenant boundary.Quota service is wired with NOOP enforcement under
auth_mode=none, but the call sites already exist.
Switching on API key auth¶
Set
SFMAPI_AUTH_MODE=api_keyin the web container’s env.Restart the web container.
Issue a key:
curl -sX POST http://localhost:8080/v1/admin/api-keys \ -H 'Content-Type: application/json' \ -d '{"tenant_id":"my-org","name":"oncall"}'
Returns the raw key once — store it. The DB only persists
sha256(raw_key).Tenant-scoped API requests now require
Authorization: Bearer sfm_xxx. Thecurrent_tenant()dependency resolves the bearer to a tenant and injects it.
/v1/admin/api-keys is intentionally an operator surface, not a
tenant-scoped API. It is not protected by sfmapi’s tenant API-key
dependency in either auth mode and must be fronted by an admin-only
auth layer in production (for example a deploy-time master key,
private control-plane network, mTLS, or an ingress policy).
Quotas¶
Quota enforcement hooks (NOOP unless auth_mode=api_key).
- async app.services.quota_service.get_or_create_quota(session, *, tenant_id)[source]
- Parameters:
session (AsyncSession)
tenant_id (str)
- Return type:
TenantQuota
- async app.services.quota_service.check_storage(session, *, tenant_id, additional)[source]
- Parameters:
session (AsyncSession)
tenant_id (str)
additional (int)
- Return type:
None
- async app.services.quota_service.check_gpu_seconds(session, *, tenant_id)[source]
- Parameters:
session (AsyncSession)
tenant_id (str)
- Return type:
None
Two quota hooks exist when auth_mode=api_key:
storage upload gate: upload session creation checks the tenant’s configured storage budget against the requested upload size.
gpu_seconds_per_day: rolling 24-hour sum of
gpu_secondsrecorded against tenant Tasks.
Quotas live on the tenant_quota table; either column NULL means
“no limit.” Hits return 429 quota_exceeded (see
errors).
Shared S3 cache bytes and sealed snapshot bytes are not yet separately attributed in this quota gate; operators should treat the storage quota as upload-focused until that accounting is wired.
Tenant isolation tests¶
Every CRUD test in tests/e2e/ runs through the same dep, so isolation
is enforced uniformly. Cross-tenant access (e.g., reading another
tenant’s project) returns 404, not 403, to avoid leaking existence.
Migration notes for existing data¶
If you started in single-user mode and have data under
tenant_id='default', switching to multi-tenant means:
Pick a target tenant for the existing data.
Issue an API key bound to that tenant.
(Optional) Migrate data to a new
tenant_idwith a manual UPDATE per table — there’s no built-in helper because mass-rewriting tenant_id is a deliberate operation, not a routine one.