From 59f8467f7c0fa292e1f894146f066d92c4001a89 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Wed, 26 Aug 2020 14:12:04 -0400 Subject: [PATCH] Create sso user api (#886) * facilitate linking/unlinking existing users from an sso enabled org * added user_identifier to identity methods for sso * moved sso user delete method to account controller * fixed a broken test * Update AccountsController.cs * facilitate linking/unlinking existing users from an sso enabled org * added user_identifier to identity methods for sso * moved sso user delete method to account controller * fixed a broken test * added a token to the existing user sso link flow * added a token to the existing user sso link flow * fixed a typo * added an event log for unlink ssoUser records * fixed a merge issue * fixed a busted test * fixed a busted test * ran a formatter over everything & changed .vscode settings in .gitignore * chagned a variable to use string interpolation * removed a blank line * Changed TokenPurpose enum to a static class of strings * code review cleanups * formatting fix * Changed parameters & logging for delete sso user * changed th method used to get organization user for deleting sso user records Co-authored-by: Kyle Spearrin --- .gitignore | 4 +- src/Api/Controllers/AccountsController.cs | 77 +++++++++++++------ src/Core/Constants.cs | 5 ++ src/Core/Enums/EventType.cs | 1 + .../ProfileOrganizationResponseModel.cs | 2 + .../OrganizationUserOrganizationDetails.cs | 1 + src/Core/Repositories/ISsoUserRepository.cs | 5 +- .../SqlServer/SsoUserRepository.cs | 16 ++++ src/Core/Services/IOrganizationService.cs | 1 + src/Core/Services/IUserService.cs | 1 + .../Implementations/OrganizationService.cs | 16 ++++ .../Services/Implementations/UserService.cs | 9 +++ src/Identity/Controllers/AccountController.cs | 66 ++++++++++------ src/Identity/Startup.cs | 1 + ...rganizationUserOrganizationDetailsView.sql | 3 +- .../Controllers/AccountsControllerTests.cs | 25 +++--- .../Services/OrganizationServiceTests.cs | 6 +- .../2020-08-19_00_AddIdentifierToOrgView.sql | 39 ++++++++++ 18 files changed, 214 insertions(+), 64 deletions(-) create mode 100644 util/Migrator/DbScripts/2020-08-19_00_AddIdentifierToOrgView.sql diff --git a/.gitignore b/.gitignore index 87e265a53..c67e67eca 100644 --- a/.gitignore +++ b/.gitignore @@ -206,4 +206,6 @@ src/Core/Properties/launchSettings.json *.override.env **/*.DS_Store src/Admin/wwwroot/lib -src/Admin/wwwroot/css \ No newline at end of file +src/Admin/wwwroot/css +.vscode/* +**/.vscode/* diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index 36c63e9cb..45a5dd762 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -1,21 +1,22 @@ -using System; -using System.Threading.Tasks; +using Bit.Api.Utilities; +using Bit.Core; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Api; +using Bit.Core.Models.Api.Request.Accounts; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Models.Table; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Bit.Core.Models.Api; -using Bit.Core.Exceptions; -using Bit.Core.Services; -using Bit.Core.Enums; -using System.Linq; -using Bit.Core.Repositories; -using Bit.Core.Utilities; -using Bit.Core; -using Bit.Core.Models.Business; -using Bit.Api.Utilities; -using Bit.Core.Models.Table; +using System; using System.Collections.Generic; -using Bit.Core.Models.Api.Request.Accounts; -using Bit.Core.Models.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; namespace Bit.Api.Controllers { @@ -23,30 +24,34 @@ namespace Bit.Api.Controllers [Authorize("Application")] public class AccountsController : Controller { - private readonly IUserService _userService; - private readonly IUserRepository _userRepository; + private readonly GlobalSettings _globalSettings; private readonly ICipherRepository _cipherRepository; private readonly IFolderRepository _folderRepository; + private readonly IOrganizationService _organizationService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPaymentService _paymentService; - private readonly GlobalSettings _globalSettings; + private readonly IUserRepository _userRepository; + private readonly IUserService _userService; public AccountsController( - IUserService userService, - IUserRepository userRepository, + GlobalSettings globalSettings, ICipherRepository cipherRepository, IFolderRepository folderRepository, + IOrganizationService organizationService, IOrganizationUserRepository organizationUserRepository, IPaymentService paymentService, - GlobalSettings globalSettings) + ISsoUserRepository ssoUserRepository, + IUserRepository userRepository, + IUserService userService) { - _userService = userService; - _userRepository = userRepository; _cipherRepository = cipherRepository; _folderRepository = folderRepository; + _globalSettings = globalSettings; + _organizationService = organizationService; _organizationUserRepository = organizationUserRepository; _paymentService = paymentService; - _globalSettings = globalSettings; + _userRepository = userRepository; + _userService = userService; } [HttpPost("prelogin")] @@ -195,7 +200,7 @@ namespace Bit.Api.Controllers await Task.Delay(2000); throw new BadRequestException(ModelState); } - + [HttpPost("set-password")] public async Task PostSetPasswordAsync([FromBody]SetPasswordRequestModel model) { @@ -708,5 +713,27 @@ namespace Bit.Api.Controllers }; await _paymentService.SaveTaxInfoAsync(user, taxInfo); } + + [HttpDelete("sso/{organizationId}")] + public async Task DeleteSsoUser(string organizationId) + { + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + await _organizationService.DeleteSsoUserAsync(userId.Value, new Guid(organizationId)); + } + + [HttpGet("sso/user-identifier")] + public async Task GetSsoUserIdentifier() + { + var user = await _userService.GetUserByPrincipalAsync(User); + var token = await _userService.GenerateSignInTokenAsync(user, TokenPurposes.LinkSso); + var bytes = Encoding.UTF8.GetBytes($"{user.Id},{token}"); + var userIdentifier = Convert.ToBase64String(bytes); + return userIdentifier; + } } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index bb507baa7..31e04baa8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -4,4 +4,9 @@ { public const int BypassFiltersEventId = 12482444; } + + public static class TokenPurposes + { + public const string LinkSso = "LinkSso"; + } } diff --git a/src/Core/Enums/EventType.cs b/src/Core/Enums/EventType.cs index e2e2088f7..5181808ec 100644 --- a/src/Core/Enums/EventType.cs +++ b/src/Core/Enums/EventType.cs @@ -42,6 +42,7 @@ OrganizationUser_Updated = 1502, OrganizationUser_Removed = 1503, OrganizationUser_UpdatedGroups = 1504, + OrganizationUser_UnlinkedSso = 1505, Organization_Updated = 1600, Organization_PurgedVault = 1601, diff --git a/src/Core/Models/Api/Response/ProfileOrganizationResponseModel.cs b/src/Core/Models/Api/Response/ProfileOrganizationResponseModel.cs index 40f43c721..6bc4b3d9a 100644 --- a/src/Core/Models/Api/Response/ProfileOrganizationResponseModel.cs +++ b/src/Core/Models/Api/Response/ProfileOrganizationResponseModel.cs @@ -28,6 +28,7 @@ namespace Bit.Core.Models.Api Type = organization.Type; Enabled = organization.Enabled; SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId); + Identifier = organization.Identifier; } public string Id { get; set; } @@ -51,5 +52,6 @@ namespace Bit.Core.Models.Api public OrganizationUserType Type { get; set; } public bool Enabled { get; set; } public bool SsoBound { get; set; } + public string Identifier { get; set; } } } diff --git a/src/Core/Models/Data/OrganizationUserOrganizationDetails.cs b/src/Core/Models/Data/OrganizationUserOrganizationDetails.cs index 5c365719e..18728a239 100644 --- a/src/Core/Models/Data/OrganizationUserOrganizationDetails.cs +++ b/src/Core/Models/Data/OrganizationUserOrganizationDetails.cs @@ -25,5 +25,6 @@ namespace Bit.Core.Models.Data public Enums.OrganizationUserType Type { get; set; } public bool Enabled { get; set; } public string SsoExternalId { get; set; } + public string Identifier { get; set; } } } diff --git a/src/Core/Repositories/ISsoUserRepository.cs b/src/Core/Repositories/ISsoUserRepository.cs index 6a3f0bee2..510c7982c 100644 --- a/src/Core/Repositories/ISsoUserRepository.cs +++ b/src/Core/Repositories/ISsoUserRepository.cs @@ -1,8 +1,11 @@ -using Bit.Core.Models.Table; +using Bit.Core.Models.Table; +using System; +using System.Threading.Tasks; namespace Bit.Core.Repositories { public interface ISsoUserRepository : IRepository { + Task DeleteAsync(Guid userId, Guid? organizationId); } } diff --git a/src/Core/Repositories/SqlServer/SsoUserRepository.cs b/src/Core/Repositories/SqlServer/SsoUserRepository.cs index 007fbcaf8..5aff5a78a 100644 --- a/src/Core/Repositories/SqlServer/SsoUserRepository.cs +++ b/src/Core/Repositories/SqlServer/SsoUserRepository.cs @@ -1,4 +1,9 @@ using Bit.Core.Models.Table; +using Dapper; +using System; +using System.Threading.Tasks; +using System.Data.SqlClient; +using System.Data; namespace Bit.Core.Repositories.SqlServer { @@ -11,5 +16,16 @@ namespace Bit.Core.Repositories.SqlServer public SsoUserRepository(string connectionString, string readOnlyConnectionString) : base(connectionString, readOnlyConnectionString) { } + + public async Task DeleteAsync(Guid userId, Guid? organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteAsync( + $"[{Schema}].[SsoUser_Delete]", + new { UserId = userId, OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + } + } } } diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 38d739156..5aef10fb1 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -51,5 +51,6 @@ namespace Bit.Core.Services IEnumerable newUsers, IEnumerable removeUserExternalIds, bool overwriteExisting); Task RotateApiKeyAsync(Organization organization); + Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); } } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 58201554f..195c3cf26 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -68,5 +68,6 @@ namespace Bit.Core.Services Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user); Task TwoFactorProviderIsEnabledAsync(TwoFactorProviderType provider, ITwoFactorProvidersUser user); Task GenerateEnterprisePortalSignInTokenAsync(User user); + Task GenerateSignInTokenAsync(User user, string purpose); } } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 0c21ec67a..9bff8599c 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -35,6 +35,7 @@ namespace Bit.Core.Services private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly ISsoUserRepository _ssoUserRepository; private readonly IReferenceEventService _referenceEventService; private readonly GlobalSettings _globalSettings; @@ -56,6 +57,7 @@ namespace Bit.Core.Services IPaymentService paymentService, IPolicyRepository policyRepository, ISsoConfigRepository ssoConfigRepository, + ISsoUserRepository ssoUserRepository, IReferenceEventService referenceEventService, GlobalSettings globalSettings) { @@ -76,6 +78,7 @@ namespace Bit.Core.Services _paymentService = paymentService; _policyRepository = policyRepository; _ssoConfigRepository = ssoConfigRepository; + _ssoUserRepository = ssoUserRepository; _referenceEventService = referenceEventService; _globalSettings = globalSettings; } @@ -1497,6 +1500,19 @@ namespace Bit.Core.Services await ReplaceAndUpdateCache(organization); } + public async Task DeleteSsoUserAsync(Guid userId, Guid? organizationId) + { + await _ssoUserRepository.DeleteAsync(userId, organizationId); + if (organizationId.HasValue) + { + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId.Value, userId); + if (organizationUser != null) + { + await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UnlinkedSso); + } + } + } + private async Task UpdateUsersAsync(Group group, HashSet groupUsers, Dictionary existingUsersIdDict, HashSet existingUsers = null) { diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index a7c19eef3..61da89491 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1087,6 +1087,7 @@ namespace Bit.Core.Services return await CanAccessPremium(user); } + //TODO refactor this to use the below method and enum public async Task GenerateEnterprisePortalSignInTokenAsync(User user) { var token = await GenerateUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, @@ -1094,6 +1095,14 @@ namespace Bit.Core.Services return token; } + + public async Task GenerateSignInTokenAsync(User user, string purpose) + { + var token = await GenerateUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, + purpose); + return token; + } + private async Task UpdatePasswordHash(User user, string newPassword, bool validatePassword = true, bool refreshStamp = true) { diff --git a/src/Identity/Controllers/AccountController.cs b/src/Identity/Controllers/AccountController.cs index ff38f0acc..741dc3bb2 100644 --- a/src/Identity/Controllers/AccountController.cs +++ b/src/Identity/Controllers/AccountController.cs @@ -1,62 +1,75 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Claims; -using System.Threading.Tasks; -using Bit.Core.Models.Table; +using Bit.Core.Models.Table; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Identity.Models; using IdentityModel; using IdentityServer4; using IdentityServer4.Services; using IdentityServer4.Stores; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; namespace Bit.Identity.Controllers { public class AccountController : Controller { - private readonly IIdentityServerInteractionService _interaction; - private readonly IUserRepository _userRepository; - private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IClientStore _clientStore; + private readonly IIdentityServerInteractionService _interaction; private readonly ILogger _logger; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IUserRepository _userRepository; public AccountController( - IIdentityServerInteractionService interaction, - IUserRepository userRepository, - ISsoConfigRepository ssoConfigRepository, IClientStore clientStore, - ILogger logger) + IIdentityServerInteractionService interaction, + ILogger logger, + IOrganizationUserRepository organizationUserRepository, + ISsoConfigRepository ssoConfigRepository, + IUserRepository userRepository, + IUserService userService) { - _interaction = interaction; - _userRepository = userRepository; - _ssoConfigRepository = ssoConfigRepository; _clientStore = clientStore; + _interaction = interaction; _logger = logger; + _ssoConfigRepository = ssoConfigRepository; + _userRepository = userRepository; } [HttpGet] public async Task Login(string returnUrl) { var context = await _interaction.GetAuthorizationContextAsync(returnUrl); - if (context.Parameters.AllKeys.Contains("domain_hint") && - !string.IsNullOrWhiteSpace(context.Parameters["domain_hint"])) + + var domainHint = context.Parameters.AllKeys.Contains("domain_hint") ? + context.Parameters["domain_hint"] : null; + + if (string.IsNullOrWhiteSpace(domainHint)) { - return RedirectToAction(nameof(ExternalChallenge), - new { organizationIdentifier = context.Parameters["domain_hint"], returnUrl = returnUrl }); + throw new Exception("No domain_hint provided"); } - else + + var userIdentifier = context.Parameters.AllKeys.Contains("user_identifier") ? + context.Parameters["user_identifier"] : null; + + return RedirectToAction(nameof(ExternalChallenge), new { - throw new Exception("No domain_hint provided."); - } + organizationIdentifier = domainHint, + returnUrl, + userIdentifier + }); } [HttpGet] - public async Task ExternalChallenge(string organizationIdentifier, string returnUrl) + public async Task ExternalChallenge(string organizationIdentifier, string returnUrl, + string userIdentifier) { if (string.IsNullOrWhiteSpace(organizationIdentifier)) { @@ -82,6 +95,11 @@ namespace Bit.Identity.Controllers }, }; + if (!string.IsNullOrWhiteSpace(userIdentifier)) + { + props.Items.Add("user_identifier", userIdentifier); + } + return Challenge(props, scheme); } diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 119a0ac40..b0672820a 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -100,6 +100,7 @@ namespace Bit.Identity { // Pass domain_hint onto the sso idp context.ProtocolMessage.DomainHint = context.Properties.Items["domain_hint"]; + context.ProtocolMessage.SessionState = context.Properties.Items["user_identifier"]; return Task.FromResult(0); } }; diff --git a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql index efa273679..07ed2d73f 100644 --- a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql +++ b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql @@ -18,6 +18,7 @@ SELECT O.[Seats], O.[MaxCollections], O.[MaxStorageGb], + O.[Identifier], OU.[Key], OU.[Status], OU.[Type], @@ -27,4 +28,4 @@ FROM INNER JOIN [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] LEFT JOIN - [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId] \ No newline at end of file + [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId] diff --git a/test/Api.Test/Controllers/AccountsControllerTests.cs b/test/Api.Test/Controllers/AccountsControllerTests.cs index 14ca7987e..8d083d6c1 100644 --- a/test/Api.Test/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Controllers/AccountsControllerTests.cs @@ -1,6 +1,3 @@ -using System; -using System.Security.Claims; -using System.Threading.Tasks; using Bit.Api.Controllers; using Bit.Core; using Bit.Core.Enums; @@ -12,21 +9,26 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Identity; using NSubstitute; +using System; +using System.Security.Claims; +using System.Threading.Tasks; using Xunit; namespace Bit.Api.Test.Controllers { public class AccountsControllerTests : IDisposable { - private readonly AccountsController _sut; - private readonly IUserService _userService; - private readonly IUserRepository _userRepository; + private readonly AccountsController _sut; + private readonly GlobalSettings _globalSettings; private readonly ICipherRepository _cipherRepository; private readonly IFolderRepository _folderRepository; + private readonly IOrganizationService _organizationService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPaymentService _paymentService; - private readonly GlobalSettings _globalSettings; + private readonly ISsoUserRepository _ssoUserRepository; + private readonly IUserRepository _userRepository; + private readonly IUserService _userService; public AccountsControllerTests() { @@ -34,17 +36,20 @@ namespace Bit.Api.Test.Controllers _userRepository = Substitute.For(); _cipherRepository = Substitute.For(); _folderRepository = Substitute.For(); + _organizationService = Substitute.For(); _organizationUserRepository = Substitute.For(); _paymentService = Substitute.For(); _globalSettings = new GlobalSettings(); _sut = new AccountsController( - _userService, - _userRepository, + _globalSettings, _cipherRepository, _folderRepository, + _organizationService, _organizationUserRepository, _paymentService, - _globalSettings + _ssoUserRepository, + _userRepository, + _userService ); } diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index 99ae38369..7856053c8 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -33,13 +33,14 @@ namespace Bit.Core.Test.Services var paymentService = Substitute.For(); var policyRepo = Substitute.For(); var ssoConfigRepo = Substitute.For(); + var ssoUserRepo = Substitute.For(); var referenceEventService = Substitute.For(); var globalSettings = Substitute.For(); var orgService = new OrganizationService(orgRepo, orgUserRepo, collectionRepo, userRepo, groupRepo, dataProtector, mailService, pushNotService, pushRegService, deviceRepo, licenseService, eventService, installationRepo, appCacheService, paymentService, policyRepo, - ssoConfigRepo, referenceEventService, globalSettings); + ssoConfigRepo, ssoUserRepo, referenceEventService, globalSettings); var id = Guid.NewGuid(); var userId = Guid.NewGuid(); @@ -93,13 +94,14 @@ namespace Bit.Core.Test.Services var paymentService = Substitute.For(); var policyRepo = Substitute.For(); var ssoConfigRepo = Substitute.For(); + var ssoUserRepo = Substitute.For(); var referenceEventService = Substitute.For(); var globalSettings = Substitute.For(); var orgService = new OrganizationService(orgRepo, orgUserRepo, collectionRepo, userRepo, groupRepo, dataProtector, mailService, pushNotService, pushRegService, deviceRepo, licenseService, eventService, installationRepo, appCacheService, paymentService, policyRepo, - ssoConfigRepo, referenceEventService, globalSettings); + ssoConfigRepo, ssoUserRepo, referenceEventService, globalSettings); var id = Guid.NewGuid(); var userId = Guid.NewGuid(); diff --git a/util/Migrator/DbScripts/2020-08-19_00_AddIdentifierToOrgView.sql b/util/Migrator/DbScripts/2020-08-19_00_AddIdentifierToOrgView.sql new file mode 100644 index 000000000..4b4975816 --- /dev/null +++ b/util/Migrator/DbScripts/2020-08-19_00_AddIdentifierToOrgView.sql @@ -0,0 +1,39 @@ +IF EXISTS(SELECT * FROM sys.views WHERE [Name] = 'OrganizationUserOrganizationDetailsView') +BEGIN + DROP VIEW [dbo].[OrganizationUserOrganizationDetailsView]; +END +GO + +CREATE VIEW [dbo].[OrganizationUserOrganizationDetailsView] +AS +SELECT + OU.[UserId], + OU.[OrganizationId], + O.[Name], + O.[Enabled], + O.[UsePolicies], + O.[UseSso], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[SelfHost], + O.[UsersGetPremium], + O.[Seats], + O.[MaxCollections], + O.[MaxStorageGb], + O.[Identifier], + OU.[Key], + OU.[Status], + OU.[Type], + SU.[ExternalId] SsoExternalId +FROM + [dbo].[OrganizationUser] OU +INNER JOIN + [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] +LEFT JOIN + [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId] + +GO