Skip to main content

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:

PageRoutePurpose
Users List/usersView 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:

FilePurpose
data/TenantInfo.ktRoom entity for tenant metadata
data/TenantInfoDao.ktDAO for tenant queries
data/TenantPrefs.ktSharedPreferences for active tenant
data/TenantContext.ktObservable tenant state manager
sync/TenantsApi.ktAPI client for /api/UserTenants
ui/tenant/TenantSelectorScreen.ktTenant selection UI
ui/tenant/TenantSelectorViewModel.ktViewModel
ui/components/TenantIndicator.ktApp 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:

FilePurpose
Models/TenantInfo.swiftSwiftData model for tenant metadata
Tenant/TenantPrefs.swiftUserDefaults for active tenant
Tenant/TenantContext.swiftObservable tenant state manager
Network/TenantsAPI.swiftAPI client for /api/UserTenants
Views/Tenant/TenantSelectorView.swiftTenant selection UI
Views/Tenant/TenantIndicator.swiftCompact tenant badge
App/EnvironmentKeys.swiftTenantContext 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 tenantSlug parameter
  • All process*Changes() methods set tenantSlug on 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:

  1. Debugging: Easy to identify tenant in logs and database queries
  2. URLs: Used in tenant subdomain URLs (e.g., acme.cyzag.co)
  3. Performance: String comparison is fast with proper indexing
  4. 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)

FileChanges
src/Panel/Models/UserTenantMembership.csNew entity
src/Panel/Models/TenantRoles.csRole constants
src/Panel/Data/ApplicationDbContext.csDbSet + configuration
src/Panel/Services/UserManagementService.csUser-tenant CRUD
src/Web/Endpoints/UserTenants.csMobile API endpoint
src/Panel/Components/Pages/Users/Users.razorUser list page
src/Panel/Components/Pages/Users/UserDetails.razorUser details page
src/Panel/Components/Pages/Tenants/TenantDetails.razorUpdated with users
src/Panel/Components/Layout/NavMenu.razorAdded Users link

Android (cyzag-blueprint-android)

FileChanges
data/AppDatabase.ktAdded TenantInfo entity, version 18
All 21 entity filesAdded tenantSlug property
All DAO filesAdded tenantSlug parameter to queries
All repository filesFilter by active tenant
sync/SyncItemsWorker.ktSet tenantSlug on synced entities
New tenant files (8)TenantContext, TenantPrefs, UI, etc.

iOS (cyzag-blueprints-ios)

FileChanges
All 22 model filesAdded tenantSlug property
Sync/SyncService.swiftAccept and apply tenantSlug
Sync/SyncAPIClient.swiftDynamic base URL
App/CyzagBlueprintIosApp.swiftTenantContext 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

  1. Tenant Selection: Verify tenant list loads from API
  2. Data Filtering: Verify queries return only active tenant's data
  3. Offline Switching: Verify can switch tenants offline
  4. Sync URL: Verify correct URL constructed per tenant

Known Limitations

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

  1. Implement tenant-specific deep links (Phase 5)
  2. Add automated tests for tenant filtering
  3. UI polish for tenant switching experience

Medium Term

  1. Tenant-scoped push notifications
  2. Per-tenant sync timestamps optimization
  3. Tenant data size indicators in UI

See Also