Planned approach for secure multi-tenant data isolation
Planned implementation: This guide describes the intended tenant isolation strategy for OrgStack. The current codebase does not yet implement tenant identifiers or isolation logic. The BaseEntity class provides UUID identifiers but does not include tenant scoping.
OrgStack is designed as a multi-tenant platform where each organization’s data will be completely isolated from other organizations. This guide explains how tenant isolation will be implemented and enforced throughout the application.
OrgStack uses a shared database, shared schema multi-tenancy model. All organizations store their data in the same database tables, with tenant isolation enforced at the application layer through a tenantId discriminator.
This architecture balances cost efficiency (one database for all tenants) with operational simplicity (no schema migrations per tenant).
@Service@Transactionalpublic class ProjectService { private final ProjectRepository projectRepository; public Project createProject(CreateProjectRequest request) { UUID tenantId = TenantContext.getCurrentTenantId(); Project project = new Project(); project.setTenantId(tenantId); project.setName(request.getName()); project.setDescription(request.getDescription()); return projectRepository.save(project); } public Project getProject(UUID projectId) { UUID tenantId = TenantContext.getCurrentTenantId(); return projectRepository.findByIdAndTenantId(projectId, tenantId) .orElseThrow(() -> new NotFoundException("Project not found")); } public void deleteProject(UUID projectId) { UUID tenantId = TenantContext.getCurrentTenantId(); Project project = projectRepository.findByIdAndTenantId(projectId, tenantId) .orElseThrow(() -> new NotFoundException("Project not found")); projectRepository.delete(project); }}
Always use findByIdAndTenantId() instead of findById(). This ensures you can’t accidentally access another tenant’s data even if you know their entity IDs.
Critical security rule: Never accept tenantId from request parameters, request body, or query strings. Always extract it from the authenticated user’s JWT token.
// ❌ DANGEROUS - Client can manipulate tenantId@PostMapping("/projects")public Project createProject(@RequestBody CreateProjectRequest request) { Project project = new Project(); project.setTenantId(request.getTenantId()); // NEVER DO THIS return projectRepository.save(project);}// ✅ SECURE - Tenant ID from authentication context@PostMapping("/projects")public Project createProject(@RequestBody CreateProjectRequest request) { UUID tenantId = TenantContext.getCurrentTenantId(); Project project = new Project(); project.setTenantId(tenantId); // Always use authenticated tenant return projectRepository.save(project);}
UUIDs prevent tenant data enumeration attacks. With sequential IDs, an attacker could guess valid IDs from other tenants. UUIDs make this computationally infeasible.
// ❌ WRONG - Missing tenant IDProject project = new Project();project.setName("New Project");projectRepository.save(project); // tenantId is null!// ✅ CORRECT - Sets tenant IDUUID tenantId = TenantContext.getCurrentTenantId();Project project = new Project();project.setTenantId(tenantId);project.setName("New Project");projectRepository.save(project);
Using @Query without tenant filter
Custom JPQL queries must include tenant filtering:
// ❌ WRONG - No tenant filter@Query("SELECT p FROM Project p WHERE p.name LIKE %:name%")List<Project> searchByName(String name);// ✅ CORRECT - Includes tenant filter@Query("SELECT p FROM Project p WHERE p.tenantId = :tenantId AND p.name LIKE %:name%")List<Project> searchByNameAndTenantId(UUID tenantId, String name);