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(" // 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}>>>();");
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user