diff --git a/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs index bc1d4780..d34aa575 100644 --- a/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs @@ -49,6 +49,7 @@ namespace Microsoft.Extensions.DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Oqtane.Client/Modules/Admin/Api/Edit.razor b/Oqtane.Client/Modules/Admin/Api/Edit.razor new file mode 100644 index 00000000..e451fec8 --- /dev/null +++ b/Oqtane.Client/Modules/Admin/Api/Edit.razor @@ -0,0 +1,75 @@ +@namespace Oqtane.Modules.Admin.Apis +@inherits ModuleBase +@inject IApiService ApiService +@inject NavigationManager NavigationManager +@inject IStringLocalizer Localizer +@inject IStringLocalizer SharedLocalizer + +
+
+ +
+ +
+
+
+
+ @if (_permissions != null) + { + + } +
+
+ +@SharedLocalizer["Cancel"] + +@code { + private string _entityname; + private string _permissionnames; + private string _permissions; + +#pragma warning disable 649 + private PermissionGrid _permissionGrid; +#pragma warning restore 649 + + public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Admin; + + protected override async Task OnInitializedAsync() + { + try + { + _entityname = PageState.QueryString["entity"]; + var api = await ApiService.GetApiAsync(PageState.Site.SiteId, _entityname); + if (api != null) + { + var apis = await ApiService.GetApisAsync(PageState.Site.SiteId); + _permissionnames = apis.SingleOrDefault(item => item.EntityName == _entityname).Permissions; + _permissions = api.Permissions; + } + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Loading API {EntityName} {Error}", _entityname, ex.Message); + AddModuleMessage(Localizer["Error.Module.Load"], MessageType.Error); + } + } + + private async Task SaveModuleDefinition() + { + try + { + var api = new Api(); + api.SiteId = PageState.Site.SiteId; + api.EntityName = _entityname; + api.Permissions = _permissionGrid.GetPermissions(); + await ApiService.UpdateApiAsync(api); + await logger.LogInformation("API Saved {Api}", api); + NavigationManager.NavigateTo(NavigateUrl()); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Saving Api {EntityName} {Error}", _entityname, ex.Message); + AddModuleMessage(Localizer["Error.Module.Save"], MessageType.Error); + } + } +} diff --git a/Oqtane.Client/Modules/Admin/Api/Index.razor b/Oqtane.Client/Modules/Admin/Api/Index.razor new file mode 100644 index 00000000..539a8479 --- /dev/null +++ b/Oqtane.Client/Modules/Admin/Api/Index.razor @@ -0,0 +1,36 @@ +@namespace Oqtane.Modules.Admin.Apis +@inherits ModuleBase +@inject IApiService ApiService +@inject IStringLocalizer Localizer +@inject IStringLocalizer SharedLocalizer + +@if (_apis == null) +{ +

@SharedLocalizer["Loading"]

+} +else +{ + +
+   + @Localizer["Entity"] + @Localizer["Permissions"] +
+ + + @context.EntityName + @context.Permissions + +
+} + +@code { + private List _apis; + + public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Admin; + + protected override async Task OnParametersSetAsync() + { + _apis = await ApiService.GetApisAsync(PageState.Site.SiteId); + } +} diff --git a/Oqtane.Client/Resources/Modules/Admin/Api/Edit.resx b/Oqtane.Client/Resources/Modules/Admin/Api/Edit.resx new file mode 100644 index 00000000..f9be0bf8 --- /dev/null +++ b/Oqtane.Client/Resources/Modules/Admin/Api/Edit.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The Name Of The Entity + + + Entity: + + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/Api/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Api/Index.resx new file mode 100644 index 00000000..e02d343e --- /dev/null +++ b/Oqtane.Client/Resources/Modules/Admin/Api/Index.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Entity + + + Permissions + + \ No newline at end of file diff --git a/Oqtane.Client/Services/ApiService.cs b/Oqtane.Client/Services/ApiService.cs new file mode 100644 index 00000000..1c05c7ec --- /dev/null +++ b/Oqtane.Client/Services/ApiService.cs @@ -0,0 +1,32 @@ +using System.Net.Http; +using System.Threading.Tasks; +using System.Collections.Generic; +using Oqtane.Documentation; +using Oqtane.Shared; +using Oqtane.Models; + +namespace Oqtane.Services +{ + [PrivateApi("Don't show in the documentation, as everything should use the Interface")] + public class ApiService : ServiceBase, IApiService + { + public ApiService(HttpClient http, SiteState siteState) : base(http, siteState) { } + + private string Apiurl => CreateApiUrl("Api"); + + public async Task> GetApisAsync(int siteId) + { + return await GetJsonAsync>($"{Apiurl}?siteid={siteId}"); + } + + public async Task GetApiAsync(int siteId, string entityName) + { + return await GetJsonAsync($"{Apiurl}/{siteId}/{entityName}"); + } + + public async Task UpdateApiAsync(Api api) + { + await PostJsonAsync(Apiurl, api); + } + } +} diff --git a/Oqtane.Client/Services/Interfaces/IApiService.cs b/Oqtane.Client/Services/Interfaces/IApiService.cs new file mode 100644 index 00000000..845061f9 --- /dev/null +++ b/Oqtane.Client/Services/Interfaces/IApiService.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Oqtane.Models; + +namespace Oqtane.Services +{ + /// + /// Service to retrieve and update API information. + /// + public interface IApiService + { + /// + /// returns a list of APIs + /// + /// + Task> GetApisAsync(int siteId); + + /// + /// returns a specific API + /// + /// + Task GetApiAsync(int siteId, string entityName); + + /// + /// Updates an API + /// + /// + /// + Task UpdateApiAsync(Api api); + } +} diff --git a/Oqtane.Server/Controllers/ApiController.cs b/Oqtane.Server/Controllers/ApiController.cs new file mode 100644 index 00000000..b8a0165f --- /dev/null +++ b/Oqtane.Server/Controllers/ApiController.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using System.Collections.Generic; +using Oqtane.Shared; +using Oqtane.Models; +using Oqtane.Infrastructure; +using Oqtane.Enums; +using System.Net; +using Oqtane.Repository; +using Oqtane.Extensions; +using System.Reflection; +using System; +using System.Linq; + +namespace Oqtane.Controllers +{ + [Route(ControllerRoutes.ApiRoute)] + public class ApiController : Controller + { + private readonly IPermissionRepository _permissions; + private readonly ILogManager _logger; + private readonly Alias _alias; + + public ApiController(IPermissionRepository permissions, ILogManager logger, ITenantManager tenantManager) + { + _permissions = permissions; + _logger = logger; + _alias = tenantManager.GetAlias(); + } + + // GET: api/?siteid=x + [HttpGet] + [Authorize(Roles = RoleNames.Admin)] + public List Get(string siteid) + { + int SiteId; + if (int.TryParse(siteid, out SiteId) && SiteId == _alias.SiteId) + { + var apis = new List(); + + var assemblies = AppDomain.CurrentDomain.GetOqtaneAssemblies(); + foreach (var assembly in assemblies) + { + // iterate controllers + foreach (var type in assembly.GetTypes().Where(type => typeof(Controller).IsAssignableFrom(type))) + { + // iterate controller methods with authorize attribute + var actions = type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.GetCustomAttributes().Any()); + foreach(var action in actions) + { + // get policy + var policy = action.GetCustomAttribute().Policy; + if (!string.IsNullOrEmpty(policy) && policy.Contains(":") && !policy.Contains(Constants.RequireEntityId)) + { + // parse policy + var segments = policy.Split(':'); + if (!apis.Any(item => item.EntityName == segments[0])) + { + apis.Add(new Api { SiteId = SiteId, EntityName = segments[0], Permissions = segments[1] }); + } + else + { + // concatenate permissions + var permissions = apis.SingleOrDefault(item => item.EntityName == segments[0]).Permissions; + if (!permissions.Split(',').Contains(segments[1])) + { + apis.SingleOrDefault(item => item.EntityName == segments[0]).Permissions += "," + segments[1]; + } + } + } + } + } + } + + return apis; + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Api Get Attempt {SiteId}", siteid); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return null; + } + } + + // GET: api//1/user + [HttpGet("{siteid}/{entityname}")] + [Authorize(Roles = RoleNames.Admin)] + public Api Get(int siteid, string entityname) + { + if (siteid == _alias.SiteId) + { + return new Api { SiteId = siteid, EntityName = entityname, Permissions = _permissions.GetPermissions(siteid, entityname).EncodePermissions() }; + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Api Get Attempt {SiteId} {EntityName}", siteid, entityname); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return null; + } + } + + // POST: api/ + [HttpPost] + [Authorize(Roles = RoleNames.Admin)] + public void Post([FromBody] Api api) + { + if (ModelState.IsValid && api.SiteId == _alias.SiteId) + { + _permissions.UpdatePermissions(api.SiteId, api.EntityName, -1, api.Permissions); + _logger.Log(LogLevel.Information, this, LogFunction.Update, "Api Updated {Api}", api); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Api Post Attempt {Api}", api); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + } + } + } +} diff --git a/Oqtane.Server/Controllers/ProfileController.cs b/Oqtane.Server/Controllers/ProfileController.cs index 60becc5f..66d9e788 100644 --- a/Oqtane.Server/Controllers/ProfileController.cs +++ b/Oqtane.Server/Controllers/ProfileController.cs @@ -24,10 +24,11 @@ namespace Oqtane.Controllers _syncManager = syncManager; _logger = logger; _alias = tenantManager.GetAlias(); - } + } - // GET: api/?siteid=x - [HttpGet] + // GET: api/?siteid=x + [HttpGet] + [Authorize(Policy = $"{EntityNames.Profile}:{PermissionNames.Read}:{RoleNames.Registered}")] public IEnumerable Get(string siteid) { int SiteId; @@ -45,6 +46,7 @@ namespace Oqtane.Controllers // GET api//5 [HttpGet("{id}")] + [Authorize(Policy = $"{EntityNames.Profile}:{PermissionNames.Read}:{RoleNames.Registered}")] public Profile Get(int id) { var profile = _profiles.GetProfile(id); @@ -62,7 +64,7 @@ namespace Oqtane.Controllers // POST api/ [HttpPost] - [Authorize(Roles = RoleNames.Admin)] + [Authorize(Policy = $"{EntityNames.Profile}:{PermissionNames.Write}:{RoleNames.Admin}")] public Profile Post([FromBody] Profile profile) { if (ModelState.IsValid && profile.SiteId == _alias.SiteId) @@ -82,7 +84,7 @@ namespace Oqtane.Controllers // PUT api//5 [HttpPut("{id}")] - [Authorize(Roles = RoleNames.Admin)] + [Authorize(Policy = $"{EntityNames.Profile}:{PermissionNames.Write}:{RoleNames.Admin}")] public Profile Put(int id, [FromBody] Profile profile) { if (ModelState.IsValid && profile.SiteId == _alias.SiteId && _profiles.GetProfile(profile.ProfileId, false) != null) @@ -102,7 +104,7 @@ namespace Oqtane.Controllers // DELETE api//5 [HttpDelete("{id}")] - [Authorize(Roles = RoleNames.Admin)] + [Authorize(Policy = $"{EntityNames.Profile}:{PermissionNames.Write}:{RoleNames.Admin}")] public void Delete(int id) { var profile = _profiles.GetProfile(id); diff --git a/Oqtane.Server/Infrastructure/UpgradeManager.cs b/Oqtane.Server/Infrastructure/UpgradeManager.cs index 2a108e81..3e7c0231 100644 --- a/Oqtane.Server/Infrastructure/UpgradeManager.cs +++ b/Oqtane.Server/Infrastructure/UpgradeManager.cs @@ -60,6 +60,9 @@ namespace Oqtane.Infrastructure case "3.2.1": Upgrade_3_2_1(tenant, scope); break; + case "3.3.0": + Upgrade_3_3_0(tenant, scope); + break; } } } @@ -303,5 +306,46 @@ namespace Oqtane.Infrastructure } } + private void Upgrade_3_3_0(Tenant tenant, IServiceScope scope) + { + var pageTemplates = new List(); + + pageTemplates.Add(new PageTemplate + { + Name = "API Management", + Parent = "Admin", + Order = 35, + Path = "admin/apis", + Icon = Icons.CloudDownload, + IsNavigation = true, + IsPersonalizable = false, + PagePermissions = new List + { + new Permission(PermissionNames.View, RoleNames.Admin, true), + new Permission(PermissionNames.Edit, RoleNames.Admin, true) + }.EncodePermissions(), + PageTemplateModules = new List + { + new PageTemplateModule + { + ModuleDefinitionName = typeof(Oqtane.Modules.Admin.Visitors.Index).ToModuleDefinitionName(), Title = "Visitor Management", Pane = PaneNames.Default, + ModulePermissions = new List + { + new Permission(PermissionNames.View, RoleNames.Admin, true), + new Permission(PermissionNames.Edit, RoleNames.Admin, true) + }.EncodePermissions(), + Content = "" + } + } + }); + + var pages = scope.ServiceProvider.GetRequiredService(); + + var sites = scope.ServiceProvider.GetRequiredService(); + foreach (Site site in sites.GetSites().ToList()) + { + sites.CreatePages(site, pageTemplates); + } + } } } diff --git a/Oqtane.Shared/Models/Api.cs b/Oqtane.Shared/Models/Api.cs new file mode 100644 index 00000000..0f3c6b68 --- /dev/null +++ b/Oqtane.Shared/Models/Api.cs @@ -0,0 +1,23 @@ +namespace Oqtane.Models +{ + /// + /// API management + /// + public class Api + { + /// + /// Reference to a + /// + public int SiteId { get; set; } + + /// + /// The Entity Name + /// + public string EntityName { get; set; } + + /// + /// The permissions for the entity + /// + public string Permissions { get; set; } + } +}