diff --git a/src/Controllers/AnalyzeModelController.cs b/src/Controllers/AnalyzeModelController.cs index 74b2a776..47f19e84 100644 --- a/src/Controllers/AnalyzeModelController.cs +++ b/src/Controllers/AnalyzeModelController.cs @@ -15,10 +15,12 @@ [Route("api/[action]")] [ApiController] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ProblemDetails))] - public class AnalyzeModelController : ControllerBase + public class AnalyzeModelController( + IAnalyzeModelService analyzeModelService, + IAuthenticationService authenticationService) : ControllerBase { - private readonly IAnalyzeModelService _analyzeModelService; - private readonly IAuthenticationService _authenticationService; + private readonly IAnalyzeModelService _analyzeModelService = analyzeModelService; + private readonly IAuthenticationService _authenticationService = authenticationService; private readonly SaveFileDialog _exportVpaxDialog = new() { Title = "Save VPAX", @@ -30,12 +32,6 @@ public class AnalyzeModelController : ControllerBase ValidateNames = true }; - public AnalyzeModelController(IAnalyzeModelService analyzeModelService, IAuthenticationService authenticationService) - { - _analyzeModelService = analyzeModelService; - _authenticationService = authenticationService; - } - /// /// Returns a database model from the VPAX file provided as multipart form data. /// An optional obfuscation dictionary file can be included to deobfuscate the VPAX. @@ -85,10 +81,11 @@ public IActionResult GetDatabase(PBIDesktopReport report, CancellationToken canc [ProducesDefaultResponseType] public async Task GetDatabase(PBICloudDataset dataset, CancellationToken cancellationToken) { - if (await _authenticationService.IsPBICloudSignInRequiredAsync(cancellationToken)) + var session = await _authenticationService.EnsureSignedInAsync(cancellationToken); + if (session is null) return Unauthorized(); - var database = _analyzeModelService.GetDatabase(dataset, _authenticationService.PBICloudAuthentication.AccessToken, cancellationToken); + var database = _analyzeModelService.GetDatabase(dataset, session.AuthenticationResult.AccessToken, cancellationToken); return Ok(database); } @@ -105,10 +102,11 @@ public async Task GetDatabase(PBICloudDataset dataset, Cancellati [ProducesDefaultResponseType] public async Task GetDatasets(CancellationToken cancellationToken) { - if (await _authenticationService.IsPBICloudSignInRequiredAsync(cancellationToken)) + var session = await _authenticationService.EnsureSignedInAsync(cancellationToken); + if (session is null) return Unauthorized(); - var datasets = await _analyzeModelService.GetDatasetsAsync(cancellationToken); + var datasets = await _analyzeModelService.GetDatasetsAsync(session, cancellationToken); return Ok(datasets); } @@ -202,12 +200,13 @@ public async Task ExportVpax(PBICloudDataset dataset, Cancellatio if (dialogResult != DialogResult.OK) return NoContent(); - if (await _authenticationService.IsPBICloudSignInRequiredAsync(cancellationToken)) + var session = await _authenticationService.EnsureSignedInAsync(cancellationToken); + if (session is null) return Unauthorized(); var path = _exportVpaxDialog.FileName!; var mode = _exportVpaxDialog.FilterIndex == 1 ? ExportVpaxMode.Default : ExportVpaxMode.Obfuscated; - var accessToken = _authenticationService.PBICloudAuthentication.AccessToken; + var accessToken = session.AuthenticationResult.AccessToken; _analyzeModelService.ExportVpax(dataset, accessToken, mode, path, cancellationToken); return Ok(); diff --git a/src/Controllers/AuthenticationController.cs b/src/Controllers/AuthenticationController.cs index 3cc6ac6a..0009f65c 100644 --- a/src/Controllers/AuthenticationController.cs +++ b/src/Controllers/AuthenticationController.cs @@ -2,7 +2,9 @@ { using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; - using Sqlbi.Bravo.Infrastructure.Services.PowerBI; + using Sqlbi.Bravo.Infrastructure; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; + using Sqlbi.Bravo.Models; using Sqlbi.Bravo.Models.Authentication; using Sqlbi.Bravo.Services; @@ -14,13 +16,11 @@ [ApiController] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ProblemDetails))] public sealed class AuthenticationController( - IPBICloudAuthenticationService pbicloudAuthenticationService, - IPBICloudService pbicloudService, + ICloudApiClient cloudApiClient, IAuthenticationService authenticationService) : ControllerBase { private readonly IAuthenticationService _authenticationService = authenticationService; - private readonly IPBICloudAuthenticationService _pbicloudAuthenticationService = pbicloudAuthenticationService; - private readonly IPBICloudService _pbicloudService = pbicloudService; + private readonly ICloudApiClient _cloudApiClient = cloudApiClient; /// /// Returns the list of available PowerBI cloud environments for the specified email account. @@ -35,10 +35,13 @@ public async Task GetEnvironmentsAsync( [FromQuery] GetEnvironmentsRequest request, CancellationToken cancellationToken) { - var environments = await _pbicloudAuthenticationService.GetEnvironmentsAsync( + var environments = await _authenticationService.GetEnvironmentsAsync( request.Email, cancellationToken); + if (AppEnvironment.IsDiagnosticLevelVerbose) + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{nameof(AuthenticationController)}.{nameof(GetEnvironmentsAsync)}", JsonSerializer.Serialize(environments)); + var response = new GetEnvironmentsResponse(environments); return Ok(response); } @@ -56,12 +59,12 @@ public async Task SignInAsync( SignInRequest request, CancellationToken cancellationToken) { - await _authenticationService.PBICloudSignInAsync( + var session = await _authenticationService.SignInAsync( request.Email, request.Environment.ToModel(), cancellationToken); - var response = new SignInResponse(_authenticationService.PBICloudAuthentication.Account); + var response = new SignInResponse(session.AuthenticationResult); return Ok(response); } @@ -75,7 +78,7 @@ await _authenticationService.PBICloudSignInAsync( [ProducesDefaultResponseType] public async Task SignOutAsync(CancellationToken cancellationToken) { - await _authenticationService.PBICloudSignOutAsync(cancellationToken); + await _authenticationService.SignOutAsync(cancellationToken); return Ok(); } @@ -94,10 +97,11 @@ public async Task SignOutAsync(CancellationToken cancellationToke [ProducesDefaultResponseType] public async Task GetUserAvatarAsync(CancellationToken cancellationToken) { - if (await _authenticationService.IsPBICloudSignInRequiredAsync(cancellationToken)) + var session = await _authenticationService.EnsureSignedInAsync(cancellationToken); + if (session is null) return Unauthorized(); - var avatar = await _pbicloudService.GetAccountAvatarAsync(); + var avatar = await _cloudApiClient.GetUserPhotoAsync(session, cancellationToken); if (avatar is null) return NotFound(); diff --git a/src/Controllers/ExportDataController.cs b/src/Controllers/ExportDataController.cs index a2247bad..10e1e079 100644 --- a/src/Controllers/ExportDataController.cs +++ b/src/Controllers/ExportDataController.cs @@ -75,7 +75,8 @@ public IActionResult ExportDelimitedTextFile(ExportDelimitedTextFromPBIReportReq [ProducesDefaultResponseType] public async Task ExportDelimitedTextFile(ExportDelimitedTextFromPBICloudDatasetRequest request, CancellationToken cancellationToken) { - if (await _authenticationService.IsPBICloudSignInRequiredAsync(cancellationToken)) + var session = await _authenticationService.EnsureSignedInAsync(cancellationToken); + if (session is null) return Unauthorized(); if (WindowDialogHelper.BrowseFolderDialog(out var path, cancellationToken)) @@ -86,7 +87,7 @@ public async Task ExportDelimitedTextFile(ExportDelimitedTextFrom return NoContent(); } - var job = _exportDataService.ExportDelimitedTextFile(request.Dataset!, request.Settings!, path, _authenticationService.PBICloudAuthentication.AccessToken, cancellationToken); + var job = _exportDataService.ExportDelimitedTextFile(request.Dataset!, request.Settings!, path, session.AuthenticationResult.AccessToken, cancellationToken); return Ok(job); } @@ -130,12 +131,13 @@ public IActionResult ExportExcelFile(ExportExcelFromPBIReportRequest request, Ca [ProducesDefaultResponseType] public async Task ExportExcelFile(ExportExcelFromPBICloudDatasetRequest request, CancellationToken cancellationToken) { - if (await _authenticationService.IsPBICloudSignInRequiredAsync(cancellationToken)) + var session = await _authenticationService.EnsureSignedInAsync(cancellationToken); + if (session is null) return Unauthorized(); if (WindowDialogHelper.SaveFileDialog(fileName: request.Dataset!.DisplayName, filter: null, defaultExt: "XLSX", out var path, cancellationToken)) { - var job = _exportDataService.ExportExcelFile(request.Dataset, request.Settings!, path, _authenticationService.PBICloudAuthentication.AccessToken, cancellationToken); + var job = _exportDataService.ExportExcelFile(request.Dataset, request.Settings!, path, session.AuthenticationResult.AccessToken, cancellationToken); return Ok(job); } diff --git a/src/Controllers/FormatDaxController.cs b/src/Controllers/FormatDaxController.cs index e9f721f9..9a30f9b9 100644 --- a/src/Controllers/FormatDaxController.cs +++ b/src/Controllers/FormatDaxController.cs @@ -81,10 +81,11 @@ public IActionResult Update(UpdatePBIDesktopReportRequest request) [ProducesDefaultResponseType] public async Task Update(UpdatePBICloudDatasetRequest request, CancellationToken cancellationToken) { - if (await _authenticationService.IsPBICloudSignInRequiredAsync(cancellationToken)) + var session = await _authenticationService.EnsureSignedInAsync(cancellationToken); + if (session is null) return Unauthorized(); - var updateResult = _formatDaxService.Update(request.Dataset!, request.Measures!, _authenticationService.PBICloudAuthentication.AccessToken); + var updateResult = _formatDaxService.Update(request.Dataset!, request.Measures!, session.AuthenticationResult.AccessToken); return Ok(updateResult); } } diff --git a/src/GlobalSuppressions.cs b/src/GlobalSuppressions.cs index cda654a1..6dee1885 100644 --- a/src/GlobalSuppressions.cs +++ b/src/GlobalSuppressions.cs @@ -73,3 +73,4 @@ [assembly: SuppressMessage("Style", "IDE0042:Deconstruct variable declaration", Justification = "", Scope = "member", Target = "~M:Sqlbi.Bravo.Models.ManageDates.DateConfiguration.CreateFrom(Dax.Template.Package)~Sqlbi.Bravo.Models.ManageDates.DateConfiguration")] [assembly: SuppressMessage("Style", "IDE0042:Deconstruct variable declaration", Justification = "", Scope = "member", Target = "~M:Sqlbi.Bravo.Models.ManageDates.DateConfiguration.CopyTo(Dax.Template.Tables.TemplateConfiguration)")] [assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "", Scope = "member", Target = "~M:Sqlbi.Bravo.Infrastructure.AppWindow.WebViewLog(System.String)")] +[assembly: SuppressMessage("Style", "IDE0290:Use primary constructor", Justification = "", Scope = "member", Target = "~M:Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Configuration.CloudConfigurationService.#ctor(System.Net.Http.IHttpClientFactory,Sqlbi.Bravo.Infrastructure.PowerBI.ILocalConfigurationReader)")] diff --git a/src/Infrastructure/Contracts/PBIConstants.cs b/src/Infrastructure/Contracts/PBIConstants.cs deleted file mode 100644 index 45b37808..00000000 --- a/src/Infrastructure/Contracts/PBIConstants.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts -{ - internal static class PBIConstants - { - public static class Endpoints - { - public static readonly Uri GlobalCloudPowerBIUri = new("https://api.powerbi.com", UriKind.Absolute); - } - - public static class Registry - { - public const string PowerBIDiscoveryUrlValueName = "PowerBIDiscoveryUrl"; - public const string PowerBISubkeyName = @"SOFTWARE\Microsoft\Microsoft Power BI\"; - public const string PowerBIPolicySubkeyName = @"SOFTWARE\Policies\Microsoft\Microsoft Power BI\"; - } - } -} diff --git a/src/Infrastructure/Contracts/PBIDesktop/LocalClientSite.cs b/src/Infrastructure/Contracts/PBIDesktop/LocalClientSite.cs deleted file mode 100644 index 321acd67..00000000 --- a/src/Infrastructure/Contracts/PBIDesktop/LocalClientSite.cs +++ /dev/null @@ -1,115 +0,0 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBIDesktop -{ - using Sqlbi.Bravo.Infrastructure.Extensions; - using System; - using System.Collections.Generic; - using System.Diagnostics.CodeAnalysis; - using System.IO; - using System.IO.Compression; - using System.Linq; - using System.Xml.Linq; - - internal class LocalClientSite - { - public string? Url { get; init; } - - public string? Version { get; init; } - - public string? UserPrincipalName { get; init; } - - public string? DisplayName { get; init; } - - public string? Avatar { get; init; } - - internal static LocalClientSite CreateFrom(XElement element) - { - var site = new LocalClientSite - { - Url = element.Attribute("Url")?.Value, - Version = element.Attribute("Version")?.Value, - UserPrincipalName = element.Element("User")?.Value.NullIfWhiteSpace(), - DisplayName = element.Element("DisplayName")?.Value.NullIfWhiteSpace(), - Avatar = element.Element("Avatar")?.Value.NullIfWhiteSpace(), - }; - - return site; - } - } - - internal class LocalClientSites - { - private static readonly string LocalDataClassicAppCachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify), "Microsoft\\Power BI Desktop"); - private static readonly string LocalDataStoreAppCachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify), "Microsoft\\Power BI Desktop Store App"); - private const string UserCacheFile = "User.zip"; - - private readonly List _sites; - - private LocalClientSites(IEnumerable sites) - { - _sites = sites.ToList(); - } - - public LocalClientSite? Find(Uri url, string? upn) - { - if (upn is not null) - { - return _sites.FirstOrDefault((site) => url.AbsoluteUri.Equals(site.Url, StringComparison.OrdinalIgnoreCase) && upn.Equals(site.UserPrincipalName, StringComparison.OrdinalIgnoreCase)); - } - - return null; - } - - public static LocalClientSites? Create() - { - var classicAppCacheFile = new FileInfo(fileName: Path.Combine(LocalDataClassicAppCachePath, UserCacheFile)); - var storeAppCacheFile = new FileInfo(fileName: Path.Combine(LocalDataStoreAppCachePath, UserCacheFile)); - - if (classicAppCacheFile.Exists && storeAppCacheFile.Exists) - { - var lastWritedCacheFile = classicAppCacheFile.LastWriteTime >= storeAppCacheFile.LastWriteTime ? classicAppCacheFile : storeAppCacheFile; - - if (TryGetFrom(lastWritedCacheFile.FullName, out var sites)) - return sites; - } - - if (classicAppCacheFile.Exists) - { - if (TryGetFrom(classicAppCacheFile.FullName, out var sites)) - return sites; - } - - if (storeAppCacheFile.Exists) - { - if (TryGetFrom(storeAppCacheFile.FullName, out var sites)) - return sites; - } - - return new LocalClientSites(Array.Empty()); - - static bool TryGetFrom(string file, [NotNullWhen(true)] out LocalClientSites? sites) - { - using var archive = ZipFile.OpenRead(file); - var entry = archive.GetEntry("ClientAccess/ClientAccess.xml"); - - if (entry is not null) - { - using var reader = new StreamReader(entry.Open()); - var document = XDocument.Load(reader); - - var elements = document.Root?.Descendants("Sites").Descendants("Site"); - if (elements is not null) - { - var latestSupportedVersion = new Version("2.9.0.0"); - var items = elements.Select(LocalClientSite.CreateFrom).Where((s) => Version.TryParse(s.Version, out var version) && version >= latestSupportedVersion); - - sites = new LocalClientSites(items); - return true; - } - } - - sites = null; - return false; - } - } - } -} diff --git a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs deleted file mode 100644 index 2b0ba5a9..00000000 --- a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Sqlbi.Bravo.Infrastructure.Services.PowerBI; - -namespace Sqlbi.Bravo.Infrastructure.Extensions -{ - internal static class ServiceCollectionExtensions - { - //public static void AddSingletonIfNotRegistered(this IServiceCollection services) - // where TService : class - // where TImplementation : class, TService - //{ - // if (!services.Any(sd => sd.ServiceType == typeof(TService))) - // { - // services.AddSingleton(); - // } - //} - - public static IServiceCollection AddPBICloudServices(this IServiceCollection services) - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - return services; - } - } -} diff --git a/src/Infrastructure/Helpers/MsalHelper.cs b/src/Infrastructure/Helpers/MsalHelper.cs deleted file mode 100644 index 6b6740d9..00000000 --- a/src/Infrastructure/Helpers/MsalHelper.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace Sqlbi.Bravo.Infrastructure.Helpers -{ - using Microsoft.Identity.Client; - using Microsoft.Identity.Client.Desktop; - using Sqlbi.Bravo.Infrastructure.Configuration; - using Sqlbi.Bravo.Infrastructure.Models.PBICloud; - - internal static class MsalHelper - { - private const string SystemBrowserRedirectUri = "http://localhost"; - private const string MicrosoftAccountOnlyQueryParameter = "msafed=0"; // Restrict logins to only AAD based organizational accounts - - public static IPublicClientApplication CreatePublicClientApplication(CloudEnvironment environment) - { - var useEmbeddedBrowser = !UserPreferences.Current.UseSystemBrowserForAuthentication; - var redirectUri = (useEmbeddedBrowser ? environment.RedirectUri : SystemBrowserRedirectUri); - - // TODO: should we add logging .WithLogging() ?? - var builder = PublicClientApplicationBuilder.Create(environment.ClientId).WithAuthority(environment.AuthorityUri).WithRedirectUri(redirectUri); - { - if (useEmbeddedBrowser) - builder.WithWindowsEmbeddedBrowserSupport(); - } - var publicClient = builder.Build(); - - TokenCacheHelper.RegisterCache(publicClient.UserTokenCache); - - return publicClient; - } - - public static async Task AcquireTokenSilentAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken) - { - var extraQueryParameters = MicrosoftAccountOnlyQueryParameter; - var scopes = new string[] { $"{environment.ResourceId}/.default" }; - var loginHint = email; - - var publicClient = CreatePublicClientApplication(environment); - var msalAuthenticationResult = await publicClient.AcquireTokenSilent(scopes, loginHint).WithExtraQueryParameters(extraQueryParameters).ExecuteAsync(cancellationToken).ConfigureAwait(false); - var pbicloudAuthenticationResult = new PBICloudAuthenticationResult(msalAuthenticationResult); - - return pbicloudAuthenticationResult; - } - - public static async Task AcquireTokenInteractiveAsync(string email, CloudEnvironment environment, string claims, CancellationToken cancellationToken) - { - var useEmbeddedBrowser = !UserPreferences.Current.UseSystemBrowserForAuthentication; - var extraQueryParameters = MicrosoftAccountOnlyQueryParameter; - var prompt = Prompt.SelectAccount; // Force a sign-in as the MSAL web browser might contain cookies for the current user and we don't necessarily want to re-sign-in the same user - var scopes = new string[] { $"{environment.ResourceId}/.default" }; - var loginHint = email; - - var acquireTokenTask = ProcessHelper.RunOnUISynchronizationContextContext(async () => - { - var publicClient = CreatePublicClientApplication(environment); - var parameterBuilder = publicClient.AcquireTokenInteractive(scopes).WithExtraQueryParameters(extraQueryParameters).WithUseEmbeddedWebView(useEmbeddedBrowser).WithLoginHint(loginHint).WithPrompt(prompt).WithClaims(claims); - - if (useEmbeddedBrowser) - { - var mainwindowHwnd = ProcessHelper.GetCurrentProcessMainWindowHandle(); - parameterBuilder.WithParentActivityOrWindow(mainwindowHwnd); - } - - var msalAuthenticationResult = await parameterBuilder.ExecuteAsync(cancellationToken).ConfigureAwait(false); - return msalAuthenticationResult; - }); - - var msalAuthenticationResult = await acquireTokenTask.ConfigureAwait(false); - var pbicloudAuthenticationResult = new PBICloudAuthenticationResult(msalAuthenticationResult); - - return pbicloudAuthenticationResult; - } - - //public static async Task AcquireTokenByIntegratedWindowsAuthAsync(IPBICloudEnvironment environment, CancellationToken cancellationToken) - //{ - // var publicClient = CreatePublicClientApplication(environment); - // var msalAuthenticationResult = await publicClient.AcquireTokenByIntegratedWindowsAuth(environment.AzureADScopes).ExecuteAsync(cancellationToken).ConfigureAwait(false); - // var pbicloudAuthenticationResult = new PBICloudAuthenticationResult(msalAuthenticationResult); - - // // TODO: Assert UPN from local windows account is equals to UPN from authentication result - // // BravoUnexpectedException.Assert(authenticationResult.ClaimsPrincipal.Identity.Name == account.Username); - - // return pbicloudAuthenticationResult; - //} - - public static async Task ClearTokenCacheAsync(CloudEnvironment environment) - { - var publicClient = CreatePublicClientApplication(environment); - var cachedAccounts = (await publicClient.GetAccountsAsync().ConfigureAwait(false)).ToArray(); - - foreach (var account in cachedAccounts) - { - await publicClient.RemoveAsync(account).ConfigureAwait(false); - } - } - } -} diff --git a/src/Infrastructure/Helpers/NetworkHelper.cs b/src/Infrastructure/Helpers/NetworkHelper.cs index 71b20b68..a617c603 100644 --- a/src/Infrastructure/Helpers/NetworkHelper.cs +++ b/src/Infrastructure/Helpers/NetworkHelper.cs @@ -1,7 +1,7 @@ namespace Sqlbi.Bravo.Infrastructure.Helpers { using Sqlbi.Bravo.Infrastructure.Extensions; - using Sqlbi.Bravo.Infrastructure.Services.PowerBI; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; using Sqlbi.Bravo.Infrastructure.Windows.Interop; using System; using System.Collections.Generic; @@ -26,13 +26,13 @@ internal static class NetworkHelper public static readonly string LoopbackProxyBypassRule = "<-loopback>"; /// - /// Returns true if the protocol schema for the provided URI is + /// Returns true if the protocol schema for the provided URI is /// public static bool IsASAzureServer(string address) { if (address.Contains(Uri.SchemeDelimiter) && Uri.TryCreate(address, UriKind.Absolute, out var addressUri)) { - if (addressUri.Scheme.EqualsI(PBICloudService.ASAzureProtocolScheme)) + if (addressUri.Scheme.EqualsI(CloudApiClient.ASAzureProtocolScheme)) return true; } @@ -40,14 +40,14 @@ public static bool IsASAzureServer(string address) } /// - /// Returns true if the protocol schema for the provided URI is or + /// Returns true if the protocol schema for the provided URI is or /// public static bool IsPBICloudDatasetServer(string address) { if (address.Contains(Uri.SchemeDelimiter) && Uri.TryCreate(address, UriKind.Absolute, out var addressUri)) { - var isGenericDataset = addressUri.Scheme.EqualsI(PBICloudService.PBIDatasetProtocolScheme); - var isPremiumDataset = addressUri.Scheme.EqualsI(PBICloudService.PBIPremiumXmlaEndpointProtocolScheme); // <-- can be removed ?? + var isGenericDataset = addressUri.Scheme.EqualsI(CloudApiClient.PBIDatasetProtocolScheme); + var isPremiumDataset = addressUri.Scheme.EqualsI(CloudApiClient.PBIPremiumXmlaEndpointProtocolScheme); // <-- can be removed ?? return isPremiumDataset || isGenericDataset; } diff --git a/src/Infrastructure/Helpers/ProcessHelper.cs b/src/Infrastructure/Helpers/ProcessHelper.cs index 82c80ea4..b9bf5b0a 100644 --- a/src/Infrastructure/Helpers/ProcessHelper.cs +++ b/src/Infrastructure/Helpers/ProcessHelper.cs @@ -86,15 +86,20 @@ public static void InvokeOnUIThread(Action action, Control? control = null) } } - public static async Task RunOnUISynchronizationContextContext(Func> callback) + /// + /// Makes the ambient + /// while runs - it does not itself run anything on the UI thread, it only lets code + /// that reads the ambient context capture the right one. Keep synchronous - any + /// inside it would resume after this method has already restored the previous context. + /// + public static T RunWithUISynchronizationContext(Func callback) { var previousSynchronizationContext = SynchronizationContext.Current; SynchronizationContext.SetSynchronizationContext(AppWindow.UISynchronizationContext); try { - var result = await callback(); - return result; + return callback(); } finally { diff --git a/src/Infrastructure/Models/PBICloud/PBICloudAuthenticationResult.cs b/src/Infrastructure/Models/PBICloud/PBICloudAuthenticationResult.cs deleted file mode 100644 index 8f3bec95..00000000 --- a/src/Infrastructure/Models/PBICloud/PBICloudAuthenticationResult.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Sqlbi.Bravo.Infrastructure.Models.PBICloud -{ - using Microsoft.Identity.Client; - using Sqlbi.Bravo.Models; - - [DebuggerDisplay($"{{{ nameof(GetDebuggerDisplay) }(),nq}}")] - public sealed class PBICloudAuthenticationResult - { - private readonly AuthenticationResult _authenticationResult; - - public PBICloudAuthenticationResult(AuthenticationResult authenticationResult) - { - _authenticationResult = authenticationResult; - Account = new AppAccount(authenticationResult); - } - - public bool IsExpired => _authenticationResult.ExpiresOn < DateTimeOffset.UtcNow.AddMinutes(1); - - public string AccessToken => _authenticationResult.AccessToken; - - public AppAccount Account { get; private set; } - - private string GetDebuggerDisplay() - { - return _authenticationResult.Account.Username; - } - } -} diff --git a/src/Infrastructure/PowerBI/Cloud/Authentication/AuthenticatedSession.cs b/src/Infrastructure/PowerBI/Cloud/Authentication/AuthenticatedSession.cs new file mode 100644 index 00000000..ab9a195a --- /dev/null +++ b/src/Infrastructure/PowerBI/Cloud/Authentication/AuthenticatedSession.cs @@ -0,0 +1,15 @@ +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication +{ + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; + + /// + /// Represents an authenticated session with the Power BI cloud service, + /// containing the authentication result and the associated cloud environment. + /// + public sealed class AuthenticatedSession(AuthenticationResult authenticationResult, CloudEnvironment environment) + { + public AuthenticationResult AuthenticationResult { get; } = authenticationResult; + + public CloudEnvironment Environment { get; } = environment; + } +} diff --git a/src/Infrastructure/PowerBI/Cloud/Authentication/AuthenticationResult.cs b/src/Infrastructure/PowerBI/Cloud/Authentication/AuthenticationResult.cs new file mode 100644 index 00000000..7618263e --- /dev/null +++ b/src/Infrastructure/PowerBI/Cloud/Authentication/AuthenticationResult.cs @@ -0,0 +1,43 @@ +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication +{ + using Msal = Microsoft.Identity.Client; + + /// + /// Bravo-owned snapshot of an MSAL authentication result, decoupled from . + /// + [DebuggerDisplay("{Email} ({Name})")] + public sealed class AuthenticationResult + { + private static readonly TimeSpan ExpirationBuffer = TimeSpan.FromSeconds(30); + + private AuthenticationResult( + string accessToken, DateTimeOffset expiresOn, string tenantId, string userId, string identifier, string email, string name) + { + AccessToken = accessToken; + ExpiresOn = expiresOn; + TenantId = tenantId; + UserId = userId; + Identifier = identifier; + Email = email; + Name = name; + } + + public string AccessToken { get; } + public DateTimeOffset ExpiresOn { get; } + public string TenantId { get; } + public string UserId { get; } + public string Identifier { get; } + public string Email { get; } + public string Name { get; } + public bool IsExpired => ExpiresOn < DateTimeOffset.UtcNow.Add(ExpirationBuffer); + + public static AuthenticationResult From(Msal.AuthenticationResult msalResult) => new( + accessToken: msalResult.AccessToken, + expiresOn: msalResult.ExpiresOn, + tenantId: msalResult.TenantId, + userId: msalResult.UniqueId, + identifier: msalResult.Account.HomeAccountId.Identifier, + email: msalResult.Account.Username, + name: msalResult.ClaimsPrincipal.FindFirst((c) => c.Type == "name")?.Value ?? string.Empty); + } +} diff --git a/src/Infrastructure/PowerBI/Cloud/Authentication/CloudAuthenticationClient.cs b/src/Infrastructure/PowerBI/Cloud/Authentication/CloudAuthenticationClient.cs new file mode 100644 index 00000000..0c11f2cb --- /dev/null +++ b/src/Infrastructure/PowerBI/Cloud/Authentication/CloudAuthenticationClient.cs @@ -0,0 +1,126 @@ +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication +{ + using Microsoft.Identity.Client; + using Microsoft.Identity.Client.Desktop; + using Sqlbi.Bravo.Infrastructure.Configuration; + using Sqlbi.Bravo.Infrastructure.Helpers; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; + using Msal = Microsoft.Identity.Client; + + public interface ICloudAuthenticationClient + { + Task AcquireTokenAsync(CloudEnvironment environment, string email, CancellationToken cancellationToken); + + Task ClearTokenCacheAsync(CloudEnvironment environment); + } + + /// + /// Handles authentication with Microsoft Entra ID (Azure AD) using MSAL.NET, including token acquisition and cache management. + /// + internal sealed class CloudAuthenticationClient : ICloudAuthenticationClient + { + private const string SystemBrowserRedirectUri = "http://localhost"; + private const string OrganizationalAccountsOnlyQueryParameter = "msafed=0"; // no Microsoft accounts (MSA) allowed + + public async Task AcquireTokenAsync( + CloudEnvironment environment, string email, CancellationToken cancellationToken) + { + var client = CreatePublicClient(environment); + var scopes = CreateScopes(environment); + try + { + // TODO: no B2B/guest-tenant support yet - acquiring a token for a workspace hosted in a tenant other than the + // user's home tenant would require passing a tenantId here and calling WithTenantId(tenantId) on the builders. + var msalResult = await AcquireTokenSilentAsync(client, scopes, email, cancellationToken).ConfigureAwait(false); + return AuthenticationResult.From(msalResult); + } + // Catching MsalServiceException (not just its MsalUiRequiredException subclass) also covers Conditional + // Access claims challenges, which MSAL surfaces as a plain MsalServiceException with a non-empty Claims + // See https://learn.microsoft.com/entra/msal/dotnet/advanced/exceptions/#handling-claim-challenge-exceptions-in-msalnet + catch (MsalServiceException ex) + { + var msalResult = await AcquireTokenInteractiveAsync(client, scopes, email, ex.Claims, cancellationToken).ConfigureAwait(false); + return AuthenticationResult.From(msalResult); + } + } + + public async Task ClearTokenCacheAsync(CloudEnvironment environment) + { + var client = CreatePublicClient(environment); + var accounts = (await client.GetAccountsAsync().ConfigureAwait(false)).ToArray(); + + foreach (var account in accounts) + { + await client.RemoveAsync(account).ConfigureAwait(false); + } + } + + private static async Task AcquireTokenSilentAsync( + IPublicClientApplication client, string[] scopes, string email, CancellationToken cancellationToken) + { + var extraQueryParameters = OrganizationalAccountsOnlyQueryParameter; + var loginHint = email; + + var builder = client.AcquireTokenSilent(scopes, loginHint) + .WithExtraQueryParameters(extraQueryParameters); + + return await builder.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task AcquireTokenInteractiveAsync( + IPublicClientApplication client, string[] scopes, string email, string claims, CancellationToken cancellationToken) + { + var useEmbeddedBrowser = !UserPreferences.Current.UseSystemBrowserForAuthentication; + var extraQueryParameters = OrganizationalAccountsOnlyQueryParameter; + var prompt = Prompt.SelectAccount; + var loginHint = email; + + // AcquireTokenInteractive(scopes) captures SynchronizationContext.Current so the builder must be + // created on the UI thread to ensure that the interactive flow is executed on the UI thread. + var builder = ProcessHelper.RunWithUISynchronizationContext(() => + { + return client.AcquireTokenInteractive(scopes); + }); + + builder + .WithExtraQueryParameters(extraQueryParameters) + .WithUseEmbeddedWebView(useEmbeddedBrowser) + .WithLoginHint(loginHint) + .WithPrompt(prompt) + .WithClaims(claims); + + if (useEmbeddedBrowser) + { + var windowHandle = ProcessHelper.GetCurrentProcessMainWindowHandle(); + builder.WithParentActivityOrWindow(windowHandle); + } + + return await builder.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + + private static IPublicClientApplication CreatePublicClient(CloudEnvironment environment) + { + var useEmbeddedBrowser = !UserPreferences.Current.UseSystemBrowserForAuthentication; + var redirectUri = (useEmbeddedBrowser ? environment.RedirectUri : SystemBrowserRedirectUri); + + var builder = PublicClientApplicationBuilder.Create(environment.ClientId) + .WithAuthority(environment.AuthorityUri) + .WithRedirectUri(redirectUri); + + if (useEmbeddedBrowser) + builder.WithWindowsEmbeddedBrowserSupport(); + + var client = builder.Build(); + + TokenCacheHelper.RegisterCache(client.UserTokenCache); + + return client; + } + + private static string[] CreateScopes(CloudEnvironment environment) + { + var resource = environment.ResourceId.TrimEnd('/'); + return [$"{resource}/.default"]; + } + } +} diff --git a/src/Infrastructure/PowerBI/Cloud/Authentication/CloudAuthenticationService.cs b/src/Infrastructure/PowerBI/Cloud/Authentication/CloudAuthenticationService.cs new file mode 100644 index 00000000..a4f50684 --- /dev/null +++ b/src/Infrastructure/PowerBI/Cloud/Authentication/CloudAuthenticationService.cs @@ -0,0 +1,94 @@ +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication +{ + using Sqlbi.Bravo.Infrastructure; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Configuration; + using Sqlbi.Bravo.Models; + + public interface ICloudAuthenticationService + { + Task SignInAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken); + + Task EnsureSignedInAsync(CancellationToken cancellationToken); + + Task SignOutAsync(CancellationToken cancellationToken); + } + + /// + /// Orchestrates PBI Cloud sign-in/out and holds session state, delegating all MSAL work to . + /// + internal class CloudAuthenticationService( + ICloudAuthenticationClient cloudAuthenticationClient, + ICloudConfigurationService cloudConfigurationService) : ICloudAuthenticationService, IDisposable + { + private readonly ICloudAuthenticationClient _cloudAuthenticationClient = cloudAuthenticationClient; + private readonly ICloudConfigurationService _cloudConfigurationService = cloudConfigurationService; + private readonly SemaphoreSlim _authenticationSemaphore = new(1, 1); + + private AuthenticatedSession? _currentSession; + + public async Task SignInAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken) + { + await _authenticationSemaphore.WaitAsync(cancellationToken); + try + { + if (AppEnvironment.IsDiagnosticLevelVerbose) + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{nameof(CloudAuthenticationService)}.{nameof(SignInAsync)}", JsonSerializer.Serialize(environment)); + + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cancellationTokenSource.CancelAfter(TimeSpan.FromMinutes(2)); + + var authenticationResult = await _cloudAuthenticationClient.AcquireTokenAsync(environment, email, cancellationTokenSource.Token); + var clusterUri = await _cloudConfigurationService.ResolveTenantClusterUriAsync(environment, authenticationResult.AccessToken, cancellationTokenSource.Token); + + var newEnvironment = environment with { ClusterUri = clusterUri }; + var newSession = new AuthenticatedSession(authenticationResult, newEnvironment); + + return _currentSession = newSession; + } + finally + { + _authenticationSemaphore.Release(); + } + } + + public async Task EnsureSignedInAsync(CancellationToken cancellationToken) + { + var session = _currentSession; + if (session is null) + return null; + + if (session.AuthenticationResult.IsExpired) + return await SignInAsync(session.AuthenticationResult.Email, session.Environment, cancellationToken); + + return session; + } + + public async Task SignOutAsync(CancellationToken cancellationToken) + { + await _authenticationSemaphore.WaitAsync(cancellationToken); + try + { + if (_currentSession is not null) + { + await _cloudAuthenticationClient.ClearTokenCacheAsync(_currentSession.Environment); + } + + _currentSession = null; + } + finally + { + _authenticationSemaphore.Release(); + } + } + + #region IDisposable + + public void Dispose() + { + _authenticationSemaphore.Dispose(); + } + + #endregion + } +} diff --git a/src/Infrastructure/PowerBI/Cloud/CloudApiClient.cs b/src/Infrastructure/PowerBI/Cloud/CloudApiClient.cs new file mode 100644 index 00000000..44b5c98f --- /dev/null +++ b/src/Infrastructure/PowerBI/Cloud/CloudApiClient.cs @@ -0,0 +1,148 @@ +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud +{ + using Sqlbi.Bravo.Infrastructure; + using Sqlbi.Bravo.Infrastructure.Extensions; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts; + using Sqlbi.Bravo.Models; + using System.Drawing; + using System.Drawing.Imaging; + using System.Net.Http; + using System.Net.Http.Headers; + + public interface ICloudApiClient + { + Task GetUserPhotoAsync(AuthenticatedSession session, CancellationToken cancellationToken); + + Task> GetDatasetsAsync(AuthenticatedSession session, CancellationToken cancellationToken); + } + + internal class CloudApiClient : ICloudApiClient + { + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = false, // required by SharedDatasetModel LastRefreshTime/lastRefreshTime properties + }; + + public const string PBIDatasetProtocolScheme = "pbiazure"; + public const string PBIPremiumXmlaEndpointProtocolScheme = "powerbi"; + //public const string PBIPremiumDedicatedProtocolScheme = "pbidedicated"; + public const string ASAzureProtocolScheme = "asazure"; + //public const string ASAzureLinkProtocolScheme = "link"; + + public CloudApiClient(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient(ServiceCollectionExtensions.PowerBIApiHttpClientName); + } + + public async Task GetUserPhotoAsync(AuthenticatedSession session, CancellationToken cancellationToken) + { + var relativeUri = "powerbi/version/201606/resource/userPhoto/?userId={0}".FormatInvariant(session.AuthenticationResult.Email); + var requestUri = session.Environment.GetBackendRequestUri(relativeUri); + + using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AuthenticationResult.AccessToken); + + using var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken); + + // Any non-200 response (no photo, server error, ...) is treated the same way: no photo to show. + if (httpResponse.StatusCode != HttpStatusCode.OK) + return null; + + using var bitmapStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken); + using var bitmap = TryCreateBitmap(bitmapStream); + if (bitmap is null) + return null; // Invalid image data + + var mimeType = TryGetMimeType(bitmap); + if (mimeType is null) + return null; // Unknown image format + + var base64String = GetBase64String(bitmap); + + return "data:{0};base64,{1}".FormatInvariant(mimeType, base64String); + + static Bitmap? TryCreateBitmap(Stream stream) + { + try + { + return new Bitmap(stream); + } + catch (ArgumentException) + { + return null; + } + } + + static string? TryGetMimeType(Bitmap bitmap) + => ImageCodecInfo.GetImageDecoders().FirstOrDefault((c) => c.FormatID == bitmap.RawFormat.Guid)?.MimeType; + + static string GetBase64String(Bitmap bitmap) + { + using var stream = new MemoryStream(); + bitmap.Save(stream, bitmap.RawFormat); + return Convert.ToBase64String(stream.ToArray()); + } + } + + public async Task> GetDatasetsAsync(AuthenticatedSession session, CancellationToken cancellationToken) + { + var cloudWorkspaces = await GetCloudWorkspacesAsync(session, cancellationToken); + var cloudSharedModels = await GetCloudSharedModelsAsync(session, cancellationToken); + + if (AppEnvironment.IsDiagnosticLevelVerbose) + { + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(CloudApiClient) }.{ nameof(GetDatasetsAsync) }.{ nameof(cloudWorkspaces) }", content: JsonSerializer.Serialize(cloudWorkspaces)); + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(CloudApiClient) }.{ nameof(GetDatasetsAsync) }.{ nameof(cloudSharedModels) }", content: JsonSerializer.Serialize(cloudSharedModels)); + } + + var datasets = cloudWorkspaces.Join(cloudSharedModels, (w) => w.ObjectId?.ToLowerInvariant(), (d) => d.ObjectId?.ToLowerInvariant(), (w, d) => PBICloudDataset.CreateFrom(session.Environment, w, d)).ToArray(); + + if (AppEnvironment.IsDiagnosticLevelVerbose) + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(CloudApiClient) }.{ nameof(GetDatasetsAsync) }", content: JsonSerializer.Serialize(datasets)); + + return datasets; + } + + private async Task> GetCloudWorkspacesAsync(AuthenticatedSession session, CancellationToken cancellationToken) + { + var baseUri = new Uri(session.Environment.ClusterUri); + var relativeUri = "powerbi/databases/v201606/workspaces"; + var requestUri = new Uri(baseUri, relativeUri); + + using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AuthenticationResult.AccessToken); + + using var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken); + httpResponse.EnsureSuccessStatusCode(); + + var json = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + + if (AppEnvironment.IsDiagnosticLevelVerbose) + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(CloudApiClient) }.{ nameof(GetCloudWorkspacesAsync) }", json); + + return JsonSerializer.Deserialize(json, _jsonOptions) ?? []; + } + + private async Task> GetCloudSharedModelsAsync(AuthenticatedSession session, CancellationToken cancellationToken) + { + var baseUri = new Uri(session.Environment.ClusterUri); + var relativeUri = "metadata/v201901/gallery/sharedDatasets"; + var requestUri = new Uri(baseUri, relativeUri); + + using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AuthenticationResult.AccessToken); + + using var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken); + httpResponse.EnsureSuccessStatusCode(); + + var json = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + + if (AppEnvironment.IsDiagnosticLevelVerbose) + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(CloudApiClient) }.{ nameof(GetCloudSharedModelsAsync) }", json); + + return JsonSerializer.Deserialize(json, _jsonOptions) ?? []; + } + } +} diff --git a/src/Infrastructure/Models/PBICloud/CloudEnvironment.cs b/src/Infrastructure/PowerBI/Cloud/CloudEnvironment.cs similarity index 84% rename from src/Infrastructure/Models/PBICloud/CloudEnvironment.cs rename to src/Infrastructure/PowerBI/Cloud/CloudEnvironment.cs index 132422b7..2942b9b6 100644 --- a/src/Infrastructure/Models/PBICloud/CloudEnvironment.cs +++ b/src/Infrastructure/PowerBI/Cloud/CloudEnvironment.cs @@ -1,7 +1,7 @@ -namespace Sqlbi.Bravo.Infrastructure.Models.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud { - using Sqlbi.Bravo.Infrastructure.Contracts.PBICloud; using Sqlbi.Bravo.Infrastructure.Extensions; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts; [DebuggerDisplay("{Name}")] public sealed record CloudEnvironment( @@ -14,8 +14,8 @@ public sealed record CloudEnvironment( string BackendUri, string ClusterUri) { - public Uri GetBackendRequestUri(string path) - => new(new Uri(BackendUri), relativeUri: path); + public Uri GetBackendRequestUri(string relativeUri) + => new(new Uri(BackendUri), relativeUri); public string GetIdentityProvider() => $"{AuthorityUri}, {ResourceId}, {ClientId}"; diff --git a/src/Infrastructure/Services/PowerBI/PBICloudConfigurationService.cs b/src/Infrastructure/PowerBI/Cloud/Configuration/CloudConfigurationService.cs similarity index 52% rename from src/Infrastructure/Services/PowerBI/PBICloudConfigurationService.cs rename to src/Infrastructure/PowerBI/Cloud/Configuration/CloudConfigurationService.cs index 6525d7f2..bdc2cabd 100644 --- a/src/Infrastructure/Services/PowerBI/PBICloudConfigurationService.cs +++ b/src/Infrastructure/PowerBI/Cloud/Configuration/CloudConfigurationService.cs @@ -1,42 +1,44 @@ -namespace Sqlbi.Bravo.Infrastructure.Services.PowerBI +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Configuration { using Sqlbi.Bravo.Infrastructure; - using Sqlbi.Bravo.Infrastructure.Contracts; - using Sqlbi.Bravo.Infrastructure.Contracts.PBICloud; - using Sqlbi.Bravo.Infrastructure.Models.PBICloud; - using Sqlbi.Bravo.Infrastructure.Serialization; + using Sqlbi.Bravo.Infrastructure.Extensions; + using Sqlbi.Bravo.Infrastructure.PowerBI; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Serialization; using Sqlbi.Bravo.Models; - using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Web; - internal interface IPBICloudConfigurationService + internal interface ICloudConfigurationService { - Task> DiscoverCloudEnvironmentsAsync(string email, CancellationToken cancellationToken); + Task> DiscoverEnvironmentsAsync(string email, CancellationToken cancellationToken); + Task ResolveTenantClusterUriAsync(CloudEnvironment environment, string accessToken, CancellationToken cancellationToken); } - internal sealed class PBICloudConfigurationService : IPBICloudConfigurationService + internal sealed class CloudConfigurationService : ICloudConfigurationService { + // The Global Cloud is the default discovery base URI for Power BI service discovery. + private static readonly Uri s_defaultDiscoveryBaseUri = new("https://api.powerbi.com", UriKind.Absolute); + private readonly HttpClient _httpClient; private readonly Uri _discoveryBaseUri; - public PBICloudConfigurationService(IPBILocalConfigurationReader pbiLocalConfiguration) + public CloudConfigurationService(IHttpClientFactory httpClientFactory, ILocalConfigurationReader localConfigurationReader) { - _httpClient = new HttpClient(); - _httpClient.DefaultRequestHeaders.Accept.Clear(); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + _httpClient = httpClientFactory.CreateClient(ServiceCollectionExtensions.PowerBIApiHttpClientName); - _discoveryBaseUri = pbiLocalConfiguration.GetPowerBIServiceDiscoveryBaseUri() - ?? PBIConstants.Endpoints.GlobalCloudPowerBIUri; + _discoveryBaseUri = localConfigurationReader.GetPowerBIServiceDiscoveryBaseUri() + ?? s_defaultDiscoveryBaseUri; } - public async Task> DiscoverCloudEnvironmentsAsync(string email, CancellationToken cancellationToken) + public async Task> DiscoverEnvironmentsAsync(string email, CancellationToken cancellationToken) { - var response = await DiscoverCloudEnvironmentsAsync(email, apiVersion: "v202408", cancellationToken); + var response = await DiscoverEnvironmentsAsync(email, apiVersion: "v202408", cancellationToken); if (response is null) - response = await DiscoverCloudEnvironmentsAsync(email, apiVersion: "v202003", cancellationToken); + response = await DiscoverEnvironmentsAsync(email, apiVersion: "v202003", cancellationToken); var environments = response?.Environments ?? []; @@ -47,7 +49,8 @@ public async Task> DiscoverCloudEnvironmentsAsync( public async Task ResolveTenantClusterUriAsync(CloudEnvironment environment, string accessToken, CancellationToken cancellationToken) { - var requestUri = environment.GetBackendRequestUri("spglobalservice/GetOrInsertClusterUrisByTenantlocation"); + var relativeUri = "spglobalservice/GetOrInsertClusterUrisByTenantlocation"; + var requestUri = environment.GetBackendRequestUri(relativeUri); using var httpRequest = new HttpRequestMessage(HttpMethod.Put, requestUri); httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); @@ -59,15 +62,15 @@ public async Task ResolveTenantClusterUriAsync(CloudEnvironment environm var json = await httpResponse.Content.ReadAsStringAsync(cancellationToken); if (AppEnvironment.IsDiagnosticLevelVerbose) - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{nameof(PBICloudConfigurationService)}.{nameof(ResolveTenantClusterUriAsync)}", json); + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{nameof(CloudConfigurationService)}.{nameof(ResolveTenantClusterUriAsync)}", json); - var tenantCluster = PBIServiceJsonSerializer.Deserialize(json); + var tenantCluster = CloudContractJsonSerializer.Deserialize(json); return tenantCluster.FixedClusterUri; } - private async Task DiscoverCloudEnvironmentsAsync(string email, string apiVersion, CancellationToken cancellationToken) + private async Task DiscoverEnvironmentsAsync(string email, string apiVersion, CancellationToken cancellationToken) { - var relativeUri = FormattableString.Invariant($"powerbi/globalservice/{apiVersion}/environments/discover?user={HttpUtility.UrlEncode(email)}"); + var relativeUri = "powerbi/globalservice/{0}/environments/discover?user={1}".FormatInvariant(apiVersion, HttpUtility.UrlEncode(email)); var requestUri = new Uri(_discoveryBaseUri, relativeUri); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri); @@ -78,9 +81,9 @@ public async Task ResolveTenantClusterUriAsync(CloudEnvironment environm var json = await httpResponse.Content.ReadAsStringAsync(cancellationToken); if (AppEnvironment.IsDiagnosticLevelVerbose) - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{nameof(PBICloudConfigurationService)}.{nameof(DiscoverCloudEnvironmentsAsync)}()", content: json); + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{nameof(CloudConfigurationService)}.{nameof(DiscoverEnvironmentsAsync)}()", content: json); - return PBIServiceJsonSerializer.Deserialize(json); + return CloudContractJsonSerializer.Deserialize(json); } else if (httpResponse.StatusCode == HttpStatusCode.NotFound) { diff --git a/src/Infrastructure/Contracts/PBICloud/CloudEnvironmentClientContract.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentClientContract.cs similarity index 91% rename from src/Infrastructure/Contracts/PBICloud/CloudEnvironmentClientContract.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentClientContract.cs index 1c019c4b..33e612c5 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudEnvironmentClientContract.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentClientContract.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudEnvironmentContract.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentContract.cs similarity index 95% rename from src/Infrastructure/Contracts/PBICloud/CloudEnvironmentContract.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentContract.cs index 35f21a7a..36be5843 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudEnvironmentContract.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentContract.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudEnvironmentResponseContract.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentResponseContract.cs similarity index 87% rename from src/Infrastructure/Contracts/PBICloud/CloudEnvironmentResponseContract.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentResponseContract.cs index 8a15f433..07dea3da 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudEnvironmentResponseContract.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentResponseContract.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudEnvironmentServiceContract.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentServiceContract.cs similarity index 93% rename from src/Infrastructure/Contracts/PBICloud/CloudEnvironmentServiceContract.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentServiceContract.cs index e92f5852..42bb7f56 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudEnvironmentServiceContract.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudEnvironmentServiceContract.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudModel.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudModel.cs similarity index 98% rename from src/Infrastructure/Contracts/PBICloud/CloudModel.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudModel.cs index f8302792..a094bdeb 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudModel.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudModel.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System; using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudOrganizationalGalleryItem.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudOrganizationalGalleryItem.cs similarity index 96% rename from src/Infrastructure/Contracts/PBICloud/CloudOrganizationalGalleryItem.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudOrganizationalGalleryItem.cs index c4c24dda..30ff153a 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudOrganizationalGalleryItem.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudOrganizationalGalleryItem.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System; using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudOrganizationalGalleryItemStatus.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudOrganizationalGalleryItemStatus.cs similarity index 61% rename from src/Infrastructure/Contracts/PBICloud/CloudOrganizationalGalleryItemStatus.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudOrganizationalGalleryItemStatus.cs index 013fcca1..b140c551 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudOrganizationalGalleryItemStatus.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudOrganizationalGalleryItemStatus.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { public enum CloudOrganizationalGalleryItemStatus { diff --git a/src/Infrastructure/Contracts/PBICloud/CloudPermissions.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudPermissions.cs similarity index 73% rename from src/Infrastructure/Contracts/PBICloud/CloudPermissions.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudPermissions.cs index eee5ba6a..31a676af 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudPermissions.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudPermissions.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudPromotionalStage.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudPromotionalStage.cs similarity index 68% rename from src/Infrastructure/Contracts/PBICloud/CloudPromotionalStage.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudPromotionalStage.cs index cffba098..3c19de70 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudPromotionalStage.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudPromotionalStage.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { public enum CloudPromotionalStage { diff --git a/src/Infrastructure/Contracts/PBICloud/CloudSharedModel.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudSharedModel.cs similarity index 96% rename from src/Infrastructure/Contracts/PBICloud/CloudSharedModel.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudSharedModel.cs index 2770419a..c88447fe 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudSharedModel.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudSharedModel.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System; using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudSharedModelWorkspaceType.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudSharedModelWorkspaceType.cs similarity index 90% rename from src/Infrastructure/Contracts/PBICloud/CloudSharedModelWorkspaceType.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudSharedModelWorkspaceType.cs index 3b697a38..55cae42d 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudSharedModelWorkspaceType.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudSharedModelWorkspaceType.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { public enum CloudSharedModelWorkspaceType { diff --git a/src/Infrastructure/Contracts/PBICloud/CloudUser.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudUser.cs similarity index 87% rename from src/Infrastructure/Contracts/PBICloud/CloudUser.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudUser.cs index 925a39b7..51392a52 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudUser.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudUser.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudWorkspace.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspace.cs similarity index 97% rename from src/Infrastructure/Contracts/PBICloud/CloudWorkspace.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspace.cs index 9ffcf39f..de7e9996 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudWorkspace.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspace.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System; using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Contracts/PBICloud/CloudWorkspaceCapacitySkuType.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspaceCapacitySkuType.cs similarity index 80% rename from src/Infrastructure/Contracts/PBICloud/CloudWorkspaceCapacitySkuType.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspaceCapacitySkuType.cs index 27417a91..95733aef 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudWorkspaceCapacitySkuType.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspaceCapacitySkuType.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { public enum CloudWorkspaceCapacitySkuType { diff --git a/src/Infrastructure/Contracts/PBICloud/CloudWorkspaceType.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspaceType.cs similarity index 84% rename from src/Infrastructure/Contracts/PBICloud/CloudWorkspaceType.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspaceType.cs index dbe0e1d8..d85264e4 100644 --- a/src/Infrastructure/Contracts/PBICloud/CloudWorkspaceType.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/CloudWorkspaceType.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { public enum CloudWorkspaceType { diff --git a/src/Infrastructure/Contracts/PBICloud/TenantClusterContract.cs b/src/Infrastructure/PowerBI/Cloud/Contracts/TenantClusterContract.cs similarity index 88% rename from src/Infrastructure/Contracts/PBICloud/TenantClusterContract.cs rename to src/Infrastructure/PowerBI/Cloud/Contracts/TenantClusterContract.cs index b14381f9..44031d9c 100644 --- a/src/Infrastructure/Contracts/PBICloud/TenantClusterContract.cs +++ b/src/Infrastructure/PowerBI/Cloud/Contracts/TenantClusterContract.cs @@ -1,4 +1,4 @@ -namespace Sqlbi.Bravo.Infrastructure.Contracts.PBICloud +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts { using System.Text.Json.Serialization; diff --git a/src/Infrastructure/Serialization/PBIServiceJsonSerializer.cs b/src/Infrastructure/PowerBI/Cloud/Serialization/CloudContractJsonSerializer.cs similarity index 60% rename from src/Infrastructure/Serialization/PBIServiceJsonSerializer.cs rename to src/Infrastructure/PowerBI/Cloud/Serialization/CloudContractJsonSerializer.cs index 127efc3f..bb1df6d5 100644 --- a/src/Infrastructure/Serialization/PBIServiceJsonSerializer.cs +++ b/src/Infrastructure/PowerBI/Cloud/Serialization/CloudContractJsonSerializer.cs @@ -1,16 +1,16 @@ -namespace Sqlbi.Bravo.Infrastructure.Serialization +namespace Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Serialization { - internal static class PBIServiceJsonSerializer + internal static class CloudContractJsonSerializer { private readonly static JsonSerializerOptions s_options = new(JsonSerializerDefaults.Web); - public static T Deserialize(string json) + public static T Deserialize(string json) where T : class { return JsonSerializer.Deserialize(json, s_options) ?? throw new InvalidOperationException($"The JSON content deserialized to a null '{typeof(T)}' instance."); } - public static string Serialize(T value) + public static string Serialize(T value) where T : class => JsonSerializer.Serialize(value, s_options); } } diff --git a/src/Infrastructure/Services/PowerBI/PBILocalConfigurationReader.cs b/src/Infrastructure/PowerBI/LocalConfigurationReader.cs similarity index 72% rename from src/Infrastructure/Services/PowerBI/PBILocalConfigurationReader.cs rename to src/Infrastructure/PowerBI/LocalConfigurationReader.cs index a96c67fa..1be0f7f1 100644 --- a/src/Infrastructure/Services/PowerBI/PBILocalConfigurationReader.cs +++ b/src/Infrastructure/PowerBI/LocalConfigurationReader.cs @@ -1,17 +1,20 @@ -namespace Sqlbi.Bravo.Infrastructure.Services.PowerBI +namespace Sqlbi.Bravo.Infrastructure.PowerBI { using Microsoft.Win32; - using Sqlbi.Bravo.Infrastructure.Contracts; using Sqlbi.Bravo.Infrastructure.Extensions; - internal interface IPBILocalConfigurationReader + internal interface ILocalConfigurationReader { Uri? GetPowerBIServiceDiscoveryBaseUri(); Uri? GetPowerBIServiceFixedClusterUri(); } - internal sealed class PBILocalConfigurationReader : IPBILocalConfigurationReader + internal sealed class LocalConfigurationReader : ILocalConfigurationReader { + private const string PowerBIDiscoveryUrlValueName = "PowerBIDiscoveryUrl"; + private const string PowerBISubkeyName = @"SOFTWARE\Microsoft\Microsoft Power BI\"; + private const string PowerBIPolicySubkeyName = @"SOFTWARE\Policies\Microsoft\Microsoft Power BI\"; + /// /// Gets the Power BI service discovery base URI from the local machine registry. /// @@ -21,7 +24,7 @@ internal sealed class PBILocalConfigurationReader : IPBILocalConfigurationReader /// public Uri? GetPowerBIServiceDiscoveryBaseUri() { - var valueName = PBIConstants.Registry.PowerBIDiscoveryUrlValueName; + var valueName = PowerBIDiscoveryUrlValueName; // The value may have been written to either the 64-bit or the 32-bit (WOW6432Node) registry view, // depending on the bitness of the Power BI Desktop build that set it. Check both views explicitly so @@ -35,10 +38,10 @@ internal sealed class PBILocalConfigurationReader : IPBILocalConfigurationReader public Uri? GetPowerBIServiceFixedClusterUri() { - // `PowerBIServiceUrl` is not a documented registry key, but it is used by the + // `PowerBIServiceUrl` is not a documented registry key, but it is used by the // PBI Desktop to override the default service URL for fixed cluster scenarios. - return null; // Not implemented yet. + throw new NotImplementedException(); } private static string? GetRegistryString(string valueName, RegistryView view) @@ -46,12 +49,12 @@ internal sealed class PBILocalConfigurationReader : IPBILocalConfigurationReader using var key = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); // Try the policy override first - var value = key.GetStringValue(PBIConstants.Registry.PowerBIPolicySubkeyName, valueName); + var value = key.GetStringValue(PowerBIPolicySubkeyName, valueName); if (!string.IsNullOrEmpty(value)) return value; // Then try the standard subkey - value = key.GetStringValue(PBIConstants.Registry.PowerBISubkeyName, valueName); + value = key.GetStringValue(PowerBISubkeyName, valueName); if (!string.IsNullOrEmpty(value)) return value; diff --git a/src/Infrastructure/PowerBI/ServiceCollectionExtensions.cs b/src/Infrastructure/PowerBI/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..373b998b --- /dev/null +++ b/src/Infrastructure/PowerBI/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; +using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication; +using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Configuration; + +namespace Sqlbi.Bravo.Infrastructure.PowerBI +{ + internal static class ServiceCollectionExtensions + { + internal const string PowerBIApiHttpClientName = "PowerBIApi"; + + public static IServiceCollection AddPowerBIServices(this IServiceCollection services) + { + services.AddHttpClient(PowerBIApiHttpClientName, (client) => + { + client.DefaultRequestHeaders.Accept.Clear(); // No default Accept header required + client.Timeout = TimeSpan.FromMinutes(3); + }); + + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + } +} diff --git a/src/Infrastructure/Services/PowerBI/PBICloudAuthenticationService.cs b/src/Infrastructure/Services/PowerBI/PBICloudAuthenticationService.cs deleted file mode 100644 index 584c3a05..00000000 --- a/src/Infrastructure/Services/PowerBI/PBICloudAuthenticationService.cs +++ /dev/null @@ -1,130 +0,0 @@ -namespace Sqlbi.Bravo.Infrastructure.Services.PowerBI -{ - using Microsoft.Identity.Client; - using Sqlbi.Bravo.Infrastructure; - using Sqlbi.Bravo.Infrastructure.Helpers; - using Sqlbi.Bravo.Infrastructure.Models.PBICloud; - using Sqlbi.Bravo.Models; - - public interface IPBICloudAuthenticationService - { - PBICloudAuthenticationResult? CurrentAuthentication { get; } - - CloudEnvironment? CurrentEnvironment { get; } - - Task> GetEnvironmentsAsync(string email, CancellationToken cancellationToken); - - Task SignInAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken); - - Task SignOutAsync(CancellationToken cancellationToken); - } - - internal class PBICloudAuthenticationService : IPBICloudAuthenticationService, IDisposable - { - private readonly IPBICloudConfigurationService _pbicloudConfiguration; - private readonly SemaphoreSlim _authenticationSemaphore = new(1, 1); - - public PBICloudAuthenticationService(IPBICloudConfigurationService pbicloudConfiguration) - { - _pbicloudConfiguration = pbicloudConfiguration; - } - - public PBICloudAuthenticationResult? CurrentAuthentication { get; private set; } - - public CloudEnvironment? CurrentEnvironment { get; private set; } - - public async Task> GetEnvironmentsAsync(string email, CancellationToken cancellationToken) - { - var environments = await _pbicloudConfiguration.DiscoverCloudEnvironmentsAsync(email, cancellationToken); - - if (AppEnvironment.IsDiagnosticLevelVerbose) - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(PBICloudAuthenticationService) }.{ nameof(GetEnvironmentsAsync) }", JsonSerializer.Serialize(environments)); - - return environments; - } - - public async Task SignInAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken) - { - await _authenticationSemaphore.WaitAsync(cancellationToken); - try - { - if (AppEnvironment.IsDiagnosticLevelVerbose) - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{nameof(PBICloudAuthenticationService)}.{nameof(SignInAsync)}", JsonSerializer.Serialize(environment)); - - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cancellationTokenSource.CancelAfter(TimeSpan.FromMinutes(2)); - - var authentication = await AcquireTokenAsync(email, environment, cancellationTokenSource.Token); - var clusterUri = await _pbicloudConfiguration.ResolveTenantClusterUriAsync(environment, authentication.AccessToken, cancellationToken); - - CurrentAuthentication = authentication; - CurrentEnvironment = environment with { ClusterUri = clusterUri }; - } - finally - { - _authenticationSemaphore.Release(); - } - } - - public async Task SignOutAsync(CancellationToken cancellationToken) - { - await _authenticationSemaphore.WaitAsync(cancellationToken); - try - { - if (CurrentEnvironment is not null) - { - await MsalHelper.ClearTokenCacheAsync(CurrentEnvironment); - } - - CurrentAuthentication = null; - CurrentEnvironment = null; - } - finally - { - _authenticationSemaphore.Release(); - } - } - - private async Task AcquireTokenAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken) - { - // TODO: Acquire a token using WAM https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-wam - // TODO: Acquire a token using integrated Windows authentication https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-integrated-windows-authentication - try - { - return await MsalHelper.AcquireTokenSilentAsync(email, environment, cancellationToken); - - //if (UserPreferences.Current.Experimental?.UseIntegratedWindowsAuthenticationSso == true) - //{ - // return await MsalHelper.AcquireTokenByIntegratedWindowsAuthAsync(environment, cancellationToken); - //} - } - catch (MsalUiRequiredException msalUiRequiredException) - { - return await MsalHelper.AcquireTokenInteractiveAsync(email, environment, msalUiRequiredException.Claims, cancellationToken); - - } - catch (MsalServiceException msalServiceException) - { - return await MsalHelper.AcquireTokenInteractiveAsync(email, environment, msalServiceException.Claims, cancellationToken); - } - catch (MsalClientException) - { - //if (UserPreferences.Current.Experimental?.UseIntegratedWindowsAuthenticationSso == true) - //{ - // return await MsalHelper.AcquireTokenInteractiveAsync(environment, claims: null, cancellationToken); - //} - - throw; - } - } - - #region IDisposable - - public void Dispose() - { - _authenticationSemaphore.Dispose(); - } - - #endregion - } -} diff --git a/src/Infrastructure/Services/PowerBI/PBICloudService.cs b/src/Infrastructure/Services/PowerBI/PBICloudService.cs deleted file mode 100644 index ca087d89..00000000 --- a/src/Infrastructure/Services/PowerBI/PBICloudService.cs +++ /dev/null @@ -1,141 +0,0 @@ -namespace Sqlbi.Bravo.Infrastructure.Services.PowerBI -{ - using Sqlbi.Bravo.Infrastructure; - using Sqlbi.Bravo.Infrastructure.Contracts.PBICloud; - using Sqlbi.Bravo.Infrastructure.Extensions; - using Sqlbi.Bravo.Models; - using Sqlbi.Bravo.Services; - using System; - using System.Collections.Generic; - using System.Drawing; - using System.Drawing.Imaging; - using System.Globalization; - using System.IO; - using System.Linq; - using System.Net.Http; - using System.Net.Http.Headers; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - - public interface IPBICloudService - { - Task GetAccountAvatarAsync(); - - Task> GetDatasetsAsync(CancellationToken cancellationToken); - } - - internal class PBICloudService : IPBICloudService - { - private const string GetWorkspacesRequestUri = "powerbi/databases/v201606/workspaces"; - private const string GetGallerySharedDatasetsRequestUri = "metadata/v201901/gallery/sharedDatasets"; - private const string GetResourceUserPhotoRequestUri = "powerbi/version/201606/resource/userPhoto/?userId={0}"; - - private readonly HttpClient _httpClient; - private readonly IAuthenticationService _authenticationService; - private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) - { - PropertyNameCaseInsensitive = false, // required by SharedDatasetModel LastRefreshTime/lastRefreshTime properties - }; - - public const string PBIDatasetProtocolScheme = "pbiazure"; - public const string PBIPremiumXmlaEndpointProtocolScheme = "powerbi"; - //public const string PBIPremiumDedicatedProtocolScheme = "pbidedicated"; - public const string ASAzureProtocolScheme = "asazure"; - //public const string ASAzureLinkProtocolScheme = "link"; - - public PBICloudService(IAuthenticationService authenticationService, HttpClient httpClient) - { - _authenticationService = authenticationService; - _httpClient = httpClient; - } - - public async Task GetAccountAvatarAsync() - { - _httpClient.DefaultRequestHeaders.Accept.Clear(); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _authenticationService.PBICloudAuthentication.AccessToken); - - var requestPath = GetResourceUserPhotoRequestUri.FormatInvariant(_authenticationService.PBICloudAuthentication.Account.Email); - var requestUri = _authenticationService.PBICloudEnvironment.GetBackendRequestUri(requestPath); - using var response = await _httpClient.GetAsync(requestUri).ConfigureAwait(false); - - if (response.IsSuccessStatusCode) - { - using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var bitmap = new Bitmap(stream); - using var memoryStream = new MemoryStream(); - bitmap.Save(memoryStream, bitmap.RawFormat); - - var imageBase64String = Convert.ToBase64String(memoryStream.ToArray()); - var imageMimeType = GetMimeType(bitmap); - - var encodedImage = string.Format(CultureInfo.InvariantCulture, "data:{0};base64,{1}", imageMimeType, imageBase64String); - return encodedImage; - } - - //var cachedImage = _authenticationService.CachedUserInfo?.Avatar; - //return cachedImage; - - return null; - - static string? GetMimeType(Bitmap bitmap) => ImageCodecInfo.GetImageDecoders().FirstOrDefault((c) => c.FormatID == bitmap.RawFormat.Guid)?.MimeType; - } - - public async Task> GetDatasetsAsync(CancellationToken cancellationToken) - { - var cloudWorkspaces = await GetCloudWorkspacesAsync(cancellationToken); - var cloudSharedModels = await GetCloudSharedModelsAsync(cancellationToken); - - if (AppEnvironment.IsDiagnosticLevelVerbose) - { - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(PBICloudService) }.{ nameof(GetDatasetsAsync) }.{ nameof(cloudWorkspaces) }", content: JsonSerializer.Serialize(cloudWorkspaces)); - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(PBICloudService) }.{ nameof(GetDatasetsAsync) }.{ nameof(cloudSharedModels) }", content: JsonSerializer.Serialize(cloudSharedModels)); - } - - var datasets = cloudWorkspaces.Join(cloudSharedModels, (w) => w.ObjectId?.ToLowerInvariant(), (d) => d.ObjectId?.ToLowerInvariant(), (w, d) => PBICloudDataset.CreateFrom(_authenticationService.PBICloudEnvironment, w, d)).ToArray(); - - if (AppEnvironment.IsDiagnosticLevelVerbose) - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(PBICloudService) }.{ nameof(GetDatasetsAsync) }", content: JsonSerializer.Serialize(datasets)); - - return datasets; - } - - private async Task> GetCloudWorkspacesAsync(CancellationToken cancellationToken) - { - _httpClient.DefaultRequestHeaders.Accept.Clear(); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _authenticationService.PBICloudAuthentication.AccessToken); - - var baseUri = new Uri(_authenticationService.PBICloudEnvironment.ClusterUri!); - var requestUri = new Uri(baseUri, relativeUri: GetWorkspacesRequestUri); - using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - - if (AppEnvironment.IsDiagnosticLevelVerbose) - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(PBICloudService) }.{ nameof(GetCloudWorkspacesAsync) }", content); - - var workspaces = JsonSerializer.Deserialize(content, _jsonOptions); - return workspaces ?? Array.Empty(); - } - - private async Task> GetCloudSharedModelsAsync(CancellationToken cancellationToken) - { - _httpClient.DefaultRequestHeaders.Accept.Clear(); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _authenticationService.PBICloudAuthentication.AccessToken); - - var baseUri = new Uri(_authenticationService.PBICloudEnvironment.ClusterUri!); - var requestUri = new Uri(baseUri, relativeUri: GetGallerySharedDatasetsRequestUri); - using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - - if (AppEnvironment.IsDiagnosticLevelVerbose) - AppEnvironment.AddDiagnostics(DiagnosticMessageType.Json, name: $"{ nameof(PBICloudService) }.{ nameof(GetCloudSharedModelsAsync) }", content); - - var datasets = JsonSerializer.Deserialize(content, _jsonOptions); - return datasets ?? Array.Empty(); - } - } -} \ No newline at end of file diff --git a/src/Models/AppAccount.cs b/src/Models/AppAccount.cs deleted file mode 100644 index 92268ab7..00000000 --- a/src/Models/AppAccount.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Sqlbi.Bravo.Models -{ - using Microsoft.Identity.Client; - - public sealed class AppAccount - { - /// - /// Unique identifier for the account - /// - public string Identifier { get; set; } - - /// - /// User name in UserPrincipalName (UPN) format - e.g. john.doe@contoso.com - /// - public string Email { get; set; } - - /// - /// Displayable user name (not guaranteed to be unique, it is mutable) - /// - public string Username { get; set; } - - public AppAccount(AuthenticationResult authenticationResult) - { - Identifier = authenticationResult.Account.HomeAccountId.Identifier; - Email = authenticationResult.Account.Username; - Username = authenticationResult.ClaimsPrincipal.FindFirst((claim) => claim.Type == "name")?.Value ?? ""; - } - } -} diff --git a/src/Models/Authentication/AccountDto.cs b/src/Models/Authentication/AccountDto.cs new file mode 100644 index 00000000..6a19a788 --- /dev/null +++ b/src/Models/Authentication/AccountDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Sqlbi.Bravo.Models.Authentication +{ + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication; + + public sealed record AccountDto( + [Required] [property: JsonPropertyName("id")] string Identifier, + [Required] string Email, + [Required] string Username); + + internal static class AccountDtoMappingExtensions + { + internal static AccountDto ToDto(this AuthenticationResult authenticationResult) => new( + authenticationResult.Identifier, + authenticationResult.Email, + authenticationResult.Name); + } +} diff --git a/src/Models/Authentication/AppAccountDto.cs b/src/Models/Authentication/AppAccountDto.cs deleted file mode 100644 index 91100d71..00000000 --- a/src/Models/Authentication/AppAccountDto.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace Sqlbi.Bravo.Models.Authentication -{ - public sealed record AppAccountDto( - [Required] [property: JsonPropertyName("id")] string Identifier, - [Required] string Email, - [Required] string Username); - - internal static class AppAccountDtoMappingExtensions - { - internal static AppAccountDto ToDto(this AppAccount account) => new( - account.Identifier, - account.Email, - account.Username); - } -} diff --git a/src/Models/Authentication/CloudEnvironmentDto.cs b/src/Models/Authentication/CloudEnvironmentDto.cs index c733dbfe..7428c5db 100644 --- a/src/Models/Authentication/CloudEnvironmentDto.cs +++ b/src/Models/Authentication/CloudEnvironmentDto.cs @@ -1,4 +1,4 @@ -using Sqlbi.Bravo.Infrastructure.Models.PBICloud; +using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; using System.ComponentModel.DataAnnotations; namespace Sqlbi.Bravo.Models.Authentication diff --git a/src/Models/Authentication/GetEnvironmentsResponse.cs b/src/Models/Authentication/GetEnvironmentsResponse.cs index 6fda7701..d5f5f4a2 100644 --- a/src/Models/Authentication/GetEnvironmentsResponse.cs +++ b/src/Models/Authentication/GetEnvironmentsResponse.cs @@ -1,4 +1,4 @@ -using Sqlbi.Bravo.Infrastructure.Models.PBICloud; +using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; namespace Sqlbi.Bravo.Models.Authentication { diff --git a/src/Models/Authentication/SignInResponse.cs b/src/Models/Authentication/SignInResponse.cs index 7e5d55b9..1f710a22 100644 --- a/src/Models/Authentication/SignInResponse.cs +++ b/src/Models/Authentication/SignInResponse.cs @@ -1,7 +1,9 @@ namespace Sqlbi.Bravo.Models.Authentication { - public sealed class SignInResponse(AppAccount account) + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication; + + public sealed class SignInResponse(AuthenticationResult authenticationResult) { - public AppAccountDto Account { get; } = account.ToDto(); + public AccountDto Account { get; } = authenticationResult.ToDto(); } } diff --git a/src/Models/PBICloudDataset.cs b/src/Models/PBICloudDataset.cs index dbe244b0..7bfc3122 100644 --- a/src/Models/PBICloudDataset.cs +++ b/src/Models/PBICloudDataset.cs @@ -1,12 +1,11 @@ namespace Sqlbi.Bravo.Models { using Sqlbi.Bravo.Infrastructure; - using Sqlbi.Bravo.Infrastructure.Contracts.PBICloud; using Sqlbi.Bravo.Infrastructure.Extensions; using Sqlbi.Bravo.Infrastructure.Helpers; using Sqlbi.Bravo.Infrastructure.Models; - using Sqlbi.Bravo.Infrastructure.Models.PBICloud; - using Sqlbi.Bravo.Infrastructure.Services.PowerBI; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Contracts; using System; using System.Diagnostics; using System.Text.Json.Serialization; @@ -149,7 +148,7 @@ internal static PBICloudDataset CreateFrom(CloudEnvironment environment, CloudWo WorkspaceName = cloudWorkspace.Name.NullIfEmpty() ?? cloudSharedModel.WorkspaceName, WorkspaceObjectId = cloudWorkspace.ObjectId, Id = cloudModel.Id, - ServerName = CommonHelper.ChangeUriScheme(environment.BackendUri, PBICloudService.PBIDatasetProtocolScheme, ignorePort: true), + ServerName = CommonHelper.ChangeUriScheme(environment.BackendUri, CloudApiClient.PBIDatasetProtocolScheme, ignorePort: true), DatabaseName = cloudModel.DBName, ExternalServerName = null, ExternalDatabaseName = null, @@ -171,7 +170,7 @@ internal static PBICloudDataset CreateFrom(CloudEnvironment environment, CloudWo if (dataset.IsXmlaEndPointSupported) { - dataset.ExternalServerName = CommonHelper.ChangeUriScheme(environment.ClusterUri, PBICloudService.PBIPremiumXmlaEndpointProtocolScheme, ignorePort: true); + dataset.ExternalServerName = CommonHelper.ChangeUriScheme(environment.ClusterUri, CloudApiClient.PBIPremiumXmlaEndpointProtocolScheme, ignorePort: true); dataset.ExternalDatabaseName = cloudModel.DisplayName; } else if (dataset.IsOnPremModel == true) diff --git a/src/Services/AnalyzeModelService.cs b/src/Services/AnalyzeModelService.cs index 14e2f29d..fb8fbcad 100644 --- a/src/Services/AnalyzeModelService.cs +++ b/src/Services/AnalyzeModelService.cs @@ -3,6 +3,8 @@ using Sqlbi.Bravo.Infrastructure; using Sqlbi.Bravo.Infrastructure.Extensions; using Sqlbi.Bravo.Infrastructure.Helpers; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication; using Sqlbi.Bravo.Infrastructure.Services; using Sqlbi.Bravo.Infrastructure.Services.PowerBI; using Sqlbi.Bravo.Models; @@ -16,7 +18,7 @@ public interface IAnalyzeModelService TabularDatabase GetDatabase(PBICloudDataset dataset, string accessToken, CancellationToken cancellationToken); - Task> GetDatasetsAsync(CancellationToken cancellationToken); + Task> GetDatasetsAsync(AuthenticatedSession session, CancellationToken cancellationToken); IEnumerable GetReports(CancellationToken cancellationToken); @@ -29,12 +31,12 @@ public interface IAnalyzeModelService internal sealed class AnalyzeModelService : IAnalyzeModelService { - private readonly IPBICloudService _pbicloudService; + private readonly ICloudApiClient _cloudApiClient; private readonly IPBIDesktopService _pbidesktopService; - public AnalyzeModelService(IPBICloudService pbicloudService, IPBIDesktopService pbidesktopService) + public AnalyzeModelService(ICloudApiClient cloudApiClient, IPBIDesktopService pbidesktopService) { - _pbicloudService = pbicloudService; + _cloudApiClient = cloudApiClient; _pbidesktopService = pbidesktopService; } @@ -83,9 +85,9 @@ public TabularDatabase GetDatabase(PBICloudDataset dataset, string accessToken, return database; } - public async Task> GetDatasetsAsync(CancellationToken cancellationToken) + public async Task> GetDatasetsAsync(AuthenticatedSession session, CancellationToken cancellationToken) { - var datasets = await _pbicloudService.GetDatasetsAsync(cancellationToken); + var datasets = await _cloudApiClient.GetDatasetsAsync(session, cancellationToken); return datasets; } diff --git a/src/Services/AuthenticationService.cs b/src/Services/AuthenticationService.cs index 0443d490..49d0a304 100644 --- a/src/Services/AuthenticationService.cs +++ b/src/Services/AuthenticationService.cs @@ -1,75 +1,50 @@ -namespace Sqlbi.Bravo.Services +namespace Sqlbi.Bravo.Services { using Sqlbi.Bravo.Infrastructure; - using Sqlbi.Bravo.Infrastructure.Models.PBICloud; - using Sqlbi.Bravo.Infrastructure.Services.PowerBI; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Authentication; + using Sqlbi.Bravo.Infrastructure.PowerBI.Cloud.Configuration; public interface IAuthenticationService { - CloudEnvironment PBICloudEnvironment { get; } + Task> GetEnvironmentsAsync(string email, CancellationToken cancellationToken); - PBICloudAuthenticationResult PBICloudAuthentication { get; } + Task EnsureSignedInAsync(CancellationToken cancellationToken); - Task IsPBICloudSignInRequiredAsync(CancellationToken cancellationToken); + Task SignInAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken); - Task PBICloudSignInAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken); - - Task PBICloudSignOutAsync(CancellationToken cancellationToken); + Task SignOutAsync(CancellationToken cancellationToken); } - internal class AuthenticationService : IAuthenticationService + internal class AuthenticationService( + ICloudAuthenticationService cloudAuthenticationService, + ICloudConfigurationService cloudConfigurationService) : IAuthenticationService { - private readonly IPBICloudAuthenticationService _pbicloudAuthenticationService; - - public AuthenticationService(IPBICloudAuthenticationService pbicloudAuthenticationService) - { - _pbicloudAuthenticationService = pbicloudAuthenticationService; - } - - public CloudEnvironment PBICloudEnvironment - { - get - { - BravoUnexpectedException.ThrowIfNull(_pbicloudAuthenticationService.CurrentEnvironment); - return _pbicloudAuthenticationService.CurrentEnvironment; - } - } + private readonly ICloudAuthenticationService _cloudAuthenticationService = cloudAuthenticationService; + private readonly ICloudConfigurationService _cloudConfigurationService = cloudConfigurationService; - public PBICloudAuthenticationResult PBICloudAuthentication + public async Task> GetEnvironmentsAsync(string email, CancellationToken cancellationToken) { - get - { - BravoUnexpectedException.ThrowIfNull(_pbicloudAuthenticationService.CurrentAuthentication); - return _pbicloudAuthenticationService.CurrentAuthentication; - } + return await _cloudConfigurationService.DiscoverEnvironmentsAsync(email, cancellationToken); } - public async Task IsPBICloudSignInRequiredAsync(CancellationToken cancellationToken) + public async Task EnsureSignedInAsync(CancellationToken cancellationToken) { - var authentication = _pbicloudAuthenticationService.CurrentAuthentication; - var environment = _pbicloudAuthenticationService.CurrentEnvironment; - - if (authentication is null || environment is null) + try { - return true; + return await _cloudAuthenticationService.EnsureSignedInAsync(cancellationToken); } - - if (authentication.IsExpired) + catch (OperationCanceledException) { - await PBICloudSignInAsync(authentication.Account.Email, environment, cancellationToken).ConfigureAwait(false); + throw new BravoException(BravoProblem.SignInMsalTimeoutExpired); } - - return false; } - public async Task PBICloudSignInAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken) + public async Task SignInAsync(string email, CloudEnvironment environment, CancellationToken cancellationToken) { try { - await _pbicloudAuthenticationService.SignInAsync(email, environment, cancellationToken); - - BravoUnexpectedException.Assert(_pbicloudAuthenticationService.CurrentAuthentication is not null); - BravoUnexpectedException.Assert(_pbicloudAuthenticationService.CurrentEnvironment is not null); + return await _cloudAuthenticationService.SignInAsync(email, environment, cancellationToken); } catch (OperationCanceledException) { @@ -77,12 +52,9 @@ public async Task PBICloudSignInAsync(string email, CloudEnvironment environment } } - public async Task PBICloudSignOutAsync(CancellationToken cancellationToken) + public async Task SignOutAsync(CancellationToken cancellationToken) { - await _pbicloudAuthenticationService.SignOutAsync(cancellationToken).ConfigureAwait(false); - - BravoUnexpectedException.Assert(_pbicloudAuthenticationService.CurrentAuthentication is null); - BravoUnexpectedException.Assert(_pbicloudAuthenticationService.CurrentEnvironment is null); + await _cloudAuthenticationService.SignOutAsync(cancellationToken); } } -} \ No newline at end of file +} diff --git a/src/Startup.cs b/src/Startup.cs index 28cf8d6c..6ba6e12f 100644 --- a/src/Startup.cs +++ b/src/Startup.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Sqlbi.Bravo.Infrastructure.Configuration.Settings; using Sqlbi.Bravo.Infrastructure.Extensions; + using Sqlbi.Bravo.Infrastructure.PowerBI; using Sqlbi.Bravo.Infrastructure.Services; using Sqlbi.Bravo.Infrastructure.Services.PowerBI; using Sqlbi.Bravo.Infrastructure.Telemetry; @@ -45,7 +46,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddPBICloudServices(); + services.AddPowerBIServices(); } public void Configure(IApplicationBuilder application, IWebHostEnvironment environment)