feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -76,7 +76,7 @@ internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext ac
internal sealed class TenantPermissionAuthorizationHandler(
ICurrentAccessContext accessContext,
ICapabilityAccessEvaluator capabilityAccessEvaluator,
IFeatureAccessService featureAccessService,
IHttpContextAccessor httpContextAccessor) :
AuthorizationHandler<TenantPermissionRequirement>
{
@@ -90,24 +90,18 @@ internal sealed class TenantPermissionAuthorizationHandler(
}
var access = await accessContext.GetAsync();
var moduleCode = ResolveModuleCode(requirement.PermissionCode);
var operation = IsSafeMethod(httpContextAccessor.HttpContext?.Request.Method)
? CapabilityOperation.Read
: CapabilityOperation.Write;
? FeatureAccessOperation.Read
: FeatureAccessOperation.Write;
if (access.TenantId is { } tenantId &&
access.HasTenantPermission(requirement.PermissionCode) &&
await capabilityAccessEvaluator.IsAllowedAsync(tenantId, moduleCode, operation))
(await featureAccessService.FilterPermissionCodesAsync(
tenantId, [requirement.PermissionCode], operation)).Contains(requirement.PermissionCode))
{
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));
@@ -197,7 +191,7 @@ public static class AccessAuthorizationServiceCollectionExtensions
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.TryAddScoped<ICapabilityAccessEvaluator, CompatibilityCapabilityAccessEvaluator>();
services.TryAddScoped<IFeatureAccessService, CompatibilityFeatureAccessService>();
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
@@ -241,6 +235,13 @@ public static class AccessAuthorizationServiceCollectionExtensions
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantContentManage),
new AllDataScopeRequirement()));
options.AddPolicy(
TikuPolicies.TenantAllDataScope,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new AllDataScopeRequirement()));
options.AddPolicy(
TikuPolicies.TenantCommerceOperateAllScope,
policy => policy
@@ -275,17 +276,21 @@ public static class AccessAuthorizationServiceCollectionExtensions
}
}
internal sealed class CompatibilityCapabilityAccessEvaluator : ICapabilityAccessEvaluator
internal sealed class CompatibilityFeatureAccessService : IFeatureAccessService
{
public Task<bool> IsAllowedAsync(
Guid tenantId,
string moduleCode,
CapabilityOperation operation,
CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<FeatureAccessDecision> EvaluateAsync(Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) =>
Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation));
public Task<IReadOnlySet<string>> GetEnabledModulesAsync(
Guid tenantId,
CapabilityOperation operation = CapabilityOperation.Read,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.Ordinal));
public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(Guid tenantId, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(SaasFeatureCatalog.All, StringComparer.Ordinal));
public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(Guid tenantId, IEnumerable<string> permissionCodes, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlySet<string>>(permissionCodes.ToHashSet(StringComparer.Ordinal));
public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyCollection<FeatureQuotaSnapshot>>([]);
public Task<bool> TryConsumeQuotaAsync(Guid tenantId, string metricCode, long amount, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task ReleaseQuotaAsync(Guid tenantId, string metricCode, long amount, CancellationToken cancellationToken = default) => Task.CompletedTask;
}

View File

@@ -8,6 +8,7 @@ namespace Tiku.Api.Security;
public sealed record EndpointAuthorizationMetadata(
string Realm,
string? Module,
IReadOnlyList<string> RequiredFeatures,
string? Permission,
CapabilityOperation Operation,
bool RequiresAllDataScope,
@@ -43,7 +44,20 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode
policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal))
? "tenant"
: "authenticated";
var module = permission is null ? null : ResolveModule(permission);
var module = permission is null ? null : PermissionModuleCatalog.ResolvePermissionModuleCode(permission);
var requiredFeatures = controller.Attributes.OfType<RequireSaasFeatureAttribute>()
.Concat(action.Attributes.OfType<RequireSaasFeatureAttribute>())
.Select(attribute => attribute.FeatureCode)
.Concat(module is not null &&
PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var moduleFeature) &&
moduleFeature is not null
? [moduleFeature]
: [])
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.Concat(action.Attributes.OfType<RequireSaasFeatureFromRouteAttribute>()
.Select(attribute => $"route:{attribute.RouteValueName}"))
.ToArray();
var httpMethods = action.Attributes.OfType<HttpMethodAttribute>()
.SelectMany(attribute => attribute.HttpMethods)
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -55,9 +69,11 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode
var metadata = new EndpointAuthorizationMetadata(
realm,
module,
requiredFeatures,
permission,
operation,
policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) ||
policies.Contains(TikuPolicies.TenantAllDataScope, StringComparer.Ordinal) ||
policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal),
$"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}");
foreach (var selector in action.Selectors)
@@ -68,12 +84,6 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode
}
}
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);
}

View 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);
}
}