Data Isolation and Tenant Security

How the SCORM API ensures complete data isolation between tenants.

Table of Contents

Overview

The SCORM API implements strict multi-tenant data isolation to ensure:

  • Complete data separation between tenants
  • No cross-tenant data access
  • Automatic tenant filtering
  • Security at multiple layers

Tenant Isolation Mechanisms

Layer 1: Authentication

API Key Authentication:

  • Each API key is associated with exactly one tenant
  • Tenant is resolved from API key record
  • All requests automatically scoped to tenant

Clerk Authentication:

  • User's tenant resolved from user_tenants table
  • Tenant cannot be overridden in requests
  • Automatic filtering by user's tenant

Layer 2: Application Logic

Automatic Filtering:

// All queries automatically filter by tenant
const { data: packages } = await supabaseAdmin
  .from(TABLES.PACKAGES)
  .select('*')
  .eq('tenant_id', tenantId); // Always included

Request Validation:

// Tenant ID in request body is ignored (security)
// Tenant always comes from authentication
const { tenantId } = await requireCustomerAuth();
// Request body tenant_id is ignored

Layer 3: Database (RLS)

Row Level Security Policies:

  • Database-level enforcement
  • Prevents direct database access
  • Works even if application logic fails

Row Level Security (RLS)

How RLS Works

RLS policies automatically filter database queries:

-- Example RLS policy
CREATE POLICY "tenant_isolation" ON scorm_packages
  FOR ALL
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

RLS Policies

Packages Table:

  • Users can only see packages from their tenant
  • INSERT requires matching tenant_id
  • UPDATE/DELETE only on own tenant's packages

Sessions Table:

  • Sessions filtered by tenant
  • Cross-tenant session access prevented
  • Automatic tenant assignment on creation

Events Table:

  • Events filtered by tenant
  • No cross-tenant event visibility
  • Tenant set automatically

Testing RLS

-- Set tenant context
SET LOCAL app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';

-- Query - only returns tenant's data
SELECT * FROM scorm_packages;
-- Returns only packages for tenant 550e8400...

-- Try to access other tenant's data
SELECT * FROM scorm_packages WHERE tenant_id = 'other-tenant-id';
-- Returns empty (RLS blocks it)

API-Level Isolation

Customer Routes

Automatic Tenant Resolution:

export async function GET(request: NextRequest) {
  // Tenant resolved from authenticated user
  const { tenantId } = await requireCustomerAuth();
  
  // All queries filtered by tenant
  const packages = await getPackages(tenantId);
  
  // Request body tenant_id is IGNORED (security)
  return NextResponse.json({ packages });
}

Security Features:

  • Tenant ID from authentication only
  • Request body tenant_id ignored
  • No way to override tenant
  • Automatic filtering

API Key Routes

Tenant from API Key:

export async function GET(request: NextRequest) {
  // Tenant resolved from API key
  const { tenantId } = await requireTenantAuth();
  
  // All queries filtered by tenant
  const packages = await getPackages(tenantId);
  
  return NextResponse.json({ packages });
}

Security Features:

  • Tenant from API key record
  • Cannot access other tenants
  • Automatic filtering
  • Scope-based permissions

Admin Routes

Optional Tenant Filtering:

export async function GET(request: NextRequest) {
  // System admin can view all tenants
  await requireSystemAdmin();
  
  // Optional tenant filter
  const tenantId = request.nextUrl.searchParams.get('tenant_id');
  
  const packages = tenantId
    ? await getPackages(tenantId)
    : await getAllPackages();
  
  return NextResponse.json({ packages });
}

Security Features:

  • System admin only
  • Explicit tenant filtering
  • Audit logging
  • Access control

Storage Isolation

Storage Paths

Tenant-Specific Paths:

tenant-550e8400/packages/pkg_abc123/
tenant-550e8400/sessions/session_xyz789/

Benefits:

  • Logical separation
  • Easy to identify tenant
  • Simplified backup/restore
  • Clear access patterns

Access Control

Storage Permissions:

  • Tenant-specific access
  • No cross-tenant file access
  • Signed URLs for temporary access
  • Expiration on URLs

Example:

// Generate tenant-scoped signed URL
const url = await generateSignedUrl({
  path: `tenant-${tenantId}/packages/${packageId}`,
  expiresIn: 3600 // 1 hour
});

Best Practices

1. Never Trust Client-Supplied Tenant ID

❌ Wrong:

const tenantId = request.body.tenant_id; // NEVER DO THIS

✅ Correct:

const { tenantId } = await requireCustomerAuth(); // From authentication

2. Always Filter by Tenant

❌ Wrong:

const packages = await db.packages.findMany(); // No tenant filter

✅ Correct:

const packages = await db.packages.findMany({
  where: { tenant_id: tenantId } // Always filter
});

3. Validate Tenant on Updates

❌ Wrong:

await db.packages.update({
  where: { id: packageId },
  data: updates
}); // No tenant check

✅ Correct:

await db.packages.update({
  where: {
    id: packageId,
    tenant_id: tenantId // Verify tenant matches
  },
  data: updates
});

4. Use RLS Policies

Enable RLS:

ALTER TABLE scorm_packages ENABLE ROW LEVEL SECURITY;

CREATE POLICY "tenant_isolation" ON scorm_packages
  FOR ALL
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

5. Test Isolation

Test Cross-Tenant Access:

// Should fail - cannot access other tenant's data
const otherTenantPackage = await getPackage(
  otherTenantPackageId,
  myTenantId
);
// Should return null or throw error

Security Checklist

  • RLS policies enabled on all tables
  • Tenant ID always from authentication
  • Request body tenant_id ignored
  • All queries filter by tenant
  • Storage paths are tenant-specific
  • Cross-tenant access tests pass
  • Admin routes properly secured
  • Audit logging enabled

Related Documentation


Last Updated: 2025-01-15