Introduces a non-breaking, multi-instance authorization-check pipeline that runs alongside the existing single-instance auth services. Motivation - Cross-cutting checks (proof-of-work, mobile attestation, rate-limit gates, IP allow-lists) don't belong in consumer auth services — they ship from framework modules and self-apply via attributes. - The existing ICommandAuthorizationService takes only a Type; checks need the request *instance* to read payload fields (e.g. an Altcha solution carried on the command). Shape - New abstractions: ICommandAuthorizationCheck, IQueryAuthorizationCheck, CommandAuthorizationCheckContext, QueryAuthorizationCheckContext. - Context carries (Type, Instance, IServiceProvider, Items dict). The Items dict lets sibling checks signal one another — e.g. a future mobile-attestation check stamps "mobile_attested" for the Altcha check to read as a bypass. - AND semantics: framework resolves IEnumerable<…Check>, runs each in registration order, first non-Allowed short-circuits. - Wired into MinimalApi (commands + queries, POST + GET) and the Svrnty.CQRS.Grpc.Generators source generator (commands, queries, dynamic queries). In all paths the checks run AFTER the instance is materialized and validated, BEFORE handler invocation. Backward compatibility - No registered checks = today's behavior exactly. - ICommandAuthorizationService / IQueryAuthorizationService signatures unchanged; consumers' existing auth services keep working untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
341 lines
14 KiB
C#
341 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Svrnty.CQRS.Abstractions;
|
|
using Svrnty.CQRS.Abstractions.Attributes;
|
|
using Svrnty.CQRS.Abstractions.Discovery;
|
|
using Svrnty.CQRS.Abstractions.Security;
|
|
|
|
namespace Svrnty.CQRS.MinimalApi;
|
|
|
|
public static class EndpointRouteBuilderExtensions
|
|
{
|
|
private static async Task<IResult?> RunCommandChecksAsync(
|
|
IServiceProvider serviceProvider,
|
|
Type commandType,
|
|
object command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var checks = serviceProvider.GetServices<ICommandAuthorizationCheck>().ToList();
|
|
if (checks.Count == 0)
|
|
return null;
|
|
|
|
var context = new CommandAuthorizationCheckContext
|
|
{
|
|
CommandType = commandType,
|
|
Command = command,
|
|
Services = serviceProvider
|
|
};
|
|
|
|
foreach (var check in checks)
|
|
{
|
|
var result = await check.CheckAsync(context, cancellationToken);
|
|
if (result == AuthorizationResult.Forbidden)
|
|
return Results.StatusCode(403);
|
|
if (result == AuthorizationResult.Unauthorized)
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static async Task<IResult?> RunQueryChecksAsync(
|
|
IServiceProvider serviceProvider,
|
|
Type queryType,
|
|
object query,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var checks = serviceProvider.GetServices<IQueryAuthorizationCheck>().ToList();
|
|
if (checks.Count == 0)
|
|
return null;
|
|
|
|
var context = new QueryAuthorizationCheckContext
|
|
{
|
|
QueryType = queryType,
|
|
Query = query,
|
|
Services = serviceProvider
|
|
};
|
|
|
|
foreach (var check in checks)
|
|
{
|
|
var result = await check.CheckAsync(context, cancellationToken);
|
|
if (result == AuthorizationResult.Forbidden)
|
|
return Results.StatusCode(403);
|
|
if (result == AuthorizationResult.Unauthorized)
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
|
|
{
|
|
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
|
|
|
|
foreach (var queryMeta in queryDiscovery.GetQueries())
|
|
{
|
|
var ignoreAttribute = queryMeta.QueryType.GetCustomAttribute<IgnoreQueryAttribute>();
|
|
if (ignoreAttribute != null)
|
|
continue;
|
|
|
|
// Skip dynamic queries - they are handled by MapSvrntyDynamicQueries
|
|
if (queryMeta.Category == "DynamicQuery")
|
|
continue;
|
|
|
|
var route = $"{routePrefix}/{queryMeta.LowerCamelCaseName}";
|
|
|
|
MapQueryPost(endpoints, route, queryMeta);
|
|
MapQueryGet(endpoints, route, queryMeta);
|
|
}
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static void MapQueryPost(
|
|
IEndpointRouteBuilder endpoints,
|
|
string route,
|
|
IQueryMeta queryMeta)
|
|
{
|
|
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
|
|
|
|
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
|
|
{
|
|
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
|
|
if (authorizationService != null)
|
|
{
|
|
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
|
|
if (authorizationResult == AuthorizationResult.Forbidden)
|
|
return Results.StatusCode(403);
|
|
if (authorizationResult == AuthorizationResult.Unauthorized)
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
// Retrieve already-deserialized and validated query from HttpContext.Items
|
|
var query = context.Items[ValidationFilter<object>.ValidatedObjectKey];
|
|
if (query == null || !queryMeta.QueryType.IsInstanceOfType(query))
|
|
return Results.BadRequest("Invalid query payload");
|
|
|
|
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
|
|
if (checkResult != null)
|
|
return checkResult;
|
|
|
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
|
if (handleMethod == null)
|
|
return Results.Problem("Handler method not found");
|
|
|
|
var task = (Task)handleMethod.Invoke(handler, [query, cancellationToken])!;
|
|
await task;
|
|
|
|
var resultProperty = task.GetType().GetProperty("Result");
|
|
var result = resultProperty?.GetValue(task);
|
|
|
|
return Results.Ok(result);
|
|
})
|
|
.AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(queryMeta.QueryType))!)
|
|
.WithName($"Query_{queryMeta.LowerCamelCaseName}_Post")
|
|
.WithTags("Queries")
|
|
.Accepts(queryMeta.QueryType, "application/json")
|
|
.Produces(200, queryMeta.QueryResultType)
|
|
.Produces(400)
|
|
.Produces(401)
|
|
.Produces(403);
|
|
}
|
|
|
|
private static void MapQueryGet(
|
|
IEndpointRouteBuilder endpoints,
|
|
string route,
|
|
IQueryMeta queryMeta)
|
|
{
|
|
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
|
|
|
|
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
|
|
{
|
|
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
|
|
if (authorizationService != null)
|
|
{
|
|
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
|
|
if (authorizationResult == AuthorizationResult.Forbidden)
|
|
return Results.StatusCode(403);
|
|
if (authorizationResult == AuthorizationResult.Unauthorized)
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var query = Activator.CreateInstance(queryMeta.QueryType);
|
|
if (query == null)
|
|
return Results.BadRequest("Could not create query instance");
|
|
|
|
foreach (var property in queryMeta.QueryType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
|
{
|
|
if (!property.CanWrite)
|
|
continue;
|
|
|
|
var queryStringValue = context.Request.Query[property.Name].FirstOrDefault();
|
|
if (queryStringValue != null)
|
|
{
|
|
try
|
|
{
|
|
var convertedValue = Convert.ChangeType(queryStringValue, property.PropertyType);
|
|
property.SetValue(query, convertedValue);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
|
|
if (checkResult != null)
|
|
return checkResult;
|
|
|
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
|
if (handleMethod == null)
|
|
return Results.Problem("Handler method not found");
|
|
|
|
var task = (Task)handleMethod.Invoke(handler, [query, cancellationToken])!;
|
|
await task;
|
|
|
|
var resultProperty = task.GetType().GetProperty("Result");
|
|
var result = resultProperty?.GetValue(task);
|
|
|
|
return Results.Ok(result);
|
|
})
|
|
.WithName($"Query_{queryMeta.LowerCamelCaseName}_Get")
|
|
.WithTags("Queries")
|
|
.Produces(200, queryMeta.QueryResultType)
|
|
.Produces(400)
|
|
.Produces(401)
|
|
.Produces(403);
|
|
}
|
|
|
|
public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command")
|
|
{
|
|
var commandDiscovery = endpoints.ServiceProvider.GetRequiredService<ICommandDiscovery>();
|
|
|
|
foreach (var commandMeta in commandDiscovery.GetCommands())
|
|
{
|
|
var ignoreAttribute = commandMeta.CommandType.GetCustomAttribute<IgnoreCommandAttribute>();
|
|
if (ignoreAttribute != null)
|
|
continue;
|
|
|
|
var route = $"{routePrefix}/{commandMeta.LowerCamelCaseName}";
|
|
|
|
if (commandMeta.CommandResultType == null)
|
|
{
|
|
MapCommandWithoutResult(endpoints, route, commandMeta);
|
|
}
|
|
else
|
|
{
|
|
MapCommandWithResult(endpoints, route, commandMeta);
|
|
}
|
|
}
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static void MapCommandWithoutResult(
|
|
IEndpointRouteBuilder endpoints,
|
|
string route,
|
|
ICommandMeta commandMeta)
|
|
{
|
|
var handlerType = typeof(ICommandHandler<>).MakeGenericType(commandMeta.CommandType);
|
|
|
|
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
|
|
{
|
|
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
|
|
if (authorizationService != null)
|
|
{
|
|
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
|
|
if (authorizationResult == AuthorizationResult.Forbidden)
|
|
return Results.StatusCode(403);
|
|
if (authorizationResult == AuthorizationResult.Unauthorized)
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
// Retrieve already-deserialized and validated command from HttpContext.Items
|
|
var command = context.Items[ValidationFilter<object>.ValidatedObjectKey];
|
|
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
|
return Results.BadRequest("Invalid command payload");
|
|
|
|
var checkResult = await RunCommandChecksAsync(serviceProvider, commandMeta.CommandType, command, cancellationToken);
|
|
if (checkResult != null)
|
|
return checkResult;
|
|
|
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
|
if (handleMethod == null)
|
|
return Results.Problem("Handler method not found");
|
|
|
|
await (Task)handleMethod.Invoke(handler, [command, cancellationToken])!;
|
|
return Results.Ok();
|
|
})
|
|
.AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(commandMeta.CommandType))!)
|
|
.WithName($"Command_{commandMeta.LowerCamelCaseName}")
|
|
.WithTags("Commands")
|
|
.Accepts(commandMeta.CommandType, "application/json")
|
|
.Produces(200)
|
|
.Produces(400)
|
|
.Produces(401)
|
|
.Produces(403);
|
|
}
|
|
|
|
private static void MapCommandWithResult(
|
|
IEndpointRouteBuilder endpoints,
|
|
string route,
|
|
ICommandMeta commandMeta)
|
|
{
|
|
var handlerType = typeof(ICommandHandler<,>).MakeGenericType(commandMeta.CommandType, commandMeta.CommandResultType!);
|
|
|
|
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
|
|
{
|
|
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
|
|
if (authorizationService != null)
|
|
{
|
|
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
|
|
if (authorizationResult == AuthorizationResult.Forbidden)
|
|
return Results.StatusCode(403);
|
|
if (authorizationResult == AuthorizationResult.Unauthorized)
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
// Retrieve already-deserialized and validated command from HttpContext.Items
|
|
var command = context.Items[ValidationFilter<object>.ValidatedObjectKey];
|
|
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
|
return Results.BadRequest("Invalid command payload");
|
|
|
|
var checkResult = await RunCommandChecksAsync(serviceProvider, commandMeta.CommandType, command, cancellationToken);
|
|
if (checkResult != null)
|
|
return checkResult;
|
|
|
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
|
if (handleMethod == null)
|
|
return Results.Problem("Handler method not found");
|
|
|
|
var task = (Task)handleMethod.Invoke(handler, [command, cancellationToken])!;
|
|
await task;
|
|
|
|
var resultProperty = task.GetType().GetProperty("Result");
|
|
var result = resultProperty?.GetValue(task);
|
|
|
|
return Results.Ok(result);
|
|
})
|
|
.AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(commandMeta.CommandType))!)
|
|
.WithName($"Command_{commandMeta.LowerCamelCaseName}")
|
|
.WithTags("Commands")
|
|
.Accepts(commandMeta.CommandType, "application/json")
|
|
.Produces(200, commandMeta.CommandResultType)
|
|
.Produces(400)
|
|
.Produces(401)
|
|
.Produces(403);
|
|
}
|
|
}
|