From 7457127bf6345115014319b7845d9574264a946c Mon Sep 17 00:00:00 2001 From: Ruben Date: Wed, 6 May 2026 17:48:06 +0400 Subject: [PATCH 1/3] Add scoped API key auth for Gateway gRPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bearer-token auth on SolverService and ExecutionService via tsk__ opaque tokens. Scopes: read, reveal, wallet.execute:{address}, wallet.execute:*. Token prefix is a hard environment scope (test vs live) — test keys can only operate on testnet networks; GetRoutes/GetQuote filter or reject on env mismatch. Stores SHA-256 of the token; raw value is shown once at creation. In-memory 60s absolute TTL cache so revocation propagates within one window. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/api-key-auth.md | 95 ++++ .claude/rules/key-files.md | 16 + CLAUDE.md | 2 +- .../src/AdminAPI/Endpoints/ApiKeyEndpoints.cs | 75 +++ csharp/src/AdminAPI/Program.cs | 5 + .../AdminAPI/Validators/ApiKeyValidators.cs | 46 ++ .../AdminPanel.Client/Layout/NavMenu.razor | 5 + .../src/AdminPanel.Client/Pages/ApiKeys.razor | 474 ++++++++++++++++++ csharp/src/AdminPanel.Client/Program.cs | 1 + .../Services/ApiKeyService.cs | 40 ++ .../src/Data.Abstractions/Entities/ApiKey.cs | 23 + .../Repositories/IApiKeyRepository.cs | 46 ++ csharp/src/Data.Npgsql/EFApiKeyRepository.cs | 119 +++++ .../TrainSolverBuilderExtensions.cs | 1 + csharp/src/Data.Npgsql/SolverDbContext.cs | 13 + .../Interceptors/ApiKeyAuthInterceptor.cs | 61 +++ .../Interceptors/ApiKeyContextExtensions.cs | 65 +++ csharp/src/Gateway.Grpc/Program.cs | 1 + .../Services/ExecutionGrpcService.cs | 24 + .../Services/SolverGrpcService.cs | 31 +- .../ApiKeyPrincipal.cs | 39 ++ .../ApiKeyScopes.cs | 30 ++ .../IApiKeyService.cs | 26 + .../src/Infrastructure/ApiKeys/ApiKeyCache.cs | 40 ++ .../Infrastructure/ApiKeys/ApiKeyService.cs | 136 +++++ .../TrainSolverBuilderExtensions.cs | 6 + .../Shared.Models/Enums/ApiKeyEnvironment.cs | 12 + csharp/src/Shared.Models/Models/ApiKeyDto.cs | 38 ++ 28 files changed, 1468 insertions(+), 2 deletions(-) create mode 100644 .claude/rules/api-key-auth.md create mode 100644 csharp/src/AdminAPI/Endpoints/ApiKeyEndpoints.cs create mode 100644 csharp/src/AdminAPI/Validators/ApiKeyValidators.cs create mode 100644 csharp/src/AdminPanel.Client/Pages/ApiKeys.razor create mode 100644 csharp/src/AdminPanel.Client/Services/ApiKeyService.cs create mode 100644 csharp/src/Data.Abstractions/Entities/ApiKey.cs create mode 100644 csharp/src/Data.Abstractions/Repositories/IApiKeyRepository.cs create mode 100644 csharp/src/Data.Npgsql/EFApiKeyRepository.cs create mode 100644 csharp/src/Gateway.Grpc/Interceptors/ApiKeyAuthInterceptor.cs create mode 100644 csharp/src/Gateway.Grpc/Interceptors/ApiKeyContextExtensions.cs create mode 100644 csharp/src/Infrastructure.Abstractions/ApiKeyPrincipal.cs create mode 100644 csharp/src/Infrastructure.Abstractions/ApiKeyScopes.cs create mode 100644 csharp/src/Infrastructure.Abstractions/IApiKeyService.cs create mode 100644 csharp/src/Infrastructure/ApiKeys/ApiKeyCache.cs create mode 100644 csharp/src/Infrastructure/ApiKeys/ApiKeyService.cs create mode 100644 csharp/src/Shared.Models/Enums/ApiKeyEnvironment.cs create mode 100644 csharp/src/Shared.Models/Models/ApiKeyDto.cs diff --git a/.claude/rules/api-key-auth.md b/.claude/rules/api-key-auth.md new file mode 100644 index 00000000..85fe33f9 --- /dev/null +++ b/.claude/rules/api-key-auth.md @@ -0,0 +1,95 @@ +# API Key Authentication (Gateway.Grpc) + +Bearer-token auth for the public Gateway gRPC API (`SolverService` + `ExecutionService`). Opaque tokens, scope-based, with hard environment scoping. + +## Token format + +`tsk__<43-char base64url>` — e.g., `tsk_test_AbCd...` (~52 chars total). + +- `` is `test` or `live`. Hard constraint, not a label. +- `` is 32 random bytes, base64url-encoded (`+`/`/` → `-`/`_`, no padding). +- Stored as `SHA-256(token)` hex digest in `ApiKey.KeyHash`. The raw token is shown to the operator **once** at creation (`CreateApiKeyResponse.Token`) and is never retrievable afterwards. + +## Scopes (flat strings, greppable in audits) + +| Scope | Covers | +|---|---| +| `read` | `GetRoutes`, `GetQuote`, `GetOrder`, `GetTransactionStatus` | +| `reveal` | `RevealSecret` | +| `wallet.execute:{address}` | `SubmitTransaction` for that exact wallet | +| `wallet.execute:*` | Wildcard — operator-level, any wallet (issued sparingly) | + +`ApiKeyPrincipal.HasScope(string)` does literal match plus expanding `wallet.execute:*` to cover any `wallet.execute:{addr}`. + +## Environment enforcement + +| Endpoint | Behavior | +|---|---| +| `SubmitTransaction` | **Reject** if `network.IsTestnet != (key.Env == Test)` | +| `RevealSecret`, `GetOrder` | **Reject** if order's `Source.Network.IsTestnet` mismatches | +| `GetTransactionStatus` | **Reject** if cached status's network's `IsTestnet` mismatches | +| `GetRoutes` | **Filter** to matching env (a `tsk_test_*` key never sees mainnet routes) | +| `GetQuote` | **Reject** if either source or destination network env mismatches | + +A leaked `tsk_test_*` key cannot touch mainnet, period — even if the caller has a buggy network selector. + +## Verification flow + +``` +gRPC request + └─► ApiKeyAuthInterceptor (registered globally on AddGrpc) + ├─► Extract `Authorization: Bearer ` from RequestHeaders + ├─► IApiKeyService.VerifyAsync(token) + │ ├─► Parse env prefix (reject if not tsk_test_/tsk_live_) + │ ├─► SHA-256 hash full token + │ ├─► ApiKeyCache.TryGet(hash) → cached principal (or null sentinel) + │ │ └── Cache miss: IApiKeyRepository.FindByHashAsync(hash) + │ │ → cross-check env, build principal, fire-and-forget LastUsedAt touch + │ └─► ApiKeyCache.Set(hash, principal-or-null, 60s absolute TTL) + └─► Attach ApiKeyPrincipal to ServerCallContext.UserState + └─► Service method calls context.RequireScope(...) / RequireWalletExecute(...) / RequireMatchingEnv(...) +``` + +- Cache: process-local `ConcurrentDictionary` with **absolute 60s TTL** (not sliding) so revocation always propagates within one window. +- Negative results are cached too — invalid tokens don't keep hammering the DB. +- Hot path on cache hit: hash + dictionary lookup. No DB query. + +## Caching & DI shape + +- `ApiKeyCache` — singleton (process-local map of hash → principal). +- `ApiKeyService : IApiKeyService` — transient (depends on scoped `IApiKeyRepository` → scoped `SolverDbContext`). +- Registered in `WithCoreServices()` (`Infrastructure.TrainSolverBuilderExtensions`). + +If the cache is in a singleton and the service is transient, a singleton consumer can't resolve `IApiKeyService` directly — the `ApiKeyAuthInterceptor` is registered as a transient gRPC interceptor (per request), so it can. + +## Revocation + +- `POST /api/api-keys/{id}/revoke` flips `Revoked = true`. Cache propagates within ≤60s. +- `DELETE /api/api-keys/{id}` permanently removes the row. Use sparingly — prefer revoke for audit. +- No TTL field — keys live until revoked or deleted (per design decision). + +## DbContext + +- `SolverDbContext.ApiKeys` (`DbSet`) with unique indexes on `(KeyHash)` and `(Name)`, enum comment on `Environment`. +- Migration is user-managed (per `Plans MUST NOT include EF Core migration steps` rule in CLAUDE.md). + +## Per-method enforcement template + +```csharp +public override async Task Method(FooRequest request, ServerCallContext context) +{ + context.RequireScope(ApiKeyScopes.Read); // or .Reveal + context.RequireWalletExecute(request.WalletAddress); // for execute endpoints + var network = await networkRepository.GetAsync(request.NetworkSlug); + context.RequireMatchingEnv(network.IsTestnet); // env enforcement + // ... business logic +} +``` + +Each `Require*` call throws `RpcException(PermissionDenied, ...)` on failure. The auth interceptor handles `Unauthenticated` (missing/invalid token) before any service code runs. + +## Admin UX + +- `AdminPanel.Client/Pages/ApiKeys.razor` — list, create (with env + scope checkboxes + per-wallet entries), view (revoke / delete). +- One-time token display: at creation, the response's `Token` field is shown in a modal panel with a copy button. Once dismissed, it's gone forever — only the hash remains in the DB. +- AdminAPI endpoints are not gated by an api-key scope themselves (they use AdminAPI's existing auth/IP allowlist conventions). diff --git a/.claude/rules/key-files.md b/.claude/rules/key-files.md index 649c0124..8ad2796b 100644 --- a/.claude/rules/key-files.md +++ b/.claude/rules/key-files.md @@ -152,3 +152,19 @@ | VolumeRule admin panel page | `src/AdminPanel.Client/Pages/VolumeRules.razor` | | VolumeRule admin service | `src/AdminPanel.Client/Services/VolumeRuleService.cs` | | Volume limits design doc | `docs/VOLUME-LIMITS.md` | +| ApiKey entity | `src/Data.Abstractions/Entities/ApiKey.cs` | +| ApiKeyDto / CreateApiKeyRequest / CreateApiKeyResponse | `src/Shared.Models/Models/ApiKeyDto.cs` | +| ApiKeyEnvironment enum | `src/Shared.Models/Enums/ApiKeyEnvironment.cs` | +| IApiKeyRepository / ApiKeyLookupResult | `src/Data.Abstractions/Repositories/IApiKeyRepository.cs` | +| EFApiKeyRepository | `src/Data.Npgsql/EFApiKeyRepository.cs` | +| ApiKeyScopes constants | `src/Infrastructure.Abstractions/ApiKeyScopes.cs` | +| ApiKeyPrincipal | `src/Infrastructure.Abstractions/ApiKeyPrincipal.cs` | +| IApiKeyService | `src/Infrastructure.Abstractions/IApiKeyService.cs` | +| ApiKeyService | `src/Infrastructure/ApiKeys/ApiKeyService.cs` | +| ApiKeyCache (singleton) | `src/Infrastructure/ApiKeys/ApiKeyCache.cs` | +| ApiKeyAuthInterceptor | `src/Gateway.Grpc/Interceptors/ApiKeyAuthInterceptor.cs` | +| ApiKeyContextExtensions | `src/Gateway.Grpc/Interceptors/ApiKeyContextExtensions.cs` | +| AdminAPI api-key endpoints | `src/AdminAPI/Endpoints/ApiKeyEndpoints.cs` | +| AdminAPI api-key validators | `src/AdminAPI/Validators/ApiKeyValidators.cs` | +| AdminPanel api-key service | `src/AdminPanel.Client/Services/ApiKeyService.cs` | +| AdminPanel api-key page | `src/AdminPanel.Client/Pages/ApiKeys.razor` | diff --git a/CLAUDE.md b/CLAUDE.md index 881fba85..add58771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ dotnet test tests/Workflow.Tests/Workflow.Tests.csproj - **BigInteger** for all token amounts (via `System.Numerics`); serialized as strings in DTOs - **Token contract addresses**: Always non-null. Native tokens use a null address (`0x0000000000000000000000000000000000000000`). Access via `network.Type.NativeTokenAddress`. - **AdminAPI**: Uses plain `Results.Ok()`, `Results.NotFound("message")` - no wrapper -- **Public API (Gateway.Grpc)**: gRPC service, no REST wrapper +- **Public API (Gateway.Grpc)**: gRPC service, no REST wrapper. All methods require an `Authorization: Bearer tsk__` API key — see `.claude/rules/api-key-auth.md`. Token prefix `tsk_test_*` / `tsk_live_*` is a hard environment scope (not a label): test keys can only operate on testnet networks, live keys only on mainnet. - **Activity methods**: Must be `public virtual async` in implementation class (Temporal requirement) - **String truncation**: Use `StringExtensions.Truncate(maxLength)` instead of inline `[..Math.Min()]` patterns. Located in `src/Util/Extensions/StringExtensions.cs`. - **Quote expiry enforcement**: `ValidateQuoteAsync` checks `QuoteExpiry < Workflow.UtcNow` after HMAC signature validation to prevent replay of stale signed quotes diff --git a/csharp/src/AdminAPI/Endpoints/ApiKeyEndpoints.cs b/csharp/src/AdminAPI/Endpoints/ApiKeyEndpoints.cs new file mode 100644 index 00000000..2d0ece83 --- /dev/null +++ b/csharp/src/AdminAPI/Endpoints/ApiKeyEndpoints.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Mvc; +using Train.Solver.AdminAPI.Filters; +using Train.Solver.Infrastructure.Abstractions; +using Train.Solver.Shared.Models; + +namespace Train.Solver.AdminAPI.Endpoints; + +public static class ApiKeyEndpoints +{ + public static RouteGroupBuilder MapApiKeyEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/api-keys", ListAsync) + .Produces>(); + + group.MapGet("/api-keys/scopes", () => Results.Ok(ApiKeyScopes.WellKnown)) + .Produces>(); + + group.MapGet("/api-keys/{id:int}", GetAsync) + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/api-keys", CreateAsync) + .AddEndpointFilter>() + .Produces() + .Produces(StatusCodes.Status400BadRequest); + + group.MapPost("/api-keys/{id:int}/revoke", RevokeAsync) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + group.MapDelete("/api-keys/{id:int}", DeleteAsync) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + return group; + } + + private static async Task ListAsync(IApiKeyService service) + { + var keys = await service.ListAsync(); + return Results.Ok(keys); + } + + private static async Task GetAsync(IApiKeyService service, int id) + { + var key = await service.GetAsync(id); + return key is null + ? Results.NotFound($"API key {id} not found") + : Results.Ok(key); + } + + private static async Task CreateAsync( + IApiKeyService service, + [FromBody] CreateApiKeyRequest request) + { + var response = await service.IssueAsync(request.Name, request.Environment, request.Scopes); + return Results.Ok(response); + } + + private static async Task RevokeAsync(IApiKeyService service, int id) + { + var ok = await service.RevokeAsync(id); + return ok + ? Results.Ok() + : Results.NotFound($"API key {id} not found"); + } + + private static async Task DeleteAsync(IApiKeyService service, int id) + { + var ok = await service.DeleteAsync(id); + return ok + ? Results.Ok() + : Results.NotFound($"API key {id} not found"); + } +} diff --git a/csharp/src/AdminAPI/Program.cs b/csharp/src/AdminAPI/Program.cs index b7509da1..12ebc443 100644 --- a/csharp/src/AdminAPI/Program.cs +++ b/csharp/src/AdminAPI/Program.cs @@ -117,6 +117,11 @@ .RequireRateLimiting("Fixed") .WithTags("Webhook"); +app.MapGroup("/api") + .MapApiKeyEndpoints() + .RequireRateLimiting("Fixed") + .WithTags("API Key"); + app.MapGroup("/api") .MapVolumeRuleEndpoints() .RequireRateLimiting("Fixed") diff --git a/csharp/src/AdminAPI/Validators/ApiKeyValidators.cs b/csharp/src/AdminAPI/Validators/ApiKeyValidators.cs new file mode 100644 index 00000000..7a79d048 --- /dev/null +++ b/csharp/src/AdminAPI/Validators/ApiKeyValidators.cs @@ -0,0 +1,46 @@ +using FluentValidation; +using Train.Solver.Infrastructure.Abstractions; +using Train.Solver.Shared.Models; + +namespace Train.Solver.AdminAPI.Validators; + +public class CreateApiKeyRequestValidator : AbstractValidator +{ + public CreateApiKeyRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(100); + + RuleFor(x => x.Scopes) + .NotNull() + .Must(s => s != null && s.Count > 0) + .WithMessage("At least one scope is required"); + + RuleForEach(x => x.Scopes) + .NotEmpty() + .Must(BeValidScope) + .WithMessage("Scope must be one of {known}, or a wallet.execute:
"); + } + + private static bool BeValidScope(string scope) + { + if (string.IsNullOrWhiteSpace(scope)) + { + return false; + } + + if (scope == ApiKeyScopes.Read || scope == ApiKeyScopes.Reveal || scope == ApiKeyScopes.WalletExecuteAny) + { + return true; + } + + if (scope.StartsWith(ApiKeyScopes.WalletExecutePrefix, StringComparison.Ordinal)) + { + var address = scope[ApiKeyScopes.WalletExecutePrefix.Length..]; + return !string.IsNullOrWhiteSpace(address) && address != "*"; + } + + return false; + } +} diff --git a/csharp/src/AdminPanel.Client/Layout/NavMenu.razor b/csharp/src/AdminPanel.Client/Layout/NavMenu.razor index 9dcd5982..89cd05ce 100644 --- a/csharp/src/AdminPanel.Client/Layout/NavMenu.razor +++ b/csharp/src/AdminPanel.Client/Layout/NavMenu.razor @@ -41,6 +41,11 @@ Webhooks +