Topliner is a multi-tenant platform where users belong to Spaces (teams). Data from one team must never be accessible to users outside that team. This document evaluates four approaches to data isolation and explains why we chose application-level checks.
Current stack: Laravel (PHP), Azure Managed MySQL 8.0, Redis, 100+ PM2 queue workers.
Data model constraints:
- Projects belong to Spaces, but can be shared with users from other Spaces
- Candidates belong to Companies, which belong to Projects — no direct tenant relationship
- Background jobs (scoring, scraping, agent research) run without HTTP/user context
- 50K+ candidates per project is common
Authorization enforced per-endpoint via policies, middleware, and manual checks.
How it works: Each controller calls $this->authorize('view', $project) or Project::canBeAccessedBy($user). Access rules: owner, personally shared, or space member.
Pros:
- Minimal changes — extends what already works
- Flexible access model (different rules per resource type)
- No infrastructure changes
- Compatible with background jobs (no user context needed)
- Supports cross-space sharing naturally
Cons:
- No safety net — a forgotten
authorize()call = data leak - Every new endpoint requires manual authorization
- Human error is the primary risk vector
Mitigation: Comprehensive audit (found 17 gaps), fix all gaps, add integration tests to prevent regressions.
Each Space gets its own MySQL database: topliner_space_1, topliner_space_2, etc.
Pros:
- Absolute physical isolation — impossible to read another tenant's data
- Safe destructive operations (TRUNCATE, DROP) scoped to one tenant
- Simple per-tenant backup/restore
Cons:
- Azure Managed MySQL: each database = separate resource and cost
- Migrations must run on every database
- Cross-tenant queries impossible (breaks cross-space project sharing)
- Massive refactor — essentially rewrite the application
- Connection management complexity at scale
PostgreSQL supports multiple schemas within one database. MySQL does not have native schema separation — effectively identical to 2a.
Add a space_id column to every table. Use Eloquent Global Scopes to automatically append WHERE space_id = X to all queries.
// Example Global Scope
class SpaceScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$builder->where('space_id', auth()->user()->current_space_id);
}
}Pros:
- Single database — simple infrastructure
- Automatic filtering via Global Scope — acts as a safety net
- Forgotten authorization in a controller? Scope still filters
Cons (and why we're not doing this):
-
Data model mismatch. Candidates don't directly belong to a Space. The chain is
candidate → company → project → space. Addingspace_idtocandidatesis denormalization that must be maintained on every move/copy operation. -
Background jobs break. 100+ PM2 workers run without HTTP context (no authenticated user, no "current space"). Global Scope would break all of them: scoring, scraping, agent research, location normalization, etc. Every job would need explicit scope bypass or tenant context threading through the entire call chain.
-
Cross-space sharing conflicts. Projects can be shared with users from other Spaces. With a
tenant_id, whosespace_idgoes on the row? Shared projects fundamentally conflict with single-tenant-per-row. -
Migration risk. Adding a column + index to ~30 tables with 50K+ rows per project = heavy migrations on production. Data backfill required for every existing row.
-
Production stability risk. One bug in a Global Scope = users can't see their own data, or jobs start failing en masse.
Laravel packages: stancl/tenancy and spatie/laravel-multitenancy implement this pattern, but they assume a cleaner tenant boundary than our data model provides.
Database-native policies that filter rows based on session variables. Enforced by the database engine itself — application code cannot bypass it.
-- PostgreSQL example
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (space_id = current_setting('app.space_id')::int);Pros:
- Impossible to bypass from application code — the database enforces it
- Even SQL injection won't expose another tenant's data
- Strongest isolation guarantee short of separate databases
Cons:
- MySQL 8.0 does not support RLS. We use Azure Managed MySQL — this option is simply unavailable.
- Migration to PostgreSQL would be a massive undertaking
- Same data model issues as Approach 2c (need
space_idon every table) - Same background job issues (need to set session variable before every query)
When it makes sense: If you're on PostgreSQL and your data model has a clean tenant boundary (every table has tenant_id), RLS is the gold standard.
Encrypt each tenant's data with a unique key. Even if the database is fully compromised, one tenant's data is unreadable without their specific key.
Pros:
- Maximum protection against data breach at the storage level
- Compliance benefits (GDPR, SOC2, HIPAA) — data encrypted at rest per tenant
- Even database administrators can't read other tenants' data without keys
Cons:
- Kills query performance.
WHEREclauses on encrypted columns don't work — no search, sort, or filter on encrypted fields. Requires decryption in application layer. - Key management complexity (secure storage, rotation, per-tenant key provisioning)
- Complete refactor of the read/write layer
- Solves a different problem — encryption protects against data breach, not access control. Our threat model is "authenticated user accesses another user's data through the application," not "attacker steals the database."
When it makes sense: Regulated industries (healthcare, finance) where compliance requires per-tenant encryption. Not applicable to our access control problem.
| Criterion | App-Level Checks | DB Multi-Tenant (2c) | RLS | RLE |
|---|---|---|---|---|
| Implementation effort | Low | High | Impossible (MySQL) | Very High |
| Production risk | Low | High | N/A | High |
| Safety net | No | Yes (Global Scope) | Yes (DB-enforced) | No |
| Background job compat | Yes | Problematic | Problematic | Yes |
| Cross-space sharing | Yes | Problematic | Problematic | Yes |
| MySQL 8.0 compatible | Yes | Yes | No | Yes |
| Query performance | No impact | Minor (extra WHERE) | Minor | Severe |
Chosen approach: Application-level checks.
Why: It's the only approach that is (a) practical with our current MySQL infrastructure, (b) compatible with our complex data model (cross-space sharing, candidate→company→project chain), (c) safe for 100+ background workers running without user context, and (d) achievable without a massive refactor.
Risk mitigation:
- Comprehensive security audit identified 17 authorization gaps
- All gaps being fixed with explicit checks
- Integration tests to catch future regressions
Future consideration: If we migrate to PostgreSQL and simplify the tenant boundary, Row-Level Security (RLS) becomes a viable upgrade path that provides a database-level safety net on top of application checks.