feat(security): add ICommandAuthorizationCheck/IQueryAuthorizationCheck seam
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>
This commit is contained in:
parent
a05ebad7fc
commit
86d87424ab
@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Abstractions.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared shape for command and query authorization-check contexts. Checks
|
||||||
|
/// receive the request type, the materialized (and validated) request instance,
|
||||||
|
/// a scoped <see cref="IServiceProvider"/>, and a free-form <see cref="Items"/>
|
||||||
|
/// dictionary that lets checks in the same pipeline pass signals to each other
|
||||||
|
/// (e.g. a future mobile-attestation check stamping "mobile_attested" for the
|
||||||
|
/// Altcha check to read).
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AuthorizationCheckContext
|
||||||
|
{
|
||||||
|
public required IServiceProvider Services { get; init; }
|
||||||
|
|
||||||
|
public IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CommandAuthorizationCheckContext : AuthorizationCheckContext
|
||||||
|
{
|
||||||
|
public required Type CommandType { get; init; }
|
||||||
|
|
||||||
|
public required object Command { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class QueryAuthorizationCheckContext : AuthorizationCheckContext
|
||||||
|
{
|
||||||
|
public required Type QueryType { get; init; }
|
||||||
|
|
||||||
|
public required object Query { get; init; }
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Abstractions.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-cutting authorization check that runs alongside (not in place of) the
|
||||||
|
/// consumer's <see cref="ICommandAuthorizationService"/>. Multiple
|
||||||
|
/// implementations may be registered; the framework resolves them as
|
||||||
|
/// <c>IEnumerable<ICommandAuthorizationCheck></c> and runs each in
|
||||||
|
/// registration order. AND semantics — any non-<see cref="AuthorizationResult.Allowed"/>
|
||||||
|
/// short-circuits the pipeline.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Use this seam for self-applying, attribute-driven checks shipped by
|
||||||
|
/// framework modules (proof-of-work, mobile attestation, rate-limit gates,
|
||||||
|
/// IP allow-lists). The check is responsible for inspecting
|
||||||
|
/// <see cref="CommandAuthorizationCheckContext.CommandType"/> attributes and
|
||||||
|
/// no-op'ing (return <see cref="AuthorizationResult.Allowed"/>) when it
|
||||||
|
/// doesn't apply.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ICommandAuthorizationCheck
|
||||||
|
{
|
||||||
|
Task<AuthorizationResult> CheckAsync(
|
||||||
|
CommandAuthorizationCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Abstractions.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query-side counterpart to <see cref="ICommandAuthorizationCheck"/>. See
|
||||||
|
/// that interface's remarks for usage.
|
||||||
|
/// </summary>
|
||||||
|
public interface IQueryAuthorizationCheck
|
||||||
|
{
|
||||||
|
Task<AuthorizationResult> CheckAsync(
|
||||||
|
QueryAuthorizationCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -2376,6 +2376,26 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
sb.AppendLine(" }");
|
sb.AppendLine(" }");
|
||||||
sb.AppendLine(" }");
|
sb.AppendLine(" }");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(" // Authorization checks (cross-cutting; see ICommandAuthorizationCheck)");
|
||||||
|
sb.AppendLine(" var commandChecks = serviceProvider.GetServices<ICommandAuthorizationCheck>();");
|
||||||
|
sb.AppendLine(" if (commandChecks != null)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkContext = new CommandAuthorizationCheckContext");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine($" CommandType = typeof({command.FullyQualifiedName}),");
|
||||||
|
sb.AppendLine(" Command = command,");
|
||||||
|
sb.AppendLine(" Services = serviceProvider");
|
||||||
|
sb.AppendLine(" };");
|
||||||
|
sb.AppendLine(" foreach (var check in commandChecks)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine();
|
||||||
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<{command.HandlerInterfaceName}>();");
|
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<{command.HandlerInterfaceName}>();");
|
||||||
|
|
||||||
if (command.HasResult)
|
if (command.HasResult)
|
||||||
@ -2493,6 +2513,27 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
sb.AppendLine(assignment);
|
sb.AppendLine(assignment);
|
||||||
}
|
}
|
||||||
sb.AppendLine(" };");
|
sb.AppendLine(" };");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(" // Authorization checks (cross-cutting; see IQueryAuthorizationCheck)");
|
||||||
|
sb.AppendLine(" var queryChecks = serviceProvider.GetServices<IQueryAuthorizationCheck>();");
|
||||||
|
sb.AppendLine(" if (queryChecks != null)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkContext = new QueryAuthorizationCheckContext");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine($" QueryType = typeof({query.FullyQualifiedName}),");
|
||||||
|
sb.AppendLine(" Query = query,");
|
||||||
|
sb.AppendLine(" Services = serviceProvider");
|
||||||
|
sb.AppendLine(" };");
|
||||||
|
sb.AppendLine(" foreach (var check in queryChecks)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine();
|
||||||
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
|
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
|
||||||
|
|
||||||
// Generate response with mapping if complex type
|
// Generate response with mapping if complex type
|
||||||
@ -2828,6 +2869,26 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
sb.AppendLine(" Aggregates = ConvertAggregates(request.Aggregates) ?? new()");
|
sb.AppendLine(" Aggregates = ConvertAggregates(request.Aggregates) ?? new()");
|
||||||
sb.AppendLine(" };");
|
sb.AppendLine(" };");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(" // Authorization checks (cross-cutting; see IQueryAuthorizationCheck)");
|
||||||
|
sb.AppendLine(" var queryChecks = serviceProvider.GetServices<IQueryAuthorizationCheck>();");
|
||||||
|
sb.AppendLine(" if (queryChecks != null)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkContext = new QueryAuthorizationCheckContext");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine($" QueryType = typeof({dynamicQuery.QueryInterfaceName}),");
|
||||||
|
sb.AppendLine(" Query = query,");
|
||||||
|
sb.AppendLine(" Services = serviceProvider");
|
||||||
|
sb.AppendLine(" };");
|
||||||
|
sb.AppendLine(" foreach (var check in queryChecks)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
// Get the handler and execute
|
// Get the handler and execute
|
||||||
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<IQueryHandler<{dynamicQuery.QueryInterfaceName}, IQueryExecutionResult<{dynamicQuery.DestinationTypeFullyQualified}>>>();");
|
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<IQueryHandler<{dynamicQuery.QueryInterfaceName}, IQueryExecutionResult<{dynamicQuery.DestinationTypeFullyQualified}>>>();");
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@ -16,6 +17,64 @@ namespace Svrnty.CQRS.MinimalApi;
|
|||||||
|
|
||||||
public static class EndpointRouteBuilderExtensions
|
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")
|
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
|
||||||
{
|
{
|
||||||
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
|
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
|
||||||
@ -63,6 +122,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
if (query == null || !queryMeta.QueryType.IsInstanceOfType(query))
|
if (query == null || !queryMeta.QueryType.IsInstanceOfType(query))
|
||||||
return Results.BadRequest("Invalid query payload");
|
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 handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
var handleMethod = handlerType.GetMethod("HandleAsync");
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
||||||
if (handleMethod == null)
|
if (handleMethod == null)
|
||||||
@ -128,6 +191,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
|
||||||
|
if (checkResult != null)
|
||||||
|
return checkResult;
|
||||||
|
|
||||||
var handler = serviceProvider.GetRequiredService(handlerType);
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
var handleMethod = handlerType.GetMethod("HandleAsync");
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
||||||
if (handleMethod == null)
|
if (handleMethod == null)
|
||||||
@ -198,6 +265,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
||||||
return Results.BadRequest("Invalid command payload");
|
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 handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
var handleMethod = handlerType.GetMethod("HandleAsync");
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
||||||
if (handleMethod == null)
|
if (handleMethod == null)
|
||||||
@ -240,6 +311,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
||||||
return Results.BadRequest("Invalid command payload");
|
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 handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
var handleMethod = handlerType.GetMethod("HandleAsync");
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
||||||
if (handleMethod == null)
|
if (handleMethod == null)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user