{"record":{"id":"f717763faf70cea8","repo":"fullstackhero/dotnet-starter-kit","slug":"tenant-must-retain-at-least-one-administrator","errorCode":null,"errorMessage":"Tenant must retain at least one administrator.","messagePattern":"Tenant must retain at least one administrator\\.","errorType":"exception","errorClass":"CustomException","httpStatus":400,"severity":"error","filePath":"src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs","lineNumber":118,"sourceCode":"        }\n\n        // After this removal, at least one admin must remain in the tenant — matches\n        // the \"at least one active administrator\" invariant enforced on user deactivation.\n        await EnsureMinimumAdminCountAsync();\n    }\n\n    private bool IsRootTenantAdmin(FshUser user)\n    {\n        return user.Email == MultitenancyConstants.Root.EmailAddress\n            && multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id == MultitenancyConstants.Root.Id;\n    }\n\n    private async Task EnsureMinimumAdminCountAsync()\n    {\n        int adminCount = (await userManager.GetUsersInRoleAsync(RoleConstants.Admin)).Count;\n        if (adminCount <= 1)\n        {\n            throw new CustomException(\n                \"Tenant must retain at least one administrator.\",\n                Array.Empty<string>(),\n                HttpStatusCode.BadRequest);\n        }\n    }\n\n    private async Task<List<string>> ProcessRoleAssignmentsAsync(FshUser user, List<UserRoleDto> userRoles)\n    {\n        var assignedRoles = new List<string>();\n\n        foreach (var userRole in userRoles)\n        {\n            if (await roleManager.FindByNameAsync(userRole.RoleName!) is null)\n            {\n                continue;\n            }\n\n            if (userRole.Enabled)","sourceCodeStart":100,"sourceCodeEnd":136,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs#L100-L136","documentation":"Thrown by EnsureMinimumAdminCountAsync after the pending removal is accounted for: if the tenant would end up with zero or one administrator, the change is rejected. Enforces the 'at least one active administrator' tenant invariant at the role level.","triggerScenarios":"Demoting or removing the Admin role from a user when the tenant currently has exactly one (or the last two) admins.","commonSituations":"Offboarding the only admin in a small tenant; bulk demotions that would exhaust admins; misconfigured role names so GetUsersInRoleAsync(Admin) undercounts (e.g. custom role constant changed).","solutions":["Promote another user to Admin before demoting the current one.","Perform changes in an order that never drops admin count to 1.","If the count looks wrong, verify RoleConstants.Admin matches the actual role name in the database.","For tenant offboarding, transfer ownership first via a different admin account."],"exampleFix":"// before: demote directly, may hit the minimum-admin guard\nawait userRoleService.AssignRolesAsync(soleAdminId, noAdminRoles, ct);\n// after: promote a successor first\nawait userRoleService.AssignRolesAsync(successorId, rolesWithAdmin, ct);\nawait userRoleService.AssignRolesAsync(soleAdminId, noAdminRoles, ct);","handlingStrategy":"validation","validationCode":"const admins = await apiFetch(\"/api/v1/users?role=Admin\").then(r => r.length);\nif (demotions >= admins - 1) throw new Error(\"Tenant must keep at least one administrator\");","typeGuard":null,"tryCatchPattern":"try { await assignRoles(userId, roles); } catch (e) {\n  if (e.status === 400 && /at least one administrator/i.test(e.message)) notify(\"Promote a successor admin first\");\n  else throw e;\n}","preventionTips":["Promote a new admin before demoting/offboarding the current one.","Apply bulk role changes in an order that never empties admins.","Keep RoleConstants.Admin in sync with actual role names in the DB."],"tags":["identity","roles","invariant"],"backgroundTag":"invalid-state-transition","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}