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:
Mathias Beaulieu-Duncan 2026-05-12 16:21:20 -04:00
parent a05ebad7fc
commit 86d87424ab
5 changed files with 211 additions and 0 deletions

View File

@ -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; }
}

View File

@ -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&lt;ICommandAuthorizationCheck&gt;</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);
}

View File

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

View File

@ -2376,6 +2376,26 @@ public class GrpcGenerator : IIncrementalGenerator
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}>();");
if (command.HasResult)
@ -2493,6 +2513,27 @@ public class GrpcGenerator : IIncrementalGenerator
sb.AppendLine(assignment);
}
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);");
// Generate response with mapping if complex type
@ -2828,6 +2869,26 @@ public class GrpcGenerator : IIncrementalGenerator
sb.AppendLine(" Aggregates = ConvertAggregates(request.Aggregates) ?? new()");
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
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<IQueryHandler<{dynamicQuery.QueryInterfaceName}, IQueryExecutionResult<{dynamicQuery.DestinationTypeFullyQualified}>>>();");

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
@ -16,6 +17,64 @@ 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>();
@ -63,6 +122,10 @@ public static class EndpointRouteBuilderExtensions
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)
@ -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 handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
@ -198,6 +265,10 @@ public static class EndpointRouteBuilderExtensions
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)
@ -240,6 +311,10 @@ public static class EndpointRouteBuilderExtensions
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)