January 2026: Multi-Tenant Mobile Support
Overview
This release enables multi-tenant support for Android and iOS mobile apps. Users can now authenticate once and access multiple organizations (tenants) from a single app installation. Each organization's data is stored locally and filtered by a human-readable tenantSlug identifier.
What's New
1. Global Authentication with Tenant Selection
Users authenticate once via OpenIddict at my.cyzag.co and receive access to all their assigned organizations:
- Single sign-on: One account, multiple organizations
- Tenant selector: Choose which organization to work with
- Easy switching: Switch organizations from the menu without re-authenticating
- Offline switching: View previously synced data for any organization even offline
2. Backend: User-Tenant Membership
New Entity: UserTenantMembership links users to tenants with roles:
public class UserTenantMembership
{
public Guid Id { get; set; }
public string UserId { get; set; }
public Guid TenantId { get; set; }
public string Role { get; set; } // "Operator" or "TenantAdmin"
public bool IsActive { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime? LastAccessedUtc { get; set; }
}
New API Endpoint: GET /api/UserTenants returns the user's assigned tenants:
{
"tenants": [
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "acme",
"displayName": "ACME Corporation",
"hostName": "acme.cyzag.co"
}
]
}
JWT Enhancement: Tenant memberships are included in JWT tokens as the tenant_memberships claim.
3. Panel: User Management UI
New Blazor pages for managing user-tenant assignments:
| Page | Route | Purpose |
|---|---|---|
| Users List | /users | View all users (SuperAdmin) |
| User Details | /users/{id} | View user profile and tenant memberships |
| Tenant Details | /tenants/details/{id} | Updated to show assigned users |
User Details Features:
- View all tenant memberships
- Assign user to additional tenants
- Change role (Operator / Tenant Admin)
- Activate/deactivate memberships
- Remove from tenant
Tenant Details Features:
- View users assigned to tenant
- Add existing users
- Change user roles
- Remove users
4. Android: Multi-Tenant Implementation
New Files:
| File | Purpose |
|---|---|
data/TenantInfo.kt | Room entity for tenant metadata |
data/TenantInfoDao.kt | DAO for tenant queries |
data/TenantPrefs.kt | SharedPreferences for active tenant |
data/TenantContext.kt | Observable tenant state manager |
sync/TenantsApi.kt | API client for /api/UserTenants |
ui/tenant/TenantSelectorScreen.kt | Tenant selection UI |
ui/tenant/TenantSelectorViewModel.kt | ViewModel |
ui/components/TenantIndicator.kt | App bar tenant indicator |
Entity Updates: All 21 entities now include tenantSlug: String:
@Entity(tableName = "assets", indices = [Index(value = ["tenantSlug"])])
data class Asset(
@PrimaryKey val id: UUID,
val tenantSlug: String, // Human-readable: "acme", "contoso"
// ... other fields
)
Database Version: Incremented to 18 with destructive migration.
Development Mode: Debug builds continue using localhost; production uses tenant subdomains.
5. iOS: Multi-Tenant Implementation
New Files:
| File | Purpose |
|---|---|
Models/TenantInfo.swift | SwiftData model for tenant metadata |
Tenant/TenantPrefs.swift | UserDefaults for active tenant |
Tenant/TenantContext.swift | Observable tenant state manager |
Network/TenantsAPI.swift | API client for /api/UserTenants |
Views/Tenant/TenantSelectorView.swift | Tenant selection UI |
Views/Tenant/TenantIndicator.swift | Compact tenant badge |
App/EnvironmentKeys.swift | TenantContext environment key |
Model Updates: All 22 SwiftData models now include tenantSlug: String:
@Model
final class Asset {
var id: UUID
var tenantSlug: String // Human-readable: "acme", "contoso"
// ... other fields
}
SyncService Updates:
- Accepts
tenantSlugparameter - All
process*Changes()methods settenantSlugon new entities - Dynamic URL construction based on tenant
Technical Details
Why Tenant Slug (Not UUID)?
We use the human-readable slug instead of tenantId (UUID) because:
- Debugging: Easy to identify tenant in logs and database queries
- URLs: Used in tenant subdomain URLs (e.g.,
acme.cyzag.co) - Performance: String comparison is fast with proper indexing
- Readability: Clear in code and data inspections
Sync URL Construction
// Android
fun getSyncUrl(tenant: TenantInfo): String {
return if (BuildConfig.DEBUG) {
"https://10.0.2.2:5001/api/" // Emulator localhost
} else {
"https://${tenant.hostName}/api/"
}
}
// iOS
func getSyncUrl(for tenant: TenantInfo) -> URL {
#if DEBUG
return URL(string: "https://localhost:5001/api/")!
#else
return URL(string: "https://\(tenant.hostName)/api/")!
#endif
}
Data Filtering Pattern
All queries filter by the active tenant:
// Android DAO
@Query("SELECT * FROM assets WHERE tenantSlug = :tenantSlug ORDER BY name")
fun getAssets(tenantSlug: String): Flow<List<Asset>>
// iOS View
private var rounds: [OperatorRound] {
guard let slug = tenantContext?.activeTenantSlug else { return [] }
return allRounds.filter { $0.tenantSlug == slug }
}
Sync Flow
1. User authenticates (once, globally)
└── JWT includes tenant_memberships claim
2. App fetches tenant list
└── GET /api/UserTenants
3. User selects tenant
└── Stored in TenantPrefs
4. Sync constructs URL
└── Dev: localhost | Prod: tenant subdomain
5. Server validates tenant access via JWT
6. Local storage tags all entities with tenantSlug
7. All queries filter by activeTenantSlug
Files Changed
Backend (cyzag-blueprint-api)
| File | Changes |
|---|---|
src/Panel/Models/UserTenantMembership.cs | New entity |
src/Panel/Models/TenantRoles.cs | Role constants |
src/Panel/Data/ApplicationDbContext.cs | DbSet + configuration |
src/Panel/Services/UserManagementService.cs | User-tenant CRUD |
src/Web/Endpoints/UserTenants.cs | Mobile API endpoint |
src/Panel/Components/Pages/Users/Users.razor | User list page |
src/Panel/Components/Pages/Users/UserDetails.razor | User details page |
src/Panel/Components/Pages/Tenants/TenantDetails.razor | Updated with users |
src/Panel/Components/Layout/NavMenu.razor | Added Users link |
Android (cyzag-blueprint-android)
| File | Changes |
|---|---|
data/AppDatabase.kt | Added TenantInfo entity, version 18 |
| All 21 entity files | Added tenantSlug property |
| All DAO files | Added tenantSlug parameter to queries |
| All repository files | Filter by active tenant |
sync/SyncItemsWorker.kt | Set tenantSlug on synced entities |
| New tenant files (8) | TenantContext, TenantPrefs, UI, etc. |
iOS (cyzag-blueprints-ios)
| File | Changes |
|---|---|
| All 22 model files | Added tenantSlug property |
Sync/SyncService.swift | Accept and apply tenantSlug |
Sync/SyncAPIClient.swift | Dynamic base URL |
App/CyzagBlueprintIosApp.swift | TenantContext in environment |
| New tenant files (6) | TenantContext, TenantPrefs, UI, etc. |
Migration Notes
Database Changes
Android: Database version 18 uses destructive migration. Users will need to re-sync.
iOS: SwiftData models updated. Existing data migrated with empty tenantSlug (requires re-sync).
API Changes
New endpoint GET /api/UserTenants requires authorization header.
Breaking Changes
- All mobile entities now require
tenantSlug - All DAO/repository methods require tenant filtering
- Sync endpoint returns tenant-scoped data based on JWT claims
Testing Recommendations
Backend Tests
[Test]
public async Task UserCanOnlyAccessAssignedTenants()
{
var user = await CreateTestUser();
var tenant1 = await CreateTestTenant("acme");
await AssignUserToTenant(user.Id, tenant1.Id, "Operator");
var tenants = await _userService.GetUserTenantsAsync(user.Id);
tenants.Should().ContainSingle();
tenants[0].Slug.Should().Be("acme");
}
Mobile Tests
- Tenant Selection: Verify tenant list loads from API
- Data Filtering: Verify queries return only active tenant's data
- Offline Switching: Verify can switch tenants offline
- Sync URL: Verify correct URL constructed per tenant
Known Limitations
Deep Links (Phase 5 - Not Yet Implemented)
Deep links currently use hardcoded domains. Future work will:
- Support tenant-specific deep link domains
- Route deep links to correct tenant context
Single Tenant Active
Only one tenant can be active at a time. Users must switch to view different tenants.
Next Steps
Short Term
- Implement tenant-specific deep links (Phase 5)
- Add automated tests for tenant filtering
- UI polish for tenant switching experience
Medium Term
- Tenant-scoped push notifications
- Per-tenant sync timestamps optimization
- Tenant data size indicators in UI