Skip to content

feat: Multi-tenant per-user data isolation#549

Open
khalidnass wants to merge 2 commits intolfnovo:mainfrom
khalidnass:feature/multi-tenant
Open

feat: Multi-tenant per-user data isolation#549
khalidnass wants to merge 2 commits intolfnovo:mainfrom
khalidnass:feature/multi-tenant

Conversation

@khalidnass
Copy link
Copy Markdown

Summary

  • Add per-user SurrealDB database isolation via Python contextvar + dynamic db_connection()
  • ProxyAuthMiddleware reads X-Forwarded-User header from auth proxy (OAuth2 Proxy + Dex + ADFS/OIDC)
  • Controlled by MULTI_TENANT_MODE=true env var — disabled by default, fully backward-compatible
  • Zero changes to domain models, routers, services, search, or frontend components

Files Changed (11 files)

Backend (4): user_context.py (new), auth.py, main.py, repository.py
Frontend (2): config/route.ts, use-auth.ts
Tests (3): test_multi_tenant.py (27 tests), test_multi_tenant_e2e.sh (12 tests), config-multitenant.test.ts (4
tests)
Tools (1): mock_auth_proxy.py
Config (1): .gitignore

How it works

  1. Request arrives with X-Forwarded-User: alice@company.com
  2. ProxyAuthMiddleware sets Python contextvar
  3. db_connection() reads contextvar, routes to user_alice_company_com database
  4. Migrations run automatically on first request per user

Test plan

  • uv run pytest tests/test_multi_tenant.py -v (27 unit tests)
  • ./tests/test_multi_tenant_e2e.sh (12 E2E tests)
  • npx vitest run src/lib/config-multitenant.test.ts (4 frontend tests)
  • uv run pytest tests/ (all 125 tests pass)
  • Manual test with mock proxy: different users see isolated data

  Each user gets an isolated SurrealDB database (user_alice, user_bob) via
  contextvar + dynamic db_connection(). Controlled by MULTI_TENANT_MODE=true.

  - ProxyAuthMiddleware reads X-Forwarded-User header from auth proxy
  - Per-user DB migrations run lazily on first request
  - Frontend config returns relative URLs when behind auth proxy
  - Zero changes to domain models, routers, services, or search
  - Fully backward-compatible (disabled by default)

  Includes 27 unit tests, 12 E2E tests, 4 frontend tests, and mock auth proxy.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 11 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="mock_auth_proxy.py">

<violation number="1" location="mock_auth_proxy.py:23">
P2: HTTPS upstreams will fail because the proxy always uses HTTPConnection and ignores the upstream URL scheme.</violation>
</file>

<file name="api/auth.py">

<violation number="1" location="api/auth.py:19">
P2: Race condition: concurrent requests for the same user can both pass the `_migrated_users` check and run migrations in parallel because the guard uses a shared set without locking.</violation>
</file>

<file name="tests/test_multi_tenant_e2e.sh">

<violation number="1" location="tests/test_multi_tenant_e2e.sh:73">
P2: List endpoint tests can falsely pass because assert_json_count only checks len(json) without validating a successful status or ensuring the response is a list; a 1-key error object would satisfy expected count=1.</violation>
</file>

<file name="frontend/src/lib/hooks/use-auth.ts">

<violation number="1" location="frontend/src/lib/hooks/use-auth.ts:63">
P2: authRequired can remain null on network errors, causing isLoading to stay true and the UI to remain stuck in a loading state.</violation>
</file>

<file name="open_notebook/database/repository.py">

<violation number="1" location="open_notebook/database/repository.py:36">
P1: Lossy sanitization of user identifiers can map different users to the same database name, breaking per-user isolation.</violation>
</file>

<file name="api/main.py">

<violation number="1" location="api/main.py:115">
P2: ProxyAuthMiddleware trusts X-Forwarded-User without verifying the request came from a trusted proxy, allowing direct clients to spoof the header and impersonate users in multi-tenant mode.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread open_notebook/database/repository.py
Comment thread mock_auth_proxy.py Outdated
Comment thread api/auth.py
Comment thread tests/test_multi_tenant_e2e.sh Outdated
return {
isAuthenticated,
isLoading: isLoading || !hasHydrated, // Treat lack of hydration as loading
isLoading: isLoading || !hasHydrated || authRequired === null, // Wait for auth check to complete
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: authRequired can remain null on network errors, causing isLoading to stay true and the UI to remain stuck in a loading state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/lib/hooks/use-auth.ts, line 63:

<comment>authRequired can remain null on network errors, causing isLoading to stay true and the UI to remain stuck in a loading state.</comment>

<file context>
@@ -60,7 +60,7 @@ export function useAuth() {
   return {
     isAuthenticated,
-    isLoading: isLoading || !hasHydrated, // Treat lack of hydration as loading
+    isLoading: isLoading || !hasHydrated || authRequired === null, // Wait for auth check to complete
     error,
     login: handleLogin,
</file context>
Fix with Cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in auth-store.ts — network errors now set authRequired: false instead of null

Comment thread api/main.py
]

if MULTI_TENANT:
app.add_middleware(ProxyAuthMiddleware, excluded_paths=_auth_excluded_paths)
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: ProxyAuthMiddleware trusts X-Forwarded-User without verifying the request came from a trusted proxy, allowing direct clients to spoof the header and impersonate users in multi-tenant mode.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/main.py, line 115:

<comment>ProxyAuthMiddleware trusts X-Forwarded-User without verifying the request came from a trusted proxy, allowing direct clients to spoof the header and impersonate users in multi-tenant mode.</comment>

<file context>
@@ -88,20 +98,23 @@ async def lifespan(app: FastAPI):
+]
+
+if MULTI_TENANT:
+    app.add_middleware(ProxyAuthMiddleware, excluded_paths=_auth_excluded_paths)
+else:
+    app.add_middleware(PasswordAuthMiddleware, excluded_paths=_auth_excluded_paths)
</file context>
Fix with Cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added optional PROXY_AUTH_SECRET env var in auth.py — when set, middleware verifies X-Proxy-Auth-Secret header from the proxy

…xy secret, HTTPS support, UI

  loading fix, E2E validation
@khalidnass
Copy link
Copy Markdown
Author

@cubic-dev-ai re-review

Fixed remaining issues:

  • use-auth.ts: Fixed in auth-store.ts — network errors now set authRequired: false instead of null
  • api/main.py: Added optional PROXY_AUTH_SECRET env var in auth.py — when set, middleware verifies
    X-Proxy-Auth-Secret header from the proxy

@cubic-dev-ai
Copy link
Copy Markdown
Contributor

cubic-dev-ai Bot commented Feb 9, 2026

@cubic-dev-ai re-review

Fixed remaining issues:

  • use-auth.ts: Fixed in auth-store.ts — network errors now set authRequired: false instead of null
  • api/main.py: Added optional PROXY_AUTH_SECRET env var in auth.py — when set, middleware verifies
    ...

@khalidnass I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 12 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="mock_auth_proxy.py">

<violation number="1" location="mock_auth_proxy.py:38">
P2: Host header includes ":None" when upstream URL omits a port, producing an invalid Host header that can break upstream routing.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread mock_auth_proxy.py
if key.lower() != "host":
headers[key] = val
headers["X-Forwarded-User"] = self.server.user
headers["Host"] = f"{upstream.hostname}:{upstream.port}"
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Host header includes ":None" when upstream URL omits a port, producing an invalid Host header that can break upstream routing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mock_auth_proxy.py, line 38:

<comment>Host header includes ":None" when upstream URL omits a port, producing an invalid Host header that can break upstream routing.</comment>

<file context>
@@ -0,0 +1,107 @@
+            if key.lower() != "host":
+                headers[key] = val
+        headers["X-Forwarded-User"] = self.server.user
+        headers["Host"] = f"{upstream.hostname}:{upstream.port}"
+        # Force non-chunked response so read() doesn't hang
+        headers["Accept-Encoding"] = "identity"
</file context>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant