Skip to Content
ContributeBackendMulti-tenancy

Multi-tenancy

Rhesis isolates data by organization and active project. Backend code should rely on the request-scoped database session instead of threading tenant identifiers through every router, service, and CRUD helper.

Ambient request scope

Every authenticated request receives an identity triple:

FieldPurpose
organization_idOrganization boundary for tenant data
user_idUser responsible for created rows
project_idActive project boundary for project-scoped rows

get_db_with_tenant_variables() stores that triple on Session.info['_scope']. SQLAlchemy listeners in app/models/scope_events.py read the session scope and apply it automatically.

ListenerSQLAlchemy hookBehavior
auto_filterQuery.before_compileAdds organization and project predicates to ORM db.query(...) reads, updates, and deletes
auto_stampSession.before_flushFills organization_id, user_id, and project_id on new ORM objects when those columns are present and unset

Normal FastAPI routers do not need to call scope helpers directly:

router.py
from fastapi import Depends
from sqlalchemy.orm import Session

from rhesis.backend.app.database import get_db_with_tenant_variables
from rhesis.backend.app.models.test import Test

@router.get("/tests")
def list_tests(db: Session = Depends(get_db_with_tenant_variables)):
    # The query is automatically filtered by organization and active project.
    return db.query(Test).all()

current_scope() reads the ContextVar fallback used by scripts and tests. In normal FastAPI and Celery database work, use db.info.get('_scope') if you need to inspect the active scope.

Project filtering behavior

Project filtering is fail-closed once an organization scope is active:

  • If project_id is set, project-scoped tables return rows for that project plus organization-level rows where project_id is NULL.
  • If project_id is missing, project-scoped tables return only organization-level rows where project_id is NULL.
  • project_membership is exempt from the project predicate so project resolution can list memberships across projects while still applying organization scope.

The ORM listener skips identity tables such as user, organization, and token because those are queried before tenant context is known.

Choosing the right scope helper

Use the narrowest helper for the runtime path you own.

SituationHelperNotes
Standard FastAPI routeDepends(get_db_with_tenant_variables)Sets tenant GUCs and stores scope on Session.info
Celery task or long-lived owned sessionbind_scope_to_session(db, org, user, project)Activates ORM scope for that session lifetime
Short project-scoped block inside a requesttemporary_project_scope(db, org, user, project)Restores the previous session scope and GUCs after the block
Re-apply PostgreSQL GUCs after a mid-request commitset_session_variables(db, org, user, project)Does not change Session.info['_scope']
Script or test without a DB dependencybind_scope(RequestScope(...)) / reset_scope(token)ContextVar fallback for non-request code

Use temporary_project_scope() for short in-request project windows. Calling bind_scope_to_session() inside a request changes the session scope for the rest of that request and can silently filter later queries to the wrong project.

temporary_project_scope.py
from rhesis.backend.app.database import temporary_project_scope

with temporary_project_scope(db, organization_id, user_id, project_id):
    project_rows = db.query(Test).all()

# The previous request scope is restored here.

Cross-tenant reads

Admin or maintenance paths can temporarily disable the ORM auto-filter:

cross_tenant_read.py
from rhesis.backend.app.scope import bypass_tenant_filter

with bypass_tenant_filter():
    all_projects = db.query(Project).all()

Bypass only affects auto_filter. Inserts still receive the caller’s organization, user, and project through auto_stamp.

For the legacy Query API, a single query can also set _bypass_scope = True.

Known limitations

  • db.execute(select(...)) and db.scalars(...) are not filtered by the Query.before_compile listener. Use db.query(...) or add explicit tenant predicates for ORM 2.0 style queries.
  • Session.bulk_insert_mappings() and bulk_save_objects() bypass before_flush; include organization_id, user_id, and project_id in bulk payloads manually.
  • Raw SQL writes bypass both ORM listeners. Add explicit tenant predicates or rely on PostgreSQL row-level security as the backstop.
  • Background scripts run outside get_db_with_tenant_variables(). Bind scope explicitly before writing tenant-owned rows.

Kill switch

Set RHESIS_DISABLE_SCOPE_LISTENER=1 to disable the ORM auto-filter and auto-stamp listeners without redeploying. PostgreSQL row-level security remains active because the switch only affects the ORM listener layer.