Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .claude/rules/api-key-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# API Key Authentication (Gateway.Grpc)

Custom-header API key auth for the public Gateway gRPC API (`SolverService` + `ExecutionService`). Opaque tokens, scope-based, with hard environment scoping.

**Header**: `X-Train-API-Key: tsk_<env>_<random>` (token is the header value directly — no `Bearer` prefix).

## Token format

`tsk_<env>_<43-char base64url>` — e.g., `tsk_test_AbCd...` (~52 chars total).

- `<env>` is `test` or `live`. Hard constraint, not a label.
- `<random>` 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 `X-Train-API-Key` header 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<ApiKey>`) 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<Foo> 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).
16 changes: 16 additions & 0 deletions .claude/rules/key-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `X-Train-API-Key: tsk_<env>_<random>` header — 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
Expand Down
75 changes: 75 additions & 0 deletions csharp/src/AdminAPI/Endpoints/ApiKeyEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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<List<ApiKeyDto>>();

group.MapGet("/api-keys/scopes", () => Results.Ok(ApiKeyScopes.WellKnown))
.Produces<IReadOnlyList<string>>();

group.MapGet("/api-keys/{id:int}", GetAsync)
.Produces<ApiKeyDto>()
.Produces(StatusCodes.Status404NotFound);

group.MapPost("/api-keys", CreateAsync)
.AddEndpointFilter<ValidationFilter<CreateApiKeyRequest>>()
.Produces<CreateApiKeyResponse>()
.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<IResult> ListAsync(IApiKeyService service)
{
var keys = await service.ListAsync();
return Results.Ok(keys);
}

private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> DeleteAsync(IApiKeyService service, int id)
{
var ok = await service.DeleteAsync(id);
return ok
? Results.Ok()
: Results.NotFound($"API key {id} not found");
}
}
5 changes: 5 additions & 0 deletions csharp/src/AdminAPI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@
.RequireRateLimiting("Fixed")
.WithTags("Webhook");

app.MapGroup("/api")
.MapApiKeyEndpoints()
.RequireRateLimiting("Fixed")
.WithTags("API Key");

app.MapGroup("/api")
.MapVolumeRuleEndpoints()
.RequireRateLimiting("Fixed")
Expand Down
46 changes: 46 additions & 0 deletions csharp/src/AdminAPI/Validators/ApiKeyValidators.cs
Original file line number Diff line number Diff line change
@@ -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<CreateApiKeyRequest>
{
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:<address>");
}

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;
}
}
5 changes: 5 additions & 0 deletions csharp/src/AdminPanel.Client/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
<span class="bi bi-bell-nav-menu" aria-hidden="true"></span> Webhooks
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="api-keys">
<span class="bi bi-shield-nav-menu" aria-hidden="true"></span> API Keys
</NavLink>
</div>
<div class="nav-item px-3">
<a class="nav-link" href="bridge" target="_blank">
<span class="bi bi-bridge-nav-menu" aria-hidden="true"></span> Bridge
Expand Down
Loading
Loading