实现权限访问控制列表

This commit is contained in:
hello 2024-03-27 20:40:28 +08:00
parent 5a170f3024
commit 258781bdc5
17 changed files with 557 additions and 7 deletions

View File

@ -0,0 +1,14 @@
using HelloShop.IdentityService.Entities;
using HelloShop.IdentityService.EntityFrameworks;
using HelloShop.ServiceDefaults.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
namespace HelloShop.IdentityService.Authorization;
public class LocalPermissionChecker(IHttpContextAccessor httpContextAccessor, IdentityServiceDbContext dbContext, IDistributedCache distributedCache) : PermissionChecker(httpContextAccessor, distributedCache)
{
public override async Task<bool> IsGrantedAsync(int roleId, string name, string? resourceType = null, string? resourceId = null)
{
return await dbContext.Set<PermissionGranted>().AsNoTracking().AnyAsync(x => x.RoleId == roleId && x.PermissionName == name && x.ResourceType == resourceType && x.ResourceId == resourceId);
}
}

View File

@ -0,0 +1,46 @@
using HelloShop.IdentityService.Entities;
using HelloShop.IdentityService.EntityFrameworks;
using HelloShop.ServiceDefaults.Authorization;
using HelloShop.ServiceDefaults.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class PermissionsController(IdentityServiceDbContext dbContext) : ControllerBase
{
[HttpGet(nameof(PermissionList))]
public async Task<ActionResult<IEnumerable<PermissionGrantedResponse>>> PermissionList(int? roleId, string? name, string? resourceType = null, string? resourceId = null)
{
var roleIds = HttpContext.User.FindAll(CustomClaimTypes.RoleIdentifier).Select(c => Convert.ToInt32(c.Value));
if (roleId.HasValue && roleIds.Any(x => x == roleId))
{
roleIds = [roleId.Value];
}
List<PermissionGrantedResponse> result = [];
IQueryable<PermissionGranted> queryable = dbContext.Set<PermissionGranted>().Where(x => roleIds.Contains(x.RoleId));
if (!string.IsNullOrWhiteSpace(name))
{
queryable = queryable.Where(x => x.PermissionName == name);
}
var permissionGrants = await queryable.Where(x => x.ResourceType == resourceType && x.ResourceId == resourceId).Distinct().ToListAsync();
result.AddRange(permissionGrants.Select(x => new PermissionGrantedResponse
{
Name = x.PermissionName,
ResourceType = x.ResourceType,
ResourceId = x.ResourceId,
IsGranted = true
}));
return Ok(result);
}
}

View File

@ -1,20 +1,24 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using HelloShop.ServiceDefaults.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace HelloShop.IdentityService.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class TestsController : ControllerBase
public class TestsController(IPermissionChecker permissionChecker, IAuthorizationService authorizationService) : ControllerBase
{
[HttpGet(nameof(Foo))]
[Authorize(IdentityPermissions.Users.Update)]
public IActionResult Foo()
public async Task<IActionResult> Foo()
{
var result = await permissionChecker.IsGrantedAsync(IdentityPermissions.Users.Update, "Order", "1");
var result2 = await authorizationService.AuthorizeAsync(User, IdentityPermissions.Users.Update);
return Ok("Hello, World!");
}

View File

@ -0,0 +1,14 @@
namespace HelloShop.IdentityService.Entities;
public class PermissionGranted
{
public int Id { get; set; }
public int RoleId { get; set; }
public required string PermissionName { get; set; }
public string? ResourceType { get; set; }
public string? ResourceId { get; set; }
}

View File

@ -0,0 +1,21 @@
using HelloShop.IdentityService.Entities;
using Microsoft.EntityFrameworkCore;
namespace HelloShop.IdentityService.EntityFrameworks.EntityConfigurations;
public class PermissionGrantedEntityTypeConfiguration : IEntityTypeConfiguration<PermissionGranted>
{
public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<PermissionGranted> builder)
{
builder.ToTable("PermissionGranted");
builder.Property(x => x.Id);
builder.Property(x => x.PermissionName).HasMaxLength(64);
builder.Property(x => x.ResourceType).HasMaxLength(16);
builder.Property(x => x.ResourceId).HasMaxLength(32);
builder.HasOne<Role>().WithMany().HasForeignKey(x => x.RoleId).IsRequired();
builder.HasIndex(x => new { x.RoleId, x.PermissionName, x.ResourceType, x.ResourceId }).IsUnique();
}
}

View File

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace HelloShop.IdentityService.EntityFrameworks.Migrations
{
[DbContext(typeof(IdentityServiceDbContext))]
[Migration("20240316084118_InitialCreate")]
[Migration("20240327110650_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
@ -25,6 +25,38 @@ namespace HelloShop.IdentityService.EntityFrameworks.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("HelloShop.IdentityService.Entities.PermissionGranted", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("PermissionName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ResourceId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ResourceType")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RoleId", "PermissionName", "ResourceType", "ResourceId")
.IsUnique();
b.ToTable("PermissionGranted", (string)null);
});
modelBuilder.Entity("HelloShop.IdentityService.Entities.Role", b =>
{
b.Property<int>("Id")
@ -262,6 +294,15 @@ namespace HelloShop.IdentityService.EntityFrameworks.Migrations
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("HelloShop.IdentityService.Entities.PermissionGranted", b =>
{
b.HasOne("HelloShop.IdentityService.Entities.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("HelloShop.IdentityService.Entities.Role", null)

View File

@ -55,6 +55,28 @@ namespace HelloShop.IdentityService.EntityFrameworks.Migrations
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PermissionGranted",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<int>(type: "integer", nullable: false),
PermissionName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ResourceType = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
ResourceId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PermissionGranted", x => x.Id);
table.ForeignKey(
name: "FK_PermissionGranted_Roles_RoleId",
column: x => x.RoleId,
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RoleClaims",
columns: table => new
@ -161,6 +183,12 @@ namespace HelloShop.IdentityService.EntityFrameworks.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PermissionGranted_RoleId_PermissionName_ResourceType_Resour~",
table: "PermissionGranted",
columns: new[] { "RoleId", "PermissionName", "ResourceType", "ResourceId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RoleClaims_RoleId",
table: "RoleClaims",
@ -202,6 +230,9 @@ namespace HelloShop.IdentityService.EntityFrameworks.Migrations
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PermissionGranted");
migrationBuilder.DropTable(
name: "RoleClaims");

View File

@ -22,6 +22,38 @@ namespace HelloShop.IdentityService.EntityFrameworks.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("HelloShop.IdentityService.Entities.PermissionGranted", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("PermissionName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ResourceId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ResourceType")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RoleId", "PermissionName", "ResourceType", "ResourceId")
.IsUnique();
b.ToTable("PermissionGranted", (string)null);
});
modelBuilder.Entity("HelloShop.IdentityService.Entities.Role", b =>
{
b.Property<int>("Id")
@ -259,6 +291,15 @@ namespace HelloShop.IdentityService.EntityFrameworks.Migrations
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("HelloShop.IdentityService.Entities.PermissionGranted", b =>
{
b.HasOne("HelloShop.IdentityService.Entities.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("HelloShop.IdentityService.Entities.Role", null)

View File

@ -1,8 +1,10 @@
using HelloShop.IdentityService;
using HelloShop.IdentityService.Authorization;
using HelloShop.IdentityService.Constants;
using HelloShop.IdentityService.DataSeeding;
using HelloShop.IdentityService.Entities;
using HelloShop.IdentityService.EntityFrameworks;
using HelloShop.ServiceDefaults.Authorization;
using HelloShop.ServiceDefaults.Extensions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
@ -54,6 +56,10 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddDataSeedingProviders();
builder.Services.AddOpenApi();
builder.Services.AddPermissionDefinitions();
builder.Services.AddHttpClient().AddHttpContextAccessor().AddRemotePermissionChecker(options =>
{
options.ApiEndpoint = "https://localhost:5001/api/Permissions/PermissionList";
});
var app = builder.Build();

View File

@ -0,0 +1,10 @@
using System.Security.Claims;
namespace HelloShop.ServiceDefaults.Authorization;
public interface IPermissionChecker
{
Task<bool> IsGrantedAsync(string name, string? resourceType = null, string? resourceId = null);
Task<bool> IsGrantedAsync(ClaimsPrincipal claimsPrincipal, string name, string? resourceType = null, string? resourceId = null);
}

View File

@ -0,0 +1,49 @@
using HelloShop.ServiceDefaults.Constants;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
using System.Security.Claims;
namespace HelloShop.ServiceDefaults.Authorization;
public abstract class PermissionChecker(IHttpContextAccessor httpContextAccessor, IDistributedCache distributedCache) : IPermissionChecker
{
protected HttpContext HttpContext { get; init; } = httpContextAccessor.HttpContext ?? throw new InvalidOperationException();
public async Task<bool> IsGrantedAsync(string name, string? resourceType = null, string? resourceId = null) => await IsGrantedAsync(HttpContext.User, name, resourceType, resourceId);
public async Task<bool> IsGrantedAsync(ClaimsPrincipal claimsPrincipal, string name, string? resourceType = null, string? resourceId = null)
{
var roleIds = claimsPrincipal.FindAll(CustomClaimTypes.RoleIdentifier).Select(c => Convert.ToInt32(c.Value)).ToArray();
foreach (var roleId in roleIds)
{
var cacheKey = PermissionGrantCacheItem.CreateCacheKey(roleId, name, resourceType, resourceId);
if (distributedCache.TryGetValue(cacheKey, out PermissionGrantCacheItem? cacheItem) && cacheItem != null)
{
if (cacheItem.IsGranted)
{
return true;
}
continue;
}
bool isGranted = await IsGrantedAsync(roleId, name, resourceType, resourceId);
await distributedCache.SetObjectAsync(cacheKey, new PermissionGrantCacheItem(isGranted), new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(10)
});
if (isGranted)
{
return true;
}
}
return false;
}
public abstract Task<bool> IsGrantedAsync(int roleId, string name, string? resourceType = null, string? resourceId = null);
}

View File

@ -0,0 +1,23 @@
namespace HelloShop.ServiceDefaults.Authorization;
public class PermissionGrantCacheItem(bool isGranted = false)
{
public bool IsGranted { get; set; } = isGranted;
public static string CreateCacheKey(int roleId, string name, string? resourceType = null, string? resourceId = null)
{
string cacheKey = $"acl:role:{roleId}:{name}";
if (!string.IsNullOrWhiteSpace(resourceType))
{
cacheKey += $":{resourceType}";
}
if (!string.IsNullOrWhiteSpace(resourceId))
{
cacheKey += $":{resourceId}";
}
return cacheKey;
}
}

View File

@ -0,0 +1,12 @@
namespace HelloShop.ServiceDefaults.Authorization;
public class PermissionGrantedResponse
{
public required string Name { get; set; }
public string? ResourceType { get; set; }
public string? ResourceId { get; set; }
public bool IsGranted { get; set; }
}

View File

@ -0,0 +1,48 @@

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace HelloShop.ServiceDefaults.Authorization;
public class RemotePermissionChecker(IHttpContextAccessor httpContextAccessor, IDistributedCache distributedCache, IHttpClientFactory httpClientFactory, IOptions<RemotePermissionCheckerOptions> options) : PermissionChecker(httpContextAccessor, distributedCache)
{
private readonly RemotePermissionCheckerOptions _remotePermissionCheckerOptions = options.Value;
public override async Task<bool> IsGrantedAsync(int roleId, string name, string? resourceType = null, string? resourceId = null)
{
string? accessToken = await HttpContext.GetTokenAsync("access_token");
HttpClient httpClient = httpClientFactory.CreateClient();
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
httpClient.BaseAddress = new Uri(_remotePermissionCheckerOptions.ApiEndpoint);
Dictionary<string, string?> parameters = new()
{
{ nameof(roleId), roleId.ToString() },
{ nameof(name), name },
{ nameof(resourceType) , resourceType },
{ nameof(resourceId), resourceId }
};
string queryString = QueryHelpers.AddQueryString(string.Empty, parameters);
var permissionGrants = httpClient.GetFromJsonAsAsyncEnumerable<PermissionGrantedResponse>(queryString);
await foreach (var permissionGrant in permissionGrants)
{
if (permissionGrant != null && permissionGrant.IsGranted)
{
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,6 @@
namespace HelloShop.ServiceDefaults.Authorization;
public class RemotePermissionCheckerOptions
{
public string ApiEndpoint { get; set; } = default!;
}

View File

@ -0,0 +1,174 @@
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
namespace Microsoft.Extensions.Caching.Distributed;
public static class CustomDistributedCacheExtensions
{
/// <summary>
/// Sets a string in the specified cache with the specified key.
/// </summary>
/// <param name="cache">The cache in which to store the data.</param>
/// <param name="key">The key to store the data in.</param>
/// <param name="value">The data to store in the cache.</param>
/// <exception cref="System.ArgumentNullException">Thrown when <paramref name="key"/> or <paramref name="value"/> is null.</exception>
public static void SetObject<TItem>(this IDistributedCache cache, string key, TItem value)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
cache.Set(key, bytes);
}
/// <summary>
/// Sets a string in the specified cache with the specified key.
/// </summary>
/// <param name="cache">The cache in which to store the data.</param>
/// <param name="key">The key to store the data in.</param>
/// <param name="value">The data to store in the cache.</param>
/// <param name="options">The cache options for the entry.</param>
/// <exception cref="System.ArgumentNullException">Thrown when <paramref name="key"/> or <paramref name="value"/> is null.</exception>
public static void SetObject<TItem>(this IDistributedCache cache, string key, TItem value, DistributedCacheEntryOptions options)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
cache.Set(key, bytes, options);
}
/// <summary>
/// Asynchronously sets a string in the specified cache with the specified key.
/// </summary>
/// <param name="cache">The cache in which to store the data.</param>
/// <param name="key">The key to store the data in.</param>
/// <param name="value">The data to store in the cache.</param>
/// <param name="token">Optional. A <see cref="CancellationToken" /> to cancel the operation.</param>
/// <returns>A task that represents the asynchronous set operation.</returns>
/// <exception cref="System.ArgumentNullException">Thrown when <paramref name="key"/> or <paramref name="value"/> is null.</exception>
public static Task SetObjectAsync<TItem>(this IDistributedCache cache, string key, TItem value, CancellationToken token = default)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
return cache.SetAsync(key, bytes, new DistributedCacheEntryOptions(), token);
}
/// <summary>
/// Asynchronously sets a string in the specified cache with the specified key.
/// </summary>
/// <param name="cache">The cache in which to store the data.</param>
/// <param name="key">The key to store the data in.</param>
/// <param name="value">The data to store in the cache.</param>
/// <param name="options">The cache options for the entry.</param>
/// <param name="token">Optional. A <see cref="CancellationToken" /> to cancel the operation.</param>
/// <returns>A task that represents the asynchronous set operation.</returns>
/// <exception cref="System.ArgumentNullException">Thrown when <paramref name="key"/> or <paramref name="value"/> is null.</exception>
public static Task SetObjectAsync<TItem>(this IDistributedCache cache, string key, TItem value, DistributedCacheEntryOptions options, CancellationToken token = default)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
return cache.SetAsync(key, bytes, options, token);
}
/// <summary>
/// Gets a string from the specified cache with the specified key.
/// </summary>
/// <param name="cache">The cache in which to store the data.</param>
/// <param name="key">The key to get the stored data for.</param>
/// <returns>The T value from the stored cache key.</returns>
public static TItem? GetObject<TItem>(this IDistributedCache cache, string key)
{
byte[]? data = cache.Get(key);
if (data == null)
{
return default;
}
return JsonSerializer.Deserialize<TItem>(data);
}
/// <summary>
/// Asynchronously gets a string from the specified cache with the specified key.
/// </summary>
/// <param name="cache">The cache in which to store the data.</param>
/// <param name="key">The key to get the stored data for.</param>
/// <param name="token">Optional. A <see cref="CancellationToken" /> to cancel the operation.</param>
/// <returns>A task that gets the T value from the stored cache key.</returns>
public static async Task<TItem?> GetObjectAsync<TItem>(this IDistributedCache cache, string key, CancellationToken token = default)
{
byte[]? data = await cache.GetAsync(key, token).ConfigureAwait(false);
if (data == null)
{
return default;
}
return JsonSerializer.Deserialize<TItem>(data);
}
/// <summary>
/// Try to get the value associated with the given key.
/// </summary>
/// <typeparam name="TItem">The type of the object to get.</typeparam>
/// <param name="cache">The <see cref="IDistributedCache"/> instance this method extends.</param>
/// <param name="key">The key of the value to get.</param>
/// <param name="value">The value associated with the given key.</param>
/// <returns><c>true</c> if the key was found. <c>false</c> otherwise.</returns>
public static bool TryGetValue<TItem>(this IDistributedCache cache, string key, out TItem? value)
{
var data = cache.Get(key);
value = default;
try
{
value = JsonSerializer.Deserialize<TItem>(data);
}
catch
{
return false;
}
return true;
}
/// <summary>
/// Gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
/// </summary>
/// <typeparam name="TItem">The type of the object to get.</typeparam>
/// <param name="cache">The <see cref="IDistributedCache"/> instance this method extends.</param>
/// <param name="key">The key of the entry to look for or create.</param>
/// <param name="factory">The factory that creates the value associated with this key if the key does not exist in the cache.</param>
/// <param name="createOptions">The options to be applied to the <see cref="ICacheEntry"/> if the key does not exist in the cache.</param>
/// <returns>The value associated with this key.</returns>
public static TItem? GetOrCreate<TItem>(this IDistributedCache cache, string key, Func<DistributedCacheEntryOptions, TItem> factory, DistributedCacheEntryOptions? createOptions = null)
{
if (!cache.TryGetValue(key, out object? result))
{
createOptions ??= new DistributedCacheEntryOptions();
result = factory(createOptions);
cache.SetObject(key, result, createOptions);
}
return (TItem?)result;
}
/// <summary>
/// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
/// </summary>
/// <typeparam name="TItem">The type of the object to get.</typeparam>
/// <param name="cache">The <see cref="IDistributedCache"/> instance this method extends.</param>
/// <param name="key">The key of the entry to look for or create.</param>
/// <param name="factory">The factory task that creates the value associated with this key if the key does not exist in the cache.</param>
/// <param name="createOptions">The options to be applied to the <see cref="ICacheEntry"/> if the key does not exist in the cache.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
public static async Task<TItem?> GetOrCreateAsync<TItem>(this IDistributedCache cache, string key, Func<DistributedCacheEntryOptions, Task<TItem>> factory, DistributedCacheEntryOptions? createOptions = null)
{
if (!cache.TryGetValue(key, out object? result))
{
createOptions ??= new DistributedCacheEntryOptions();
result = await factory(createOptions).ConfigureAwait(false);
await cache.SetObjectAsync(key, result, createOptions);
}
return (TItem?)result;
}
}

View File

@ -1,4 +1,5 @@
using HelloShop.ServiceDefaults.Permissions;
using HelloShop.ServiceDefaults.Authorization;
using HelloShop.ServiceDefaults.Permissions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@ -62,4 +63,13 @@ public static class PermissionExtensions
return routeGroup;
}
public static IServiceCollection AddRemotePermissionChecker(this IServiceCollection services, Action<RemotePermissionCheckerOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddTransient<IPermissionChecker, RemotePermissionChecker>();
return services;
}
}