feat(security): add distributed authorization foundation

This commit is contained in:
2026-07-29 10:40:10 +08:00
parent c7f9a4e3c9
commit df88fa19cb
76 changed files with 22020 additions and 88 deletions

View File

@@ -1,6 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Tiku.Application.Security;
namespace Tiku.Api.Security;
@@ -73,7 +74,10 @@ internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext ac
Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null;
}
internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext accessContext) :
internal sealed class TenantPermissionAuthorizationHandler(
ICurrentAccessContext accessContext,
ICapabilityAccessEvaluator capabilityAccessEvaluator,
IHttpContextAccessor httpContextAccessor) :
AuthorizationHandler<TenantPermissionRequirement>
{
protected override async Task HandleRequirementAsync(
@@ -86,11 +90,27 @@ internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext
}
var access = await accessContext.GetAsync();
if (access.HasTenantPermission(requirement.PermissionCode))
var moduleCode = ResolveModuleCode(requirement.PermissionCode);
var operation = IsSafeMethod(httpContextAccessor.HttpContext?.Request.Method)
? CapabilityOperation.Read
: CapabilityOperation.Write;
if (access.TenantId is { } tenantId &&
access.HasTenantPermission(requirement.PermissionCode) &&
await capabilityAccessEvaluator.IsAllowedAsync(tenantId, moduleCode, operation))
{
context.Succeed(requirement);
}
}
private static string ResolveModuleCode(string permissionCode)
{
var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant();
}
private static bool IsSafeMethod(string? method) =>
method is not null &&
(HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method));
}
internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
@@ -176,6 +196,8 @@ public static class AccessAuthorizationServiceCollectionExtensions
{
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.TryAddScoped<ICapabilityAccessEvaluator, CompatibilityCapabilityAccessEvaluator>();
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
@@ -252,3 +274,18 @@ public static class AccessAuthorizationServiceCollectionExtensions
return services;
}
}
internal sealed class CompatibilityCapabilityAccessEvaluator : ICapabilityAccessEvaluator
{
public Task<bool> IsAllowedAsync(
Guid tenantId,
string moduleCode,
CapabilityOperation operation,
CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<IReadOnlySet<string>> GetEnabledModulesAsync(
Guid tenantId,
CapabilityOperation operation = CapabilityOperation.Read,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.Ordinal));
}

View File

@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Routing;
using Tiku.Application.Security;
namespace Tiku.Api.Security;
public sealed record EndpointAuthorizationMetadata(
string Realm,
string? Module,
string? Permission,
CapabilityOperation Operation,
bool RequiresAllDataScope,
string AuditAction);
internal sealed class EndpointAuthorizationMetadataConvention : IApplicationModelConvention
{
public void Apply(ApplicationModel application)
{
foreach (var controller in application.Controllers)
{
foreach (var action in controller.Actions)
{
var anonymous = controller.Attributes.OfType<AllowAnonymousAttribute>().Any() ||
action.Attributes.OfType<AllowAnonymousAttribute>().Any();
if (anonymous)
{
continue;
}
var policies = controller.Attributes.OfType<AuthorizeAttribute>()
.Concat(action.Attributes.OfType<AuthorizeAttribute>())
.Select(attribute => attribute.Policy)
.Where(policy => !string.IsNullOrWhiteSpace(policy))
.Cast<string>()
.ToArray();
var permission = policies.FirstOrDefault(policy =>
BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy));
var realm = permission is not null && BackendPermissions.Platform.Contains(permission) ||
policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal))
? "platform"
: permission is not null && BackendPermissions.Tenant.Contains(permission) ||
policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal))
? "tenant"
: "authenticated";
var module = permission is null ? null : ResolveModule(permission);
var httpMethods = action.Attributes.OfType<HttpMethodAttribute>()
.SelectMany(attribute => attribute.HttpMethods)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var operation = httpMethods.All(IsSafeMethod)
? CapabilityOperation.Read
: CapabilityOperation.Write;
var route = $"{controller.ControllerName}.{action.ActionName}";
var metadata = new EndpointAuthorizationMetadata(
realm,
module,
permission,
operation,
policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) ||
policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal),
$"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}");
foreach (var selector in action.Selectors)
{
selector.EndpointMetadata.Add(metadata);
}
}
}
}
private static string ResolveModule(string permission)
{
var parts = permission.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permission.ToLowerInvariant();
}
private static bool IsSafeMethod(string method) =>
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
}