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:
| Field | Purpose |
|---|---|
organization_id | Organization boundary for tenant data |
user_id | User responsible for created rows |
project_id | Active 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.
| Listener | SQLAlchemy hook | Behavior |
|---|---|---|
auto_filter | Query.before_compile | Adds organization and project predicates to ORM db.query(...) reads, updates, and deletes |
auto_stamp | Session.before_flush | Fills 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:
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_idis set, project-scoped tables return rows for that project plus organization-level rows whereproject_idisNULL. - If
project_idis missing, project-scoped tables return only organization-level rows whereproject_idisNULL. project_membershipis 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.
| Situation | Helper | Notes |
|---|---|---|
| Standard FastAPI route | Depends(get_db_with_tenant_variables) | Sets tenant GUCs and stores scope on Session.info |
| Celery task or long-lived owned session | bind_scope_to_session(db, org, user, project) | Activates ORM scope for that session lifetime |
| Short project-scoped block inside a request | temporary_project_scope(db, org, user, project) | Restores the previous session scope and GUCs after the block |
| Re-apply PostgreSQL GUCs after a mid-request commit | set_session_variables(db, org, user, project) | Does not change Session.info['_scope'] |
| Script or test without a DB dependency | bind_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.
Cross-tenant reads
Admin or maintenance paths can temporarily disable the ORM auto-filter:
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(...))anddb.scalars(...)are not filtered by theQuery.before_compilelistener. Usedb.query(...)or add explicit tenant predicates for ORM 2.0 style queries.Session.bulk_insert_mappings()andbulk_save_objects()bypassbefore_flush; includeorganization_id,user_id, andproject_idin 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.