Files
tiku-backend.net/Tiku.Api/Security/SaasFeatureEndpointMetadata.cs

114 lines
4.2 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Tiku.Application.Security;
namespace Tiku.Api.Security;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class RequireSaasFeatureAttribute(string featureCode) : Attribute
{
public string FeatureCode { get; } = featureCode;
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class RequireSaasFeatureFromRouteAttribute(string routeValueName) : Attribute
{
public string RouteValueName { get; } = routeValueName;
}
public sealed class SaasFeatureAccessMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
ITenantContext tenantContext,
IFeatureAccessService featureAccessService)
{
var requirements = context.GetEndpoint()?.Metadata.GetOrderedMetadata<RequireSaasFeatureAttribute>();
var routeRequirements = context.GetEndpoint()?.Metadata.GetOrderedMetadata<RequireSaasFeatureFromRouteAttribute>();
if ((requirements is null || requirements.Count == 0) &&
(routeRequirements is null || routeRequirements.Count == 0))
{
await next(context);
return;
}
if (!tenantContext.IsResolved || tenantContext.IsSystem || tenantContext.TenantId is not { } tenantId)
{
// Some platform-hosted public entry points resolve their tenant from a
// signed code inside the application service. They must keep their
// existing 404 semantics when no request tenant has been established.
await next(context);
return;
}
var operation = HttpMethods.IsGet(context.Request.Method) ||
HttpMethods.IsHead(context.Request.Method) ||
HttpMethods.IsOptions(context.Request.Method)
? FeatureAccessOperation.Read
: FeatureAccessOperation.Write;
foreach (var requirement in (requirements ?? []).DistinctBy(value => value.FeatureCode, StringComparer.Ordinal))
{
var decision = await featureAccessService.EvaluateAsync(
tenantId,
requirement.FeatureCode,
operation,
context.RequestAborted);
if (!decision.Allowed)
{
await WriteForbiddenAsync(
context,
decision.DenialCode ?? "feature_not_available",
$"The SaaS feature '{requirement.FeatureCode}' is not available.");
return;
}
}
foreach (var requirement in routeRequirements ?? [])
{
var routeValue = context.Request.RouteValues[requirement.RouteValueName]?.ToString();
var featureCode = ResolveRouteFeature(routeValue);
if (featureCode is null)
{
await WriteForbiddenAsync(context, "feature_route_value_invalid", "The requested content module is not available.");
return;
}
var decision = await featureAccessService.EvaluateAsync(
tenantId,
featureCode,
operation,
context.RequestAborted);
if (!decision.Allowed)
{
await WriteForbiddenAsync(
context,
decision.DenialCode ?? "feature_not_available",
$"The SaaS feature '{featureCode}' is not available.");
return;
}
}
await next(context);
}
private static Task WriteForbiddenAsync(HttpContext context, string code, string title)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = title,
Instance = context.Request.Path,
Extensions =
{
["code"] = code,
["traceId"] = context.TraceIdentifier
}
});
}
internal static string? ResolveRouteFeature(string? routeValue)
{
return SaasFeatureCatalog.ResolveContentImportFeature(routeValue);
}
}