diff --git a/README.md b/README.md index 77cbea9..b55288d 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ Native support for popular web frameworks. | Next.js | Middleware package | ✅ Active | Community | | Express.js | Middleware package | ✅ Active | Community | | FastAPI | Middleware package | ✅ Active | Community | +| [ASP.NET Core Minimal API](examples/frameworks/aspnetcore-minimal-api/) | Endpoint filter example for APort policy checks | ✅ Active | Community | | Django | Middleware package | 🚧 In Progress | Community | | Laravel | Composer package | 📋 Planned | Community | | Rails | Ruby gem | 📋 Planned | Community | diff --git a/examples/frameworks/aspnetcore-minimal-api/.env.example b/examples/frameworks/aspnetcore-minimal-api/.env.example new file mode 100644 index 0000000..6d31123 --- /dev/null +++ b/examples/frameworks/aspnetcore-minimal-api/.env.example @@ -0,0 +1,7 @@ +# Copy these values into your shell or secret store before running against the live APort API. +# The sample runs in mock mode by default so it can be tested without credentials. + +APort__BaseUrl=https://api.aport.io +APort__ApiKey=your_api_key_here +APort__DefaultPolicy=finance.payment.refund.v1 +APort__UseMock=false diff --git a/examples/frameworks/aspnetcore-minimal-api/README.md b/examples/frameworks/aspnetcore-minimal-api/README.md new file mode 100644 index 0000000..2a12133 --- /dev/null +++ b/examples/frameworks/aspnetcore-minimal-api/README.md @@ -0,0 +1,111 @@ +# APort ASP.NET Core Minimal API Example + +This example shows how to protect an ASP.NET Core Minimal API endpoint with an APort policy check before business logic runs. + +Integration highlight: the `/refunds` endpoint uses an endpoint filter to call APort `/verify` with the agent ID from `X-Agent-Id`. If APort denies the policy check, the endpoint returns `403` and the refund handler is not executed. + +## What is included + +- `src/Program.cs` - a self-contained Minimal API app. +- `IAportVerificationClient` - a small typed `HttpClient` wrapper for APort `/verify`. +- `RequireAportVerification(...)` - an endpoint filter extension that protects selected routes. +- Mock mode for local development, enabled by default. + +## Prerequisites + +- .NET 10 SDK +- An APort API key for live verification + +## Run locally + +```bash +cd examples/frameworks/aspnetcore-minimal-api/src +dotnet run +``` + +The app listens on the URL printed by `dotnet run`, usually `http://localhost:5xxx`. + +## Try the public health endpoint + +```bash +curl http://localhost:5000/health +``` + +If your local port is different, replace `5000` with the port from `dotnet run`. + +## Try the protected refund endpoint in mock mode + +Mock mode is enabled by default so the example works without credentials. + +Allowed mock request: + +```bash +curl -X POST http://localhost:5000/refunds \ + -H "Content-Type: application/json" \ + -H "X-Agent-Id: agt_demo" \ + -d '{"orderId":"ord_123","amount":42.50,"currency":"USD"}' +``` + +Denied mock request: + +```bash +curl -X POST http://localhost:5000/refunds \ + -H "Content-Type: application/json" \ + -H "X-Agent-Id: agt_denied" \ + -d '{"orderId":"ord_123","amount":42.50,"currency":"USD"}' +``` + +## Use the live APort API + +Set these values in your shell or secret store: + +```bash +export APort__BaseUrl="https://api.aport.io" +export APort__ApiKey="your_api_key_here" +export APort__DefaultPolicy="finance.payment.refund.v1" +export APort__UseMock="false" +``` + +On PowerShell: + +```powershell +$env:APort__BaseUrl="https://api.aport.io" +$env:APort__ApiKey="your_api_key_here" +$env:APort__DefaultPolicy="finance.payment.refund.v1" +$env:APort__UseMock="false" +``` + +The live request body sent to APort is: + +```json +{ + "policy": "finance.payment.refund.v1", + "agentId": "agt_demo", + "context": { + "path": "/refunds", + "method": "POST" + } +} +``` + +## Configuration + +| Key | Default | Description | +| --- | --- | --- | +| `APort__BaseUrl` | `https://api.aport.io` | APort API base URL. | +| `APort__ApiKey` | empty | API key used as a Bearer token for live verification. | +| `APort__DefaultPolicy` | `finance.payment.refund.v1` | Policy checked by the protected endpoint. | +| `APort__UseMock` | `true` | Keeps the sample runnable without credentials. Set to `false` for live API calls. | + +## How to reuse this pattern + +1. Register a typed `HttpClient` for APort verification. +2. Add `RequireAportVerification("your.policy.id")` to any Minimal API endpoint that needs policy enforcement. +3. Pass an agent identifier through a trusted header, token claim, or service-to-service identity mechanism. +4. Keep secrets in environment variables, user secrets, or a production secret manager; do not commit API keys. + +## Notes + +- The example intentionally avoids third-party packages so it can be copied into existing ASP.NET Core apps. +- The endpoint filter returns Problem Details responses for missing agent IDs, denied policies, and API failures. +- In a production app, bind the agent identity from your authentication layer instead of trusting a raw client-supplied header. diff --git a/examples/frameworks/aspnetcore-minimal-api/src/Aport.AspNetCore.MinimalApi.Example.csproj b/examples/frameworks/aspnetcore-minimal-api/src/Aport.AspNetCore.MinimalApi.Example.csproj new file mode 100644 index 0000000..9a04a31 --- /dev/null +++ b/examples/frameworks/aspnetcore-minimal-api/src/Aport.AspNetCore.MinimalApi.Example.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/examples/frameworks/aspnetcore-minimal-api/src/Program.cs b/examples/frameworks/aspnetcore-minimal-api/src/Program.cs new file mode 100644 index 0000000..671240b --- /dev/null +++ b/examples/frameworks/aspnetcore-minimal-api/src/Program.cs @@ -0,0 +1,172 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("APort")); +builder.Services.AddHttpClient(); + +var app = builder.Build(); + +var defaultPolicy = app.Configuration["APort:DefaultPolicy"] ?? "finance.payment.refund.v1"; + +app.MapGet("/health", () => Results.Ok(new +{ + status = "ok", + service = "aport-aspnetcore-minimal-api-example" +})); + +app.MapPost("/refunds", (RefundRequest request) => Results.Ok(new +{ + message = "Refund request passed APort verification and is ready for business processing.", + request.OrderId, + request.Amount +})) +.RequireAportVerification(defaultPolicy) +.WithName("CreateRefund") +.WithSummary("Create a refund after APort policy verification"); + +app.Run(); + +public sealed record RefundRequest(string OrderId, decimal Amount, string Currency = "USD"); + +public sealed class AportOptions +{ + public string BaseUrl { get; init; } = "https://api.aport.io"; + public string? ApiKey { get; init; } + public string DefaultPolicy { get; init; } = "finance.payment.refund.v1"; + + // Keeps the example runnable out of the box. Set APort__UseMock=false for the live API. + public bool UseMock { get; init; } = true; +} + +public sealed record AportVerificationResult(bool Allow, IReadOnlyList Reasons, JsonElement? Passport); + +public interface IAportVerificationClient +{ + Task VerifyAsync( + string policy, + string agentId, + object? context, + CancellationToken cancellationToken); +} + +public sealed class AportVerificationClient( + HttpClient httpClient, + IOptions options, + ILogger logger) : IAportVerificationClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly AportOptions _options = options.Value; + + public async Task VerifyAsync( + string policy, + string agentId, + object? context, + CancellationToken cancellationToken) + { + if (_options.UseMock) + { + return MockVerification(agentId); + } + + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + throw new InvalidOperationException("APort__ApiKey is required when APort__UseMock=false."); + } + + var baseUri = new Uri(_options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute); + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(baseUri, "verify")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey); + request.Content = JsonContent.Create(new + { + policy, + agentId, + context + }, options: JsonOptions); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("APort verification returned {StatusCode}", response.StatusCode); + return new AportVerificationResult( + Allow: false, + Reasons: [$"APort API returned HTTP {(int)response.StatusCode}."], + Passport: null); + } + + var verification = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + var allow = verification?.Allow ?? verification?.Verified ?? false; + var reasons = verification?.Reasons ?? []; + + return new AportVerificationResult(allow, reasons, verification?.Passport); + } + + private static AportVerificationResult MockVerification(string agentId) + { + if (agentId.Equals("agt_denied", StringComparison.OrdinalIgnoreCase)) + { + return new AportVerificationResult(false, ["Mock denial for demonstration."], null); + } + + return new AportVerificationResult(true, ["Mock allow for local development."], null); + } +} + +public sealed class AportVerifyResponse +{ + [JsonPropertyName("allow")] + public bool? Allow { get; init; } + + [JsonPropertyName("verified")] + public bool? Verified { get; init; } + + [JsonPropertyName("reasons")] + public string[] Reasons { get; init; } = []; + + [JsonPropertyName("passport")] + public JsonElement? Passport { get; init; } +} + +public static class AportEndpointFilterExtensions +{ + public static RouteHandlerBuilder RequireAportVerification(this RouteHandlerBuilder builder, string policy) + { + return builder.AddEndpointFilter(async (context, next) => + { + var httpContext = context.HttpContext; + var agentId = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(agentId)) + { + return Results.Problem( + title: "Missing APort agent identity", + detail: "Send the agent identifier in the X-Agent-Id header.", + statusCode: StatusCodes.Status400BadRequest); + } + + var client = httpContext.RequestServices.GetRequiredService(); + var result = await client.VerifyAsync(policy, agentId, new + { + path = httpContext.Request.Path.Value, + method = httpContext.Request.Method + }, httpContext.RequestAborted); + + if (!result.Allow) + { + return Results.Problem( + title: "APort verification denied", + detail: result.Reasons.Count > 0 + ? string.Join(" ", result.Reasons) + : "The agent is not allowed to perform this action.", + statusCode: StatusCodes.Status403Forbidden); + } + + httpContext.Items["APortPassport"] = result.Passport; + return await next(context); + }); + } +} diff --git a/examples/frameworks/aspnetcore-minimal-api/src/appsettings.json b/examples/frameworks/aspnetcore-minimal-api/src/appsettings.json new file mode 100644 index 0000000..be4de8c --- /dev/null +++ b/examples/frameworks/aspnetcore-minimal-api/src/appsettings.json @@ -0,0 +1,8 @@ +{ + "APort": { + "BaseUrl": "https://api.aport.io", + "ApiKey": "", + "DefaultPolicy": "finance.payment.refund.v1", + "UseMock": true + } +}