Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SaaS Configuration module #207

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions StatCan.OrchardCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StatCan.OrchardCore.EmailTe
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StatCan.OrchardCore.OpenAPI", "src\Modules\StatCan.OrchardCore.OpenAPI\StatCan.OrchardCore.OpenAPI.csproj", "{834EA8DB-BF5E-4122-87A8-F7912F00D0AE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatCan.OrchardCore.SaaSConfiguration", "src\Modules\StatCan.OrchardCore.SaaSConfiguration\StatCan.OrchardCore.SaaSConfiguration.csproj", "{ADAEB375-9C36-46F1-B816-18A6E930BF1C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -394,6 +396,18 @@ Global
{834EA8DB-BF5E-4122-87A8-F7912F00D0AE}.Release|x64.Build.0 = Release|Any CPU
{834EA8DB-BF5E-4122-87A8-F7912F00D0AE}.Release|x86.ActiveCfg = Release|Any CPU
{834EA8DB-BF5E-4122-87A8-F7912F00D0AE}.Release|x86.Build.0 = Release|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|x64.ActiveCfg = Debug|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|x64.Build.0 = Debug|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|x86.ActiveCfg = Debug|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Debug|x86.Build.0 = Debug|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|Any CPU.Build.0 = Release|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|x64.ActiveCfg = Release|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|x64.Build.0 = Release|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|x86.ActiveCfg = Release|Any CPU
{ADAEB375-9C36-46F1-B816-18A6E930BF1C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -428,6 +442,7 @@ Global
{289ECA91-51F6-47C3-801C-376C0B30CB26} = {5E638520-41E8-11EA-885A-BDD3BB7B4F92}
{AF93DDD6-8BB0-43EF-BDA7-989F09C41348} = {8BEC45F6-4F23-4994-9959-50C1DB93ABC3}
{834EA8DB-BF5E-4122-87A8-F7912F00D0AE} = {8BEC45F6-4F23-4994-9959-50C1DB93ABC3}
{ADAEB375-9C36-46F1-B816-18A6E930BF1C} = {8BEC45F6-4F23-4994-9959-50C1DB93ABC3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8FF197F3-C3E2-4D83-80AC-D59BE36DD4AF}
Expand Down
16 changes: 16 additions & 0 deletions docs/en/reference/modules/SaaSConfiguration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# SaaS Configuration (`StatCan.OrchardCore.SaaSConfiguration`)

This module aims at simplifying the configuration of a SaaS application.

## Features
- Automatic configuration of OpenId Server (Server, Application, Scopes)
- Automatic configuration of OpenId Clients for tenants.

When a new tenant is created and has the `StatCan.OrchardCore.SaaSConfiguration.Client` feature enabled, this module will automatically sync the Client Id and Secret when the Main tenant modifies the settings.

## Roadmap
- Automatic configuration of Login / Registration settings for child tenants.
- Role mapping configuration for child tenants
- Mapping of UserProfile properties for tenants (based on the Default tenant's claims)
- Running of recipes in child tenants (maybe multiple at once)

Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<ProjectReference Include="..\..\Modules\StatCan.OrchardCore.OpenAPI\StatCan.OrchardCore.OpenAPI.csproj" PrivateAssets="none" />
<ProjectReference Include="..\..\Modules\StatCan.OrchardCore.Scripting\StatCan.OrchardCore.Scripting.csproj" PrivateAssets="none" />
<ProjectReference Include="..\..\Modules\StatCan.OrchardCore.VueForms\StatCan.OrchardCore.VueForms.csproj" PrivateAssets="none" />
<ProjectReference Include="..\..\Modules\StatCan.OrchardCore.SaaSConfiguration\StatCan.OrchardCore.SaaSConfiguration.csproj" PrivateAssets="none" />

<!-- Included OpenSource Extensions -->
<ProjectReference Include="..\..\Modules\StatCan.OrchardCore.ContentPermissions\StatCan.OrchardCore.ContentPermissions.csproj" PrivateAssets="none" />
Expand Down
49 changes: 49 additions & 0 deletions src/Modules/StatCan.OrchardCore.SaaSConfiguration/AdminMenu.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Localization;
using OrchardCore.Environment.Shell.Descriptor.Models;
using OrchardCore.Navigation;

namespace StatCan.OrchardCore.SaaSConfiguration
{
public class AdminMenu : INavigationProvider
{
private readonly IStringLocalizer S;

public AdminMenu(
IStringLocalizer<AdminMenu> localizer)
{
S = localizer;
}

public Task BuildNavigationAsync(string name, NavigationBuilder builder)
{
if (!String.Equals(name, "admin", StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}

builder
.Add(S["Configuration"], configuration => configuration
.Add(S["SaaS"], security => security

.Add(S["OpenId"], S["OpenId"].PrefixPosition(), openid =>
{
openid.Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = Constants.Features.SaaSConfiguration })
.Permission(Permissions.ManageSaaSConfiguration)
.LocalNav();
})
.Add(S["Execute JSON"], S["Execute JSON"].PrefixPosition(), deployment => deployment
.Action("ExecuteRecipe", "Admin", Constants.Features.SaaSConfiguration)
.Permission(Permissions.ManageSaaSConfiguration)
.LocalNav()
)
)
);

return Task.CompletedTask;
}
}
}
123 changes: 123 additions & 0 deletions src/Modules/StatCan.OrchardCore.SaaSConfiguration/ClientMigrations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using OrchardCore.Data.Migration;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using OpenIddict.Abstractions;
using OrchardCore.Entities;
using OrchardCore.Environment.Extensions;
using OrchardCore.Environment.Shell;
using OrchardCore.OpenId.Configuration;
using OrchardCore.OpenId.Services;
using OrchardCore.Settings;
using OrchardCore.Setup.Events;
using Microsoft.AspNetCore.Http;
using OrchardCore.DisplayManagement.Notify;
using Microsoft.AspNetCore.Mvc.Localization;

namespace StatCan.OrchardCore.SaaSConfiguration
{
public class ClientMigrations : DataMigration
{
private readonly IShellHost _shellHost;
private readonly ITenantHelperService _tenantHelper;
private readonly IOpenIdClientService _openIdClientService;
private readonly IDataProtectionProvider _dataProtectionProvider;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ShellSettings _shellSettings;
private readonly INotifier _notifier;
private readonly IHtmlLocalizer H;

public ClientMigrations(IShellHost shellHost,
ITenantHelperService tenantHelper,
IOpenIdClientService openIdClientService,
IDataProtectionProvider dataProtectionProvider,
IHttpContextAccessor httpContextAccessor,
ShellSettings shellSettings,
INotifier notifier,
IHtmlLocalizer<ClientMigrations> htmlLocalizer)
{
_shellHost = shellHost;
_tenantHelper = tenantHelper;
_openIdClientService = openIdClientService;
_dataProtectionProvider = dataProtectionProvider;
_httpContextAccessor = httpContextAccessor;
_shellSettings = shellSettings;
_notifier = notifier;
H = htmlLocalizer;
}

public async Task<int> CreateAsync()
{
var request = _httpContextAccessor.HttpContext.Request;

// Get the Configuration options from the default tenant
bool moduleEnabled = true;
SaaSConfigurationSettings saasConfigurationSettings = null;
string unprotectedSecret = null;
Uri authority = null;

var currentUri = new UriBuilder()
{
Scheme = request.Scheme,
Host = request.Host.Host,
Port = request.Host.Port ?? 0,
Path = _shellSettings.RequestUrlPrefix + "/signin-oidc"
};

var shellScope = await _shellHost.GetScopeAsync(ShellHelper.DefaultShellName);
// This code is running on the default tenant to get the configuration settings for this tenant
await shellScope.UsingAsync(async scope =>
{
var extensionManager = scope.ServiceProvider.GetRequiredService<IExtensionManager>();
var feature = extensionManager.GetFeatures().FirstOrDefault(f => f.Id == Constants.Features.SaaSConfiguration);
// tenant does not have our ConfigurationClient enabled, thus not configured by this module.
if(feature == null)
{
moduleEnabled = false;
return;
}

var saasConfigurationService = scope.ServiceProvider.GetRequiredService<ISaaSConfigurationService>();

saasConfigurationSettings = await saasConfigurationService.GetSettingsAsync();
unprotectedSecret = saasConfigurationService.GetUnprotectedClientSecret(saasConfigurationSettings.ClientSecret);
authority = saasConfigurationSettings.Authority;

await saasConfigurationService.UpdateRedirectUris(currentUri.ToString());
});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ActivateShell = false


if(!moduleEnabled)
{
await _tenantHelper.DisableFeatureAsync(Constants.Features.SaaSConfigurationClient);
_notifier.Error(H["The Default tenant does not have the {0} feature enabled. The {1} module has been disabled as a result", Constants.Features.SaaSConfiguration, Constants.Features.SaaSConfigurationClient]);
return 0 ;
}

var settings = await _openIdClientService.GetSettingsAsync();

settings.Authority = authority;
settings.ClientId = saasConfigurationSettings.ClientId;
settings.Scopes = new string[]{OpenIddictConstants.Scopes.Email};
settings.DisplayName = "Login"; //todo i18n?
settings.ResponseType = OpenIdConnectResponseType.Code;
settings.ResponseMode = OpenIdConnectResponseMode.FormPost;

// protect the client secret using the openid protector.
var clientProtector = _dataProtectionProvider.CreateProtector(nameof(OpenIdClientConfiguration));
settings.ClientSecret = clientProtector.Protect(unprotectedSecret);

var results = await _openIdClientService.ValidateSettingsAsync(settings);

await _openIdClientService.UpdateSettingsAsync(settings);

// restart the tenant to reload the OpenId connect configuration
await _shellHost.ReleaseShellContextAsync(_shellSettings);

return 1;
}
}
}
15 changes: 15 additions & 0 deletions src/Modules/StatCan.OrchardCore.SaaSConfiguration/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace StatCan.OrchardCore.SaaSConfiguration
{
public static class Constants
{
public static class Features
{
public const string SaaSConfiguration = "StatCan.OrchardCore.SaaSConfiguration";
public const string SaaSConfigurationClient = "StatCan.OrchardCore.SaaSConfiguration.Client";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Localization;
using OrchardCore.Deployment.Services;
using OrchardCore.DisplayManagement.Notify;
using OrchardCore.Mvc.Utilities;
using StatCan.OrchardCore.SaaSConfiguration.ViewModels;

namespace StatCan.OrchardCore.SaaSConfiguration.Controllers
{
public class AdminController : Controller
{
private readonly IAuthorizationService _authorizationService;
private readonly INotifier _notifier;
private readonly IHtmlLocalizer H;
private readonly IStringLocalizer S;
private readonly ITenantHelperService _helperService;

public AdminController(
IAuthorizationService authorizationService,
INotifier notifier,
IHtmlLocalizer<AdminController> htmlLocalizer,
IStringLocalizer<AdminController> stringLocalizer,
ITenantHelperService helperService
)
{
_authorizationService = authorizationService;
_notifier = notifier;
_helperService = helperService;

H = htmlLocalizer;
S = stringLocalizer;
}

public async Task<IActionResult> ExecuteRecipe()
{
if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSaaSConfiguration))
{
return Forbid();
}

var vm = new ExecuteRecipeViewModel
{
AllTenants = _helperService.GetTenantsExceptDefault().Select(t => new SelectListItem(t.Name, t.Name)).ToList()
};

return View(vm);
}

[HttpPost]
public async Task<IActionResult> ExecuteRecipe(ExecuteRecipeViewModel model)
{
if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSaaSConfiguration))
{
return Forbid();
}

if (!model.Json.IsJson())
{
ModelState.AddModelError(nameof(model.Json), S["The recipe is written in an incorrect json format."]);
}

if(model.SelectedTenantNames.Count == 0)
{
ModelState.AddModelError(nameof(model.SelectedTenantNames), S["Please select at least one tenant to execute the recipe"]);
}

if (ModelState.IsValid)
{
var tempArchiveFolder = PathExtensions.Combine(Path.GetTempPath(), Path.GetRandomFileName());

try
{
Directory.CreateDirectory(tempArchiveFolder);
System.IO.File.WriteAllText(Path.Combine(tempArchiveFolder, "Recipe.json"), model.Json);
// todo: fix this to only apply for selected tenants
var tenants = _helperService.GetTenantsExceptDefault();
var log = new StringBuilder();

await _helperService.ExecuteForTenants(tenants, async (settings, scope) => {
var deploymentManager = scope.ServiceProvider.GetRequiredService<IDeploymentManager>();
try
{
await deploymentManager.ImportDeploymentPackageAsync(new PhysicalFileProvider(tempArchiveFolder));
}
catch(Exception ex)
{
log.AppendLine(S["An exception occurred while executing the Json for tenant {0}: {1}", settings.Name, ex.Message]);
}
});
if(log.Length > 0)
{
ModelState.AddModelError("summary", log.ToString());
}
else
{
_notifier.Success(H["Recipe successfully executed for selected tenants"]);
}
}
finally
{
if (Directory.Exists(tempArchiveFolder))
{
Directory.Delete(tempArchiveFolder, true);
}
}
}
model.AllTenants = _helperService.GetTenantsExceptDefault().Select(t=> new SelectListItem(t.Name, t.Name, model.SelectedTenantNames.Contains(t.Name))).ToList();

return View(model);
}
}
}
Loading