using Microsoft.AspNetCore.Mvc; using Tiku.Application.Security; namespace Tiku.Api.Security; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] public sealed class RequireSaasFeatureAttribute(string featureCode) : Attribute { public string FeatureCode { get; } = featureCode; } [AttributeUsage(AttributeTargets.Method, AllowMultiple = 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(); var routeRequirements = context.GetEndpoint()?.Metadata.GetOrderedMetadata(); 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); } }