Skip to main content
Glama
research.md31.7 kB
# Research: Multi-Tenant Billable MCP Server (v2.0) **Date**: 2025-10-13 **Branch**: `003-we-need-to` **Phase**: 0 (Research & Discovery) --- ## Research Tasks Based on Technical Context unknowns, the following research areas must be explored: 1. **R1**: Supabase Row Level Security (RLS) patterns for multi-tenancy 2. **R2**: Supabase Vault (pgsodium) for credential encryption 3. **R3**: Next.js 14 App Router with Supabase Auth integration 4. **R4**: Stripe subscription API with quantity-based metering 5. **R5**: Supabase Edge Functions for webhook handling 6. **R6**: FastAPI-Supabase integration patterns 7. **R7**: Multi-tenant testing strategies (RLS enforcement verification) --- ## R1: Supabase Row Level Security (RLS) Patterns for Multi-Tenancy ### Decision Use **Supabase RLS policies** to enforce organization-level data isolation at the database layer. All application tables will have RLS enabled with policies that filter rows based on the authenticated user's organization membership. ### Research Findings **RLS Policy Pattern**: ```sql -- Enable RLS on all tenant-scoped tables ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; ALTER TABLE hostaway_credentials ENABLE ROW LEVEL SECURITY; ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY; -- Policy: Users can only access data from organizations they belong to CREATE POLICY "Users access own org data" ON organizations FOR ALL USING ( id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); -- Policy: API keys scoped to user's organization CREATE POLICY "Users manage own org API keys" ON api_keys FOR ALL USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); -- Policy: Hostaway credentials scoped to user's organization CREATE POLICY "Users access own org credentials" ON hostaway_credentials FOR SELECT USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); ``` **Key Insights**: 1. **auth.uid()** function returns the current authenticated user's ID from Supabase Auth JWT 2. RLS policies are **evaluated on every query** - no application-level filtering needed 3. Policies use **subquery pattern** to check organization membership via `organization_members` table 4. **Service role key bypasses RLS** - FastAPI backend must use service role key carefully (only for API key validation) **Security Benefits**: - Database enforces isolation (cannot be bypassed by application bugs) - Zero-trust architecture (even if FastAPI is compromised, database blocks cross-org access) - Automatic filtering (developers don't need to remember to add WHERE clauses) **Performance Considerations**: - RLS policies add subquery overhead (~1-5ms per query) - Index on `organization_members(user_id, organization_id)` critical for performance - Use `USING` clause for SELECT, `WITH CHECK` for INSERT/UPDATE to prevent bypasses **Alternatives Considered**: - **Application-level filtering**: Rejected - requires manual WHERE clauses, easy to forget, error-prone - **Separate databases per tenant**: Rejected - too expensive, complex migration, not needed at current scale - **Schema-based multi-tenancy**: Rejected - PostgreSQL has schema limits, Supabase doesn't support well **Rationale**: RLS provides database-level isolation with minimal performance impact and zero application complexity. --- ## R2: Supabase Vault (pgsodium) for Credential Encryption ### Decision Use **Supabase Vault** (backed by pgsodium PostgreSQL extension) to encrypt Hostaway credentials at rest. Encryption keys are managed by Supabase and stored in hardware security modules (HSMs). ### Research Findings **Vault Encryption Pattern**: ```sql -- Enable pgsodium extension CREATE EXTENSION IF NOT EXISTS pgsodium; -- Create secret for encrypting Hostaway credentials INSERT INTO vault.secrets (name, secret) VALUES ('hostaway_encryption_key', pgsodium.crypto_secretbox_keygen()); -- Store encrypted credentials CREATE TABLE hostaway_credentials ( id BIGSERIAL PRIMARY KEY, organization_id BIGINT REFERENCES organizations(id), account_id TEXT NOT NULL, encrypted_secret_key TEXT NOT NULL, -- encrypted with pgsodium credentials_valid BOOLEAN DEFAULT true, last_validated_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Encrypt on insert INSERT INTO hostaway_credentials (organization_id, account_id, encrypted_secret_key) VALUES ( 123, 'ACC_12345', pgsodium.crypto_secretbox( 'secret_key_value'::bytea, (SELECT secret FROM vault.secrets WHERE name = 'hostaway_encryption_key') ) ); -- Decrypt on select (service role only) SELECT organization_id, account_id, pgsodium.crypto_secretbox_open( encrypted_secret_key::bytea, (SELECT secret FROM vault.secrets WHERE name = 'hostaway_encryption_key') )::text AS secret_key FROM hostaway_credentials WHERE organization_id = 123; ``` **Key Insights**: 1. **pgsodium.crypto_secretbox**: Uses XSalsa20-Poly1305 authenticated encryption (same as NaCl/libsodium) 2. **vault.secrets table**: Stores encryption keys, **accessible only by service role** (not anon key) 3. **Automatic key rotation**: Supabase manages HSM-backed keys, rotates periodically 4. **Decryption in application**: FastAPI backend must decrypt using service role key (bypasses RLS) **Security Benefits**: - Encryption at rest (even database administrators cannot read credentials without vault access) - Hardware-backed keys (HSM ensures keys never leave secure boundary) - Compliance-ready (GDPR, SOC 2 compliant encryption) **Performance Considerations**: - Encryption/decryption adds ~2-5ms per operation - Decryption happens in FastAPI backend (not in database) to avoid exposing decrypted values - Cache decrypted credentials per request (don't decrypt on every tool invocation) **Alternatives Considered**: - **Application-level encryption (AES-256 in Python)**: Rejected - requires managing encryption keys in env vars, less secure - **AWS KMS**: Rejected - adds external dependency, Supabase Vault is integrated and free - **HashiCorp Vault**: Rejected - separate service, more complexity, not needed at current scale **Rationale**: Supabase Vault provides HSM-backed encryption with zero operational overhead and native PostgreSQL integration. --- ## R3: Next.js 14 App Router with Supabase Auth Integration ### Decision Use **Next.js 14 App Router** with **@supabase/ssr** library for server-side Supabase Auth integration. Implement Server Components for data fetching and Server Actions for mutations. ### Research Findings **Supabase Auth Integration Pattern**: ```typescript // lib/supabase/server.ts - Server Component client import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' export function createClient() { const cookieStore = cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return cookieStore.get(name)?.value }, }, } ) } // app/(dashboard)/page.tsx - Server Component with auth import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' export default async function DashboardPage() { const supabase = createClient() // Get authenticated user const { data: { user } } = await supabase.auth.getUser() if (!user) redirect('/login') // Get user's organization (RLS enforced) const { data: org } = await supabase .from('organizations') .select('*, api_keys(*), subscriptions(*)') .single() return <DashboardView org={org} user={user} /> } // app/api/api-keys/route.ts - Server Action for API key generation import { createClient } from '@/lib/supabase/server' import crypto from 'crypto' export async function POST() { const supabase = createClient() // Get user's org (RLS enforced) const { data: { user } } = await supabase.auth.getUser() if (!user) return new Response('Unauthorized', { status: 401 }) const { data: orgMember } = await supabase .from('organization_members') .select('organization_id') .eq('user_id', user.id) .single() // Generate API key const apiKey = crypto.randomBytes(32).toString('hex') const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex') // Store hash in Supabase (RLS enforced) await supabase.from('api_keys').insert({ organization_id: orgMember.organization_id, key_hash: keyHash, created_by_user_id: user.id }) // Return full key ONCE return Response.json({ api_key: apiKey }) } ``` **Key Insights**: 1. **@supabase/ssr**: Handles cookie-based sessions in Next.js App Router (Server Components + Server Actions) 2. **Server Components**: Fetch data on server, no client-side waterfalls, RLS policies enforced automatically 3. **Server Actions**: Mutations run on server, inherently secure (no exposing logic to client) 4. **Automatic session refresh**: @supabase/ssr handles token refresh transparently **Authentication Flow**: 1. User signs in → Supabase Auth sets JWT in httpOnly cookie 2. Server Component reads cookie → validates JWT → extracts user_id 3. Supabase queries use user_id in RLS policies → auto-filter by organization 4. No manual session management needed (all handled by @supabase/ssr) **Performance Benefits**: - Server Components eliminate client-side rendering overhead - Data fetching happens on server (closer to database) - Parallel data fetching via React Suspense **Alternatives Considered**: - **Pages Router + getServerSideProps**: Rejected - App Router is newer, better performance, simpler auth - **Client-side rendering + SWR**: Rejected - slower initial load, RLS policies require server-side auth - **Next-Auth (Auth.js)**: Rejected - Supabase Auth is integrated with database, no need for separate provider **Rationale**: Next.js 14 App Router + @supabase/ssr provides seamless server-side auth with minimal boilerplate and automatic RLS enforcement. --- ## R4: Stripe Subscription API with Quantity-Based Metering ### Decision Use **Stripe Subscriptions API** with **quantity-based pricing** to bill organizations per active listing. Implement daily sync job to update subscription quantity based on Hostaway listing count, with automatic proration. ### Research Findings **Stripe Subscription Pattern**: ```typescript // Create subscription on organization signup import Stripe from 'stripe' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) async function createSubscription(orgId: number, listingCount: number) { // Create Stripe customer const customer = await stripe.customers.create({ email: user.email, metadata: { organization_id: orgId } }) // Create subscription with quantity = listing count const subscription = await stripe.subscriptions.create({ customer: customer.id, items: [{ price: 'price_listing_monthly', // $5/listing/month quantity: listingCount }], proration_behavior: 'create_prorations', // Auto-prorate on quantity changes metadata: { organization_id: orgId } }) // Store subscription ID in Supabase await supabase.from('subscriptions').insert({ organization_id: orgId, stripe_subscription_id: subscription.id, stripe_customer_id: customer.id, current_quantity: listingCount, status: subscription.status }) return subscription } // Update subscription quantity (daily sync job) async function updateSubscriptionQuantity(orgId: number, newListingCount: number) { // Get subscription from Supabase const { data: sub } = await supabase .from('subscriptions') .select('stripe_subscription_id, current_quantity') .eq('organization_id', orgId) .single() if (sub.current_quantity === newListingCount) return // No change // Update Stripe subscription quantity const subscription = await stripe.subscriptions.retrieve(sub.stripe_subscription_id) await stripe.subscriptions.update(sub.stripe_subscription_id, { items: [{ id: subscription.items.data[0].id, quantity: newListingCount }], proration_behavior: 'create_prorations' // Prorate the difference }) // Update Supabase await supabase.from('subscriptions').update({ current_quantity: newListingCount }).eq('organization_id', orgId) } ``` **Key Insights**: 1. **Quantity-based pricing**: Stripe automatically calculates `quantity * unit_price` per billing cycle 2. **Automatic proration**: When quantity changes mid-cycle, Stripe creates prorated invoice items (credit/charge) 3. **Metadata for mapping**: Store `organization_id` in Stripe customer/subscription metadata for webhook processing 4. **Subscription statuses**: active, past_due, canceled, trialing - must handle all states **Billing Flow**: 1. **Signup**: Create subscription with initial listing count 2. **Daily sync**: Fetch listing count from Hostaway → update Stripe subscription quantity 3. **Stripe invoices**: Generated monthly, includes prorations from quantity changes 4. **Payment failure**: Webhook triggers → suspend API keys → notify user **Performance Considerations**: - Daily sync job runs asynchronously (not blocking user requests) - Stripe API rate limits: 100 req/s (sufficient for 10K orgs with 1 sync/day/org) - Cache listing count in Supabase to avoid excessive Hostaway API calls **Alternatives Considered**: - **Metered billing (usage-based)**: Rejected - requires reporting usage events, more complex, listing count is simpler - **Fixed tiers (0-10 listings, 11-50, etc.)**: Rejected - unfair for users at tier boundaries, quantity-based is fairer - **Annual billing**: Rejected - monthly is safer for MVP, can add annual later **Rationale**: Quantity-based subscriptions with automatic proration provide transparent, fair billing with minimal operational overhead. --- ## R5: Supabase Edge Functions for Webhook Handling ### Decision Use **Supabase Edge Functions** (Deno runtime) to handle Stripe webhooks. Edge Functions provide secure webhook processing with built-in signature verification and direct database access. ### Research Findings **Stripe Webhook Edge Function Pattern**: ```typescript // supabase/functions/stripe-webhook/index.ts import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' import Stripe from 'https://esm.sh/stripe@14.0.0' import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { httpClient: Stripe.createFetchHttpClient(), }) serve(async (req) => { const signature = req.headers.get('stripe-signature')! const body = await req.text() // Verify webhook signature let event: Stripe.Event try { event = stripe.webhooks.constructEvent( body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')! ) } catch (err) { return new Response(`Webhook signature verification failed: ${err.message}`, { status: 400 }) } const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_KEY')! // Service role for RLS bypass ) // Handle events switch (event.type) { case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice const orgId = invoice.subscription_metadata?.organization_id // Suspend API keys await supabase .from('api_keys') .update({ is_active: false }) .eq('organization_id', orgId) // Update subscription status await supabase .from('subscriptions') .update({ status: 'past_due' }) .eq('organization_id', orgId) break } case 'invoice.payment_succeeded': { const invoice = event.data.object as Stripe.Invoice const orgId = invoice.subscription_metadata?.organization_id // Restore API keys await supabase .from('api_keys') .update({ is_active: true }) .eq('organization_id', orgId) // Update subscription status await supabase .from('subscriptions') .update({ status: 'active' }) .eq('organization_id', orgId) break } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription const orgId = subscription.metadata?.organization_id // Mark subscription as canceled await supabase .from('subscriptions') .update({ status: 'canceled' }) .eq('organization_id', orgId) break } } return new Response(JSON.stringify({ received: true }), { status: 200 }) }) ``` **Key Insights**: 1. **Deno runtime**: TypeScript-first, secure by default (no file system access unless granted) 2. **Signature verification**: Stripe SDK verifies webhook signature to prevent forgery 3. **Idempotency**: Use Stripe event ID to prevent duplicate processing (store in `processed_webhooks` table) 4. **Service role key**: Edge Function uses service role key to bypass RLS (writes to any org's data) **Security Benefits**: - Webhook signature verification prevents unauthorized requests - Edge Functions run in isolated environment (cannot access other functions' secrets) - Service role key scoped to database operations only (no file system access) **Performance Considerations**: - Edge Functions run globally on Cloudflare network (low latency) - Auto-scaling: handles webhook bursts (1000s of events/sec) - Cold start: ~50-100ms (acceptable for async webhooks) **Alternatives Considered**: - **FastAPI webhook endpoint**: Rejected - requires VPS to handle webhooks, Edge Functions are serverless - **AWS Lambda**: Rejected - Supabase Edge Functions are integrated, no separate AWS account needed - **Vercel Serverless Functions**: Rejected - Supabase Edge Functions have direct database access (no network hop) **Rationale**: Supabase Edge Functions provide serverless webhook processing with native database integration and zero operational overhead. --- ## R6: FastAPI-Supabase Integration Patterns ### Decision Integrate **supabase-py** library into FastAPI backend to validate API keys against Supabase database and retrieve organization-scoped Hostaway credentials. Use service role key to bypass RLS for authentication logic. ### Research Findings **FastAPI-Supabase Integration Pattern**: ```python # src/services/supabase_client.py from supabase import create_client, Client import os def get_supabase_client() -> Client: """Create Supabase client with service role key (bypasses RLS)""" return create_client( os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_SERVICE_KEY") # Service key for RLS bypass ) # src/api/dependencies.py from fastapi import Header, HTTPException, Depends from src.services.supabase_client import get_supabase_client from src.services.credential_service import decrypt_hostaway_credentials import hashlib async def get_organization_context( x_api_key: str = Header(..., alias="X-API-Key"), supabase: Client = Depends(get_supabase_client) ): """Validate API key and return organization context""" # Hash the provided API key key_hash = hashlib.sha256(x_api_key.encode()).hexdigest() # Query Supabase for valid API key (service role bypasses RLS) result = supabase.table('api_keys')\ .select('organization_id, is_active')\ .eq('key_hash', key_hash)\ .eq('is_active', True)\ .execute() if not result.data: raise HTTPException(status_code=401, detail="Invalid API key") org_id = result.data[0]['organization_id'] # Get organization's Hostaway credentials (encrypted) creds = supabase.table('hostaway_credentials')\ .select('account_id, encrypted_secret_key')\ .eq('organization_id', org_id)\ .single()\ .execute() # Decrypt credentials using Supabase Vault secret_key = decrypt_hostaway_credentials(creds.data['encrypted_secret_key']) # Update last_used_at for API key supabase.table('api_keys')\ .update({'last_used_at': 'now()'})\ .eq('key_hash', key_hash)\ .execute() return { "organization_id": org_id, "hostaway_account_id": creds.data['account_id'], "hostaway_secret_key": secret_key } # src/api/routes/properties.py from fastapi import APIRouter, Depends from src.api.dependencies import get_organization_context import httpx router = APIRouter(prefix="/api/properties", tags=["properties"]) @router.get("/") async def get_properties( context = Depends(get_organization_context) ): """Get properties for authenticated organization's Hostaway account""" async with httpx.AsyncClient() as client: response = await client.get( "https://api.hostaway.com/v1/listings", headers={ "Authorization": f"Bearer {context['hostaway_secret_key']}", "X-Account-Id": context['hostaway_account_id'] } ) return response.json() ``` **Key Insights**: 1. **Service role key bypasses RLS**: Required for API key validation (can read any org's api_keys table) 2. **Dependency injection**: `Depends(get_organization_context)` provides org context to all endpoints 3. **Credential caching**: Cache decrypted credentials per request to avoid re-decryption 4. **Audit logging**: Log all MCP invocations to Supabase audit_logs table with org_id **Security Considerations**: - Service role key stored in env var (never committed to code) - Decrypted credentials never logged or exposed in responses - API key last_used_at updated for tracking (helps detect compromised keys) **Performance Considerations**: - API key validation adds ~50-100ms per request (Supabase query + decryption) - Connection pooling via httpx reduces Supabase query overhead - Consider Redis cache for API key → org_id mapping (future optimization) **Alternatives Considered**: - **Supabase Auth JWT for MCP tools**: Rejected - Claude Desktop needs static API keys, not session tokens - **Direct PostgreSQL connection**: Rejected - Supabase client handles auth/RLS/pooling automatically - **GraphQL API**: Rejected - REST API is simpler, Supabase auto-generates REST from schema **Rationale**: supabase-py provides seamless integration with FastAPI, handles authentication, and enables RLS bypass for service-level operations. --- ## R7: Multi-Tenant Testing Strategies (RLS Enforcement Verification) ### Decision Implement **multi-tenant isolation tests** to verify RLS policies prevent cross-organization data access. Use test fixtures to create multiple organizations and verify queries return only organization-scoped data. ### Research Findings **Multi-Tenant Test Pattern**: ```python # tests/integration/test_supabase_rls.py import pytest from src.services.supabase_client import get_supabase_client from supabase import Client @pytest.fixture async def setup_test_orgs(supabase: Client): """Create test organizations and users""" # Create Org A org_a = supabase.table('organizations').insert({ 'name': 'Test Org A', 'owner_user_id': 'user_a_id' }).execute().data[0] # Create Org B org_b = supabase.table('organizations').insert({ 'name': 'Test Org B', 'owner_user_id': 'user_b_id' }).execute().data[0] # Add API keys for each org api_key_a = supabase.table('api_keys').insert({ 'organization_id': org_a['id'], 'key_hash': 'hash_a', 'is_active': True }).execute().data[0] api_key_b = supabase.table('api_keys').insert({ 'organization_id': org_b['id'], 'key_hash': 'hash_b', 'is_active': True }).execute().data[0] yield { 'org_a': org_a, 'org_b': org_b, 'api_key_a': api_key_a, 'api_key_b': api_key_b } # Cleanup supabase.table('api_keys').delete().eq('organization_id', org_a['id']).execute() supabase.table('api_keys').delete().eq('organization_id', org_b['id']).execute() supabase.table('organizations').delete().eq('id', org_a['id']).execute() supabase.table('organizations').delete().eq('id', org_b['id']).execute() @pytest.mark.asyncio async def test_rls_prevents_cross_org_access(setup_test_orgs): """Verify RLS policies prevent Org A from accessing Org B's data""" supabase = get_supabase_client() orgs = await setup_test_orgs # Authenticate as Org A user (simulate RLS context) supabase.auth.set_session(access_token='user_a_token') # Query API keys - should only see Org A's keys result = supabase.table('api_keys').select('*').execute() assert len(result.data) == 1, "Should only see Org A's API key" assert result.data[0]['organization_id'] == orgs['org_a']['id'] assert result.data[0]['organization_id'] != orgs['org_b']['id'] @pytest.mark.asyncio async def test_concurrent_org_requests(setup_test_orgs): """Verify concurrent requests from different orgs don't leak data""" import asyncio from httpx import AsyncClient orgs = await setup_test_orgs async def fetch_properties_for_org(api_key: str): async with AsyncClient() as client: response = await client.get( "http://localhost:8000/api/properties", headers={"X-API-Key": api_key} ) return response.json() # Make concurrent requests from both orgs results = await asyncio.gather( fetch_properties_for_org('api_key_a_value'), fetch_properties_for_org('api_key_b_value') ) # Verify each org only sees their own data assert results[0]['org_id'] == orgs['org_a']['id'] assert results[1]['org_id'] == orgs['org_b']['id'] assert results[0] != results[1], "Orgs should see different data" # tests/e2e/test_onboarding_flow.py from playwright.async_api import async_playwright @pytest.mark.asyncio async def test_multi_org_onboarding(): """E2E test: Two users sign up and verify data isolation""" async with async_playwright() as p: browser = await p.chromium.launch() # User A signup page_a = await browser.new_page() await page_a.goto('http://localhost:3000/signup') await page_a.fill('input[name="email"]', 'usera@example.com') await page_a.fill('input[name="password"]', 'password123') await page_a.click('button[type="submit"]') await page_a.wait_for_url('**/dashboard') # User A connects Hostaway account await page_a.click('text=Connect Hostaway') await page_a.fill('input[name="account_id"]', 'ACC_A') await page_a.fill('input[name="secret_key"]', 'SECRET_A') await page_a.click('button[type="submit"]') # User A generates API key await page_a.click('text=Generate API Key') api_key_a = await page_a.locator('[data-testid="api-key-display"]').text_content() # User B signup (in parallel) page_b = await browser.new_page() await page_b.goto('http://localhost:3000/signup') await page_b.fill('input[name="email"]', 'userb@example.com') await page_b.fill('input[name="password"]', 'password123') await page_b.click('button[type="submit"]') await page_b.wait_for_url('**/dashboard') # User B connects different Hostaway account await page_b.click('text=Connect Hostaway') await page_b.fill('input[name="account_id"]', 'ACC_B') await page_b.fill('input[name="secret_key"]', 'SECRET_B') await page_b.click('button[type="submit"]') # Verify User A cannot see User B's credentials in dashboard await page_a.goto('http://localhost:3000/dashboard/settings') hostaway_account_a = await page_a.locator('[data-testid="hostaway-account"]').text_content() assert 'ACC_A' in hostaway_account_a assert 'ACC_B' not in hostaway_account_a await browser.close() ``` **Key Insights**: 1. **RLS context simulation**: Use `supabase.auth.set_session()` to simulate authenticated user in tests 2. **Concurrent isolation tests**: Verify parallel requests don't leak data between orgs 3. **E2E onboarding flow**: Playwright tests verify UI-level isolation (user A can't see user B's data) 4. **Cleanup fixtures**: Always clean up test data to avoid polluting database **Test Categories**: - **Unit**: RLS policy logic, API key hashing, credential encryption/decryption - **Integration**: Supabase queries with RLS enabled, FastAPI-Supabase data flow - **E2E**: Full user journey from signup → API key generation → MCP tool usage - **Load**: 1000 concurrent MCP requests across 100 orgs (verify no data leakage under load) **Performance Testing**: ```python @pytest.mark.asyncio async def test_concurrent_load_no_leakage(): """Verify 1000 concurrent requests across 100 orgs have no data leakage""" import asyncio # Create 100 test orgs with API keys orgs = await create_test_orgs(count=100) async def make_request(api_key: str, expected_org_id: int): async with httpx.AsyncClient() as client: response = await client.get( "http://localhost:8000/api/properties", headers={"X-API-Key": api_key} ) data = response.json() assert data['org_id'] == expected_org_id, f"Data leaked to wrong org!" return data # Make 10 requests per org (1000 total) tasks = [] for org in orgs: for _ in range(10): tasks.append(make_request(org['api_key'], org['org_id'])) # Run concurrently results = await asyncio.gather(*tasks) # Verify all requests returned correct org data assert len(results) == 1000 assert all(r['org_id'] in [o['org_id'] for o in orgs] for r in results) ``` **Alternatives Considered**: - **Manual testing only**: Rejected - RLS policies too critical to rely on manual verification - **Mocked RLS tests**: Rejected - must test against real Supabase instance to verify policies work - **Production testing**: Rejected - multi-tenant bugs in production = data breach, must test in staging **Rationale**: Automated multi-tenant isolation tests provide confidence that RLS policies prevent data leakage under all conditions. --- ## Research Summary All unknowns from Technical Context have been resolved: | Unknown | Decision | Key Insight | |---------|----------|-------------| | **R1: RLS patterns** | Use subquery-based policies with auth.uid() | Database-enforced isolation, zero application complexity | | **R2: Vault encryption** | pgsodium.crypto_secretbox with HSM-backed keys | Compliance-ready encryption with zero operational overhead | | **R3: Next.js Auth** | @supabase/ssr with Server Components | Seamless auth, automatic RLS enforcement, no boilerplate | | **R4: Stripe billing** | Quantity-based subscriptions with proration | Fair billing, automatic proration, transparent pricing | | **R5: Edge Functions** | Supabase Edge Functions for webhooks | Serverless, secure, native database integration | | **R6: FastAPI integration** | supabase-py with service role key | RLS bypass for auth, dependency injection pattern | | **R7: Testing strategy** | RLS enforcement tests + E2E isolation tests | Automated verification of multi-tenant isolation | **All research complete. Ready for Phase 1: Design (data-model.md, contracts/, quickstart.md).**

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/darrentmorgan/hostaway-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server