forked from gongxuegit/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
113
Tiku.Api/Security/SaasFeatureEndpointMetadata.cs
Normal file
113
Tiku.Api/Security/SaasFeatureEndpointMetadata.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user