Multi-Tenancy Design
Aero2 is a multi-tenant platform where a single deployment serves many applications. This page documents the entity hierarchy, isolation model, and data scoping strategy.
Hierarchy
Platform (single Aero2 deployment)
|
+-- Dashboard Application (built-in, slug: "dashboard")
| +-- Developers (Users of the dashboard app)
| | +-- Developer Teams (Organizations in the dashboard app)
| | +-- owns Applications
| |
| +-- Uses Aero2's own auth (dogfooding)
|
+-- Application "TaskFlow" (slug: "swift-maple", auto-generated)
| +-- App Users (end-users of TaskFlow)
| +-- App Organizations (customer teams within TaskFlow)
| +-- App IdPs, Branding, Settings
| +-- Served at swift-maple.aero2.dev
|
+-- Application "ShopEasy" (slug: "brave-falcon", auto-generated)
+-- App Users (completely separate from TaskFlow)
+-- App Organizations
+-- Served at brave-falcon.aero2.devEntity Relationships
"Dashboard" Application (built-in)
|
+-- Developer (User of dashboard app)
| +-- Developer Team (Organization in dashboard app)
| +-- owns ---+
v
+------------------+
| Application |
|------------------|
| slug (auto) | -> swift-maple.aero2.dev
| custom_domain | -> auth.myapp.com (optional)
| owner_org_id | -> Developer Team
| settings (JSON) |
| branding (JSON) |
+--------+---------+
+-----------+-----------+--------------+
v v v v
+----------+ +--------+ +----------+ +--------------+
| App User | |App IdP | |App OAuth | | App |
|----------| |--------| |Client | |Organization |
| app_id | | app_id | |----------| |--------------|
| email | | type | | app_id | | app_id |
| password | | config | | scopes | | name |
+----------+ +--------+ +----------+ | members |
+--------------+Key Principles
- Application slugs are auto-generated as adjective-noun pairs (e.g.,
swift-maple,brave-falcon). Developers choose the app name, but the subdomain slug is assigned by the system. On collision, a 3-character suffix is appended (e.g.,swift-maple-k3f). - A Platform Operator is seeded at first boot (from
BOOTSTRAP_ADMIN_EMAILenvironment variable). - The Dashboard is a built-in Application created at first boot (slug:
dashboard). - A Developer is a User of the Dashboard Application.
- A Developer signs up at
dashboard.{domain}via OAuth login (self-service) and receives thedeveloperrole. - A Developer Team is an Organization within the Dashboard Application.
- A Developer Team owns many Applications.
- An Application belongs to exactly one Developer Team.
- Multiple Developers in a Team can manage the same Application.
- An Application has its own Users, IdPs, OAuth Clients, Settings, and Branding.
- An Application User belongs to exactly one Application.
- An Application User can belong to many Organizations within that Application.
- Dashboard Users (Developers) cannot see Application Users and vice versa.
- The same email can exist in multiple Applications as separate user accounts.
Data Isolation via app_id
Every tenant-scoped table includes an app_id column that references the applications table. This column enforces data isolation at the database level.
Tables with app_id Scoping
users-- each user belongs to one applicationuser_identity_links-- inherits from user, explicit for query safetyoauth_clients-- each client belongs to one applicationidentity_providers-- each IdP config belongs to one applicationauthorization_codes-- scoped to apprefresh_tokens-- scoped to appoauth_state-- scoped to appuser_sessions-- scoped to appaudit_logs-- scoped to approles-- each app has its own rolespermissions-- each app has its own permissionsrole_permissions-- scoped to appuser_roles-- scoped to app
Composite Unique Constraints
Because the same email can exist in different applications, unique constraints must be composite:
UNIQUE(app_id, email)onusersUNIQUE(app_id, name)onrolesUNIQUE(app_id, client_id)onoauth_clientsUNIQUE(app_id, name)onidentity_providers
Query Pattern
Every database query touching tenant-scoped tables must include the app_id filter:
-- Correct: scoped to the current application
SELECT * FROM users WHERE app_id = ? AND email = ?
-- WRONG: missing app_id filter, leaks data across applications
SELECT * FROM users WHERE email = ?RBAC scoping rule: All RBAC joins must include app_id across user_roles, roles, role_permissions, and permissions. Role and permission IDs are not globally unique, so missing app_id filters can leak access across applications.
Per-Application Isolation
Each application is isolated across multiple dimensions:
| Dimension | Isolation Mechanism |
|---|---|
| Data | app_id column on all tenant-scoped tables |
| Cookies | Cookie Domain set to app's subdomain |
| CORS | Each subdomain is its own origin |
| OIDC Issuer | https://{slug}.{PLATFORM_DOMAIN} per app |
| Branding | Per-app logo, colors, name applied to auth pages |
| Settings | Per-app signup mode, MFA policy, session TTL |
| Users | Same email in different apps = different user accounts |
Dashboard as an Application
The Dashboard itself is an Application with is_dashboard = true. This enables dogfooding: developers authenticate using the same auth system they configure for their own applications. Developer Teams are simply Organizations within the dashboard app.
Dashboard Access Control
The Dashboard uses a two-layer authorization model that separates what actions a user can perform (RBAC) from which resources they can access (ownership).
Dashboard Roles
| Role | Description | How Assigned |
|---|---|---|
operator | Platform operator with full system access | Seeded from BOOTSTRAP_ADMIN_EMAIL; manually granted |
developer | Can create and manage own applications | Auto-assigned on first OAuth login (default) |
member | Read-only access to team applications | Manually assigned by team admin |
Two-Layer Authorization
Every authorized action passes through two checks:
- RBAC Layer: Does the user's role include the required permission?
- Ownership Layer: Does the user's team own the target resource?
The operator role bypasses the ownership layer and can access any resource (for platform support). The developer role must pass both layers.
Dashboard vs Per-App Permissions
The Dashboard has its own permission set, separate from the permissions within each application:
Dashboard permissions control platform-level actions:
- Platform operations (operator only):
platform:manage,developers:read,developers:write,apps:read:all,apps:write:all - Developer operations:
apps:create,apps:read,apps:write,apps:delete,apps:transfer,teams:read,teams:write - Everyone:
profile:read,profile:write
Per-app permissions control end-user actions within an application:
- The default set (
clients:read,clients:write,users:read,users:write,idps:read, etc.) is seeded when creating each new application - Each application can customize its own roles and permissions independently
- When a developer manages their app's users/clients/roles via
/api/applications/:slug/*, the system checks dashboard permissions (RBAC + ownership), not per-app permissions
Dashboard vs App Context: Dashboard requests are authenticated against the dashboard app but may operate on a target application if RBAC + ownership checks pass. This is the only supported cross-app access path.
Token and Session Scoping
- Sessions and refresh tokens are stored with
app_idand must be validated against the current request'sapp_id. - Tokens should carry app context via
iss(per-app issuer) and/or anapp_idclaim so verifiers can reject cross-app tokens.
Sign-up Flows
Developer Sign-up (Dashboard)
Developer navigates to dashboard.aero2.dev/login
|
v
Clicks "Sign in with GitHub" (or Google, etc.)
|
v
OAuth callback → User record created in dashboard app
|
v
Auto-assigned "developer" role
(Exception: BOOTSTRAP_ADMIN_EMAIL gets "operator" role)
|
v
Lands on Dashboard → Can create applicationsDevelopers sign up through the Dashboard's own OAuth login. There is no separate sign-up form -- the first OAuth login implicitly creates the developer account. This is the same auth flow used by all applications, but pointed at the Dashboard's own configured identity providers.
End-User Sign-up (Per-Application)
End-user navigates to swift-maple.aero2.dev/signup
|
v
Signs up via app's configured auth methods
(email/password, OAuth, magic link, passkey)
|
v
User record created in the application
(completely separate from dashboard users)
|
v
Assigned app's default role → Access app resourcesEnd-users sign up on the application's subdomain using whatever authentication methods the developer has enabled. Their user record exists only within that application's app_id partition.
Key Distinction
A developer's identity in the Dashboard is completely separate from any end-user identity in an application. Even if the same person uses the same email address for both, they are different user records in different app_id partitions. The same email can exist in as many applications as needed.