added API Management for managing site level entity permissions

This commit is contained in:
Shaun Walker 2022-12-02 16:42:43 -05:00
parent 44fc6de82b
commit cb10dde97d
11 changed files with 622 additions and 6 deletions

View File

@ -49,6 +49,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddScoped<IUrlMappingService, UrlMappingService>();
services.AddScoped<IVisitorService, VisitorService>();
services.AddScoped<ISyncService, SyncService>();
services.AddScoped<IApiService, ApiService>();
return services;
}

View File

@ -0,0 +1,75 @@
@namespace Oqtane.Modules.Admin.Apis
@inherits ModuleBase
@inject IApiService ApiService
@inject NavigationManager NavigationManager
@inject IStringLocalizer<Edit> Localizer
@inject IStringLocalizer<SharedResources> SharedLocalizer
<div class="container">
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="entityname" HelpText="The name of the entity" ResourceKey="EntityName">Entity: </Label>
<div class="col-sm-9">
<input id="entityname" class="form-control" @bind="@_entityname" readonly />
</div>
</div>
<br />
<div class="row mb-1 align-items-center">
@if (_permissions != null)
{
<PermissionGrid EntityName="@_entityname" PermissionNames="@_permissionnames" Permissions="@_permissions" @ref="_permissionGrid" />
}
</div>
</div>
<button type="button" class="btn btn-success" @onclick="SaveModuleDefinition">@SharedLocalizer["Save"]</button>
<NavLink class="btn btn-secondary" href="@NavigateUrl()">@SharedLocalizer["Cancel"]</NavLink>
@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);
}
}
}

View File

@ -0,0 +1,36 @@
@namespace Oqtane.Modules.Admin.Apis
@inherits ModuleBase
@inject IApiService ApiService
@inject IStringLocalizer<Index> Localizer
@inject IStringLocalizer<SharedResources> SharedLocalizer
@if (_apis == null)
{
<p><em>@SharedLocalizer["Loading"]</em></p>
}
else
{
<Pager Items="@_apis">
<Header>
<th style="width: 1px;">&nbsp;</th>
<th>@Localizer["Entity"]</th>
<th>@Localizer["Permissions"]</th>
</Header>
<Row>
<td><ActionLink Action="Edit" Parameters="@($"entity=" + context.EntityName)" ResourceKey="Edit" /></td>
<td>@context.EntityName</td>
<td>@context.Permissions</td>
</Row>
</Pager>
}
@code {
private List<Api> _apis;
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Admin;
protected override async Task OnParametersSetAsync()
{
_apis = await ApiService.GetApisAsync(PageState.Site.SiteId);
}
}

View File

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="EntityName.HelpText" xml:space="preserve">
<value>The Name Of The Entity</value>
</data>
<data name="EntityName.Text" xml:space="preserve">
<value>Entity:</value>
</data>
</root>

View File

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Entity" xml:space="preserve">
<value>Entity</value>
</data>
<data name="Permissions" xml:space="preserve">
<value>Permissions</value>
</data>
</root>

View File

@ -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<List<Api>> GetApisAsync(int siteId)
{
return await GetJsonAsync<List<Api>>($"{Apiurl}?siteid={siteId}");
}
public async Task<Api> GetApiAsync(int siteId, string entityName)
{
return await GetJsonAsync<Api>($"{Apiurl}/{siteId}/{entityName}");
}
public async Task UpdateApiAsync(Api api)
{
await PostJsonAsync(Apiurl, api);
}
}
}

View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Oqtane.Models;
namespace Oqtane.Services
{
/// <summary>
/// Service to retrieve and update API information.
/// </summary>
public interface IApiService
{
/// <summary>
/// returns a list of APIs
/// </summary>
/// <returns></returns>
Task<List<Api>> GetApisAsync(int siteId);
/// <summary>
/// returns a specific API
/// </summary>
/// <returns></returns>
Task<Api> GetApiAsync(int siteId, string entityName);
/// <summary>
/// Updates an API
/// </summary>
/// <param name="api"></param>
/// <returns></returns>
Task UpdateApiAsync(Api api);
}
}

View File

@ -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/<controller>?siteid=x
[HttpGet]
[Authorize(Roles = RoleNames.Admin)]
public List<Api> Get(string siteid)
{
int SiteId;
if (int.TryParse(siteid, out SiteId) && SiteId == _alias.SiteId)
{
var apis = new List<Api>();
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<AuthorizeAttribute>().Any());
foreach(var action in actions)
{
// get policy
var policy = action.GetCustomAttribute<AuthorizeAttribute>().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/<controller>/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/<controller>
[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;
}
}
}
}

View File

@ -24,10 +24,11 @@ namespace Oqtane.Controllers
_syncManager = syncManager;
_logger = logger;
_alias = tenantManager.GetAlias();
}
}
// GET: api/<controller>?siteid=x
[HttpGet]
// GET: api/<controller>?siteid=x
[HttpGet]
[Authorize(Policy = $"{EntityNames.Profile}:{PermissionNames.Read}:{RoleNames.Registered}")]
public IEnumerable<Profile> Get(string siteid)
{
int SiteId;
@ -45,6 +46,7 @@ namespace Oqtane.Controllers
// GET api/<controller>/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/<controller>
[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/<controller>/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/<controller>/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);

View File

@ -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<PageTemplate>();
pageTemplates.Add(new PageTemplate
{
Name = "API Management",
Parent = "Admin",
Order = 35,
Path = "admin/apis",
Icon = Icons.CloudDownload,
IsNavigation = true,
IsPersonalizable = false,
PagePermissions = new List<Permission>
{
new Permission(PermissionNames.View, RoleNames.Admin, true),
new Permission(PermissionNames.Edit, RoleNames.Admin, true)
}.EncodePermissions(),
PageTemplateModules = new List<PageTemplateModule>
{
new PageTemplateModule
{
ModuleDefinitionName = typeof(Oqtane.Modules.Admin.Visitors.Index).ToModuleDefinitionName(), Title = "Visitor Management", Pane = PaneNames.Default,
ModulePermissions = new List<Permission>
{
new Permission(PermissionNames.View, RoleNames.Admin, true),
new Permission(PermissionNames.Edit, RoleNames.Admin, true)
}.EncodePermissions(),
Content = ""
}
}
});
var pages = scope.ServiceProvider.GetRequiredService<IPageRepository>();
var sites = scope.ServiceProvider.GetRequiredService<ISiteRepository>();
foreach (Site site in sites.GetSites().ToList())
{
sites.CreatePages(site, pageTemplates);
}
}
}
}

View File

@ -0,0 +1,23 @@
namespace Oqtane.Models
{
/// <summary>
/// API management
/// </summary>
public class Api
{
/// <summary>
/// Reference to a <see cref="Site"/>
/// </summary>
public int SiteId { get; set; }
/// <summary>
/// The Entity Name
/// </summary>
public string EntityName { get; set; }
/// <summary>
/// The permissions for the entity
/// </summary>
public string Permissions { get; set; }
}
}