Compare commits

..

2 Commits

Author SHA1 Message Date
Oleksandr Bezdieniezhnykh 96cd3c4495 [AZ-487] JWT validation baseline (HS256, all endpoints)
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
Adds Microsoft.AspNetCore.Authentication.JwtBearer 8.0.21 and the
SatelliteProvider.Api.Authentication.AddSatelliteJwt extension that
validates HS256 tokens against a shared JWT_SECRET (>=32 bytes, fail
fast at startup). Every minimal-API endpoint now carries
.RequireAuthorization(); the middleware chain is UseExceptionHandler ->
UseHttpsRedirection -> UseCors -> UseAuthentication -> UseAuthorization
-> endpoints. Swagger UI gets a Bearer security definition so the
Authorize button works.

Test infrastructure: JwtTokenFactory (unit) and JwtTestHelpers
(integration) mint deterministic tokens against the same secret; the
integration test runner attaches a default Bearer token to its shared
HttpClient so existing tests continue to exercise protected endpoints.
JwtIntegrationTests adds AC-1..AC-4 and AC-7 (Swagger advertises
Bearer) end-to-end; AuthenticationServiceCollectionExtensionsTests
covers AC-5 (missing/empty/short secret fail-fast) plus env-var
precedence; JwtTokenFactoryTests covers AC-6 (claims pass through
the JwtSecurityTokenHandler.ValidateToken path JwtBearer uses).

docker-compose and scripts/run-tests.sh now propagate JWT_SECRET to
the api and integration-tests containers, with a >=32-byte guard.
.env.example documents the required keys; .env stays gitignored.

Code review verdict: PASS_WITH_WARNINGS (2 Low findings surfaced
in _docs/03_implementation/reviews/batch_01_cycle2_review.md).

Cross-component coordination: gps-denied-onboard and the mission
planner UI must attach Bearer tokens before this lands in dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:06:23 +03:00
Oleksandr Bezdieniezhnykh 8e15e53782 chore: cycle 2 step 9 task plan artifacts + step 10 state
Carries forward new-task research + solution drafts under
_docs/02_task_plans/uav-batch-upload/ that were not included in
the Step 9 task-spec commit (42a3cc7). Also marks the autodev
state as Step 10 in_progress for cycle 2 implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 22:54:36 +03:00
26 changed files with 1375 additions and 16 deletions
+18
View File
@@ -0,0 +1,18 @@
# Satellite Provider environment configuration template.
# Copy this file to `.env` and replace placeholder values before running
# docker-compose or scripts/run-tests.sh.
#
# IMPORTANT: `.env` is gitignored on purpose. Never commit real secrets.
# Google Maps Platform API key for satellite imagery downloads.
GOOGLE_MAPS_API_KEY=
# HMAC-SHA256 signing key for JWT validation (suite-level auth contract,
# `suite/_docs/10_auth.md`). MUST be at least 32 bytes (UTF-8) — the API
# fails fast on startup if this is unset or shorter.
#
# Generate a strong secret with, for example:
# openssl rand -hex 32
#
# Test/CI runs may use a clearly tagged TEST-ONLY value (still >=32 bytes).
JWT_SECRET=
@@ -0,0 +1,66 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace SatelliteProvider.Api.Authentication;
public static class AuthenticationServiceCollectionExtensions
{
public const string JwtSecretEnvVar = "JWT_SECRET";
public const string JwtSecretConfigKey = "Jwt:Secret";
public const int MinSecretByteLength = 32;
public static IServiceCollection AddSatelliteJwt(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var secret = ResolveSecretOrThrow(configuration);
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
ValidateIssuer = false,
ValidateAudience = false,
RequireSignedTokens = true,
RequireExpirationTime = true
};
});
return services;
}
internal static string ResolveSecretOrThrow(IConfiguration configuration)
{
var secret = Environment.GetEnvironmentVariable(JwtSecretEnvVar);
if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration[JwtSecretConfigKey];
}
if (string.IsNullOrWhiteSpace(secret))
{
throw new InvalidOperationException(
$"JWT secret is not configured. Set the {JwtSecretEnvVar} environment variable " +
$"or the {JwtSecretConfigKey} configuration key to a value of at least {MinSecretByteLength} bytes.");
}
var byteLength = Encoding.UTF8.GetByteCount(secret);
if (byteLength < MinSecretByteLength)
{
throw new InvalidOperationException(
$"JWT secret is too short ({byteLength} bytes). HMAC-SHA256 requires at least {MinSecretByteLength} bytes " +
$"per RFC 2104 §3. Set {JwtSecretEnvVar} or {JwtSecretConfigKey} to a longer value.");
}
return secret;
}
}
+39
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using SatelliteProvider.Api;
using SatelliteProvider.Api.Authentication;
using SatelliteProvider.Api.DTOs;
using SatelliteProvider.Api.Swagger;
using SatelliteProvider.DataAccess;
@@ -39,6 +40,9 @@ builder.Services.AddTileDownloader();
builder.Services.AddRegionProcessing();
builder.Services.AddRouteManagement();
builder.Services.AddSatelliteJwt(builder.Configuration);
builder.Services.AddAuthorization();
var allowedOrigins = builder.Configuration.GetSection("CorsConfig:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
var allowAnyOrigin = builder.Configuration.GetValue<bool>("CorsConfig:AllowAnyOrigin");
CorsConfigurationValidator.EnsureSafeForEnvironment(allowedOrigins, allowAnyOrigin, builder.Environment.EnvironmentName);
@@ -70,6 +74,31 @@ builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Satellite Provider API", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "JWT Authorization header using the Bearer scheme. Example: 'Bearer {token}'"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
c.MapType<UploadImageRequest>(() => new OpenApiSchema
{
Type = "object",
@@ -119,24 +148,31 @@ if (app.Environment.IsDevelopment())
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseCors("TilesCors");
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile)
.RequireAuthorization()
.WithOpenApi(op => new(op) { Summary = "Get satellite tile image by z/x/y coordinates (Slippy Map tile server)" });
app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon)
.RequireAuthorization()
.WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" });
app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
.RequireAuthorization()
.ProducesProblem(StatusCodes.Status501NotImplemented)
.WithOpenApi(op => new(op) { Summary = "Get satellite tiles by MGRS coordinates (NOT IMPLEMENTED)" });
app.MapPost("/api/satellite/upload", UploadImage)
.RequireAuthorization()
.Accepts<UploadImageRequest>("multipart/form-data")
.ProducesProblem(StatusCodes.Status501NotImplemented)
.WithOpenApi(op => new(op) { Summary = "Upload image with metadata (NOT IMPLEMENTED)" })
.DisableAntiforgery();
app.MapPost("/api/satellite/request", RequestRegion)
.RequireAuthorization()
.WithOpenApi(op => new(op)
{
Summary = "Request tiles for a region",
@@ -144,9 +180,11 @@ app.MapPost("/api/satellite/request", RequestRegion)
});
app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus)
.RequireAuthorization()
.WithOpenApi(op => new(op) { Summary = "Get region status and file paths" });
app.MapPost("/api/satellite/route", CreateRoute)
.RequireAuthorization()
.WithOpenApi(op => new(op)
{
Summary = "Create a route with intermediate points",
@@ -154,6 +192,7 @@ app.MapPost("/api/satellite/route", CreateRoute)
});
app.MapGet("/api/satellite/route/{id:guid}", GetRoute)
.RequireAuthorization()
.WithOpenApi(op => new(op) { Summary = "Get route information with calculated points" });
app.Run();
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.21" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.21"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
@@ -10,6 +10,9 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": "DEV-ONLY-DO-NOT-USE-IN-PROD-replace-with-real-secret-via-JWT_SECRET-env-var"
},
"MapConfig": {
"Service": "GoogleMaps",
"ApiKey": ""
+3
View File
@@ -23,6 +23,9 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=satelliteprovider;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": ""
},
"MapConfig": {
"Service": "GoogleMaps",
"ApiKey": "",
@@ -0,0 +1,146 @@
using System.Net;
using System.Net.Http.Headers;
namespace SatelliteProvider.IntegrationTests;
public static class JwtIntegrationTests
{
private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18";
private const string ProtectedRegionPath = "/api/satellite/region/00000000-0000-0000-0000-000000000000";
public static async Task RunAll(string apiUrl, string secret)
{
RouteTestHelpers.PrintTestHeader("Test: JWT auth baseline (AZ-487)");
await AnonymousRequest_To_AnyEndpoint_Returns401(apiUrl);
await ExpiredToken_Returns401(apiUrl, secret);
await InvalidSignature_Returns401(apiUrl, secret);
await ValidToken_Returns200_OnHealthyEndpoint(apiUrl, secret);
await SwaggerDocument_AdvertisesBearerSecurityScheme(apiUrl);
Console.WriteLine("✓ JWT integration tests: PASSED");
}
private static async Task AnonymousRequest_To_AnyEndpoint_Returns401(string apiUrl)
{
Console.WriteLine();
Console.WriteLine("AZ-487 AC-1: Anonymous request to a protected endpoint returns 401");
using var anon = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
var response = await anon.GetAsync(ProtectedTilesPath);
var status = (int)response.StatusCode;
if (status != 401)
{
throw new Exception($"Expected 401 for anonymous request, got {status}");
}
Console.WriteLine($" ✓ Anonymous request rejected with HTTP {status}");
}
private static async Task ExpiredToken_Returns401(string apiUrl, string secret)
{
Console.WriteLine();
Console.WriteLine("AZ-487 AC-2: Expired token returns 401");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
var expired = JwtTestHelpers.MintExpiredToken(secret);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", expired);
var response = await client.GetAsync(ProtectedTilesPath);
var status = (int)response.StatusCode;
if (status != 401)
{
throw new Exception($"Expected 401 for expired token, got {status}");
}
Console.WriteLine($" ✓ Expired token rejected with HTTP {status}");
}
private static async Task InvalidSignature_Returns401(string apiUrl, string secret)
{
Console.WriteLine();
Console.WriteLine("AZ-487 AC-3: Tampered signature returns 401");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
var valid = JwtTestHelpers.MintValidToken(secret);
var tampered = JwtTestHelpers.TamperSignature(valid);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tampered);
var response = await client.GetAsync(ProtectedRegionPath);
var status = (int)response.StatusCode;
if (status != 401)
{
throw new Exception($"Expected 401 for tampered signature, got {status}");
}
Console.WriteLine($" ✓ Tampered signature rejected with HTTP {status}");
}
private static async Task ValidToken_Returns200_OnHealthyEndpoint(string apiUrl, string secret)
{
Console.WriteLine();
Console.WriteLine("AZ-487 AC-4: Valid token reaches handler unchanged");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(2) };
var valid = JwtTestHelpers.MintValidToken(secret);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", valid);
var response = await client.GetAsync(ProtectedTilesPath);
var status = (int)response.StatusCode;
// The endpoint may legitimately return 200 (tile available) or a tile-download-related
// error; what we care about for AZ-487 is that the request reached the handler at all
// (i.e. NOT 401 / 403). Treat 200 as confirmation; treat anything other than 401/403 as
// "passed auth" — the handler decided the outcome.
if (status == 401 || status == 403)
{
var body = await response.Content.ReadAsStringAsync();
throw new Exception($"Expected valid-token request to bypass auth, got {status}. Body: {body}");
}
Console.WriteLine($" ✓ Valid-token request reached handler (HTTP {status})");
}
private static async Task SwaggerDocument_AdvertisesBearerSecurityScheme(string apiUrl)
{
// AC-7: Swagger UI accepts a Bearer token via the "Authorize" button. The button is
// rendered only when the OpenAPI document declares a `Bearer` security scheme — so the
// existence of that scheme in the document is the automatable signal for AC-7.
Console.WriteLine();
Console.WriteLine("AZ-487 AC-7: Swagger document advertises Bearer security scheme");
using var anon = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
var response = await anon.GetAsync("/swagger/v1/swagger.json");
if (!response.IsSuccessStatusCode)
{
throw new Exception($"Expected Swagger document to be reachable, got HTTP {(int)response.StatusCode}");
}
var body = await response.Content.ReadAsStringAsync();
using var doc = System.Text.Json.JsonDocument.Parse(body);
var root = doc.RootElement;
if (!root.TryGetProperty("components", out var components) ||
!components.TryGetProperty("securitySchemes", out var schemes) ||
!schemes.TryGetProperty("Bearer", out var bearer))
{
throw new Exception("Swagger document is missing `components.securitySchemes.Bearer`.");
}
var type = bearer.GetProperty("type").GetString();
var scheme = bearer.GetProperty("scheme").GetString();
var format = bearer.TryGetProperty("bearerFormat", out var bf) ? bf.GetString() : null;
if (!string.Equals(type, "http", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(scheme, "bearer", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(format, "JWT", StringComparison.OrdinalIgnoreCase))
{
throw new Exception($"Bearer scheme has wrong shape: type={type}, scheme={scheme}, bearerFormat={format}");
}
Console.WriteLine($" ✓ Swagger document declares Bearer (http, bearer, JWT)");
}
}
@@ -0,0 +1,86 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace SatelliteProvider.IntegrationTests;
public static class JwtTestHelpers
{
public const string JwtSecretEnvVar = "JWT_SECRET";
public const string DefaultSubject = "integration-tests";
public static string ResolveSecretOrThrow()
{
var secret = Environment.GetEnvironmentVariable(JwtSecretEnvVar);
if (string.IsNullOrWhiteSpace(secret))
{
throw new InvalidOperationException(
$"{JwtSecretEnvVar} is not set in the integration test environment. " +
"It must match the JWT_SECRET configured for the API container.");
}
var byteLength = Encoding.UTF8.GetByteCount(secret);
if (byteLength < 32)
{
throw new InvalidOperationException(
$"{JwtSecretEnvVar} is {byteLength} bytes; the test runner requires at least 32 bytes to match API validation.");
}
return secret;
}
public static string MintValidToken(string secret, string subject = DefaultSubject, TimeSpan? lifetime = null, IEnumerable<Claim>? extraClaims = null)
{
ArgumentNullException.ThrowIfNull(secret);
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, subject),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
};
if (extraClaims is not null)
{
claims.AddRange(extraClaims);
}
var token = new JwtSecurityToken(
issuer: null,
audience: null,
claims: claims,
notBefore: now,
expires: now.Add(lifetime ?? TimeSpan.FromHours(1)),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public static string MintExpiredToken(string secret, string subject = DefaultSubject)
{
return MintValidToken(secret, subject, lifetime: TimeSpan.FromMinutes(-10));
}
public static string TamperSignature(string token)
{
var parts = token.Split('.');
if (parts.Length != 3)
{
throw new ArgumentException("JWT must have three dot-separated segments.", nameof(token));
}
var signature = parts[2];
var firstChar = signature[0];
parts[2] = (firstChar == 'A' ? 'B' : 'A') + signature[1..];
return string.Join('.', parts);
}
public static void AttachDefaultAuthorization(HttpClient httpClient, string token)
{
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
@@ -17,10 +17,23 @@ class Program
TestRunMode.Smoke = modeEnv == "smoke";
}
string jwtSecret;
try
{
jwtSecret = JwtTestHelpers.ResolveSecretOrThrow();
}
catch (InvalidOperationException ex)
{
Console.WriteLine("❌ Integration tests cannot start without a valid JWT secret.");
Console.WriteLine($" {ex.Message}");
return 1;
}
Console.WriteLine("Starting Integration Tests");
Console.WriteLine("=========================");
Console.WriteLine($"API URL : {apiUrl}");
Console.WriteLine($"Mode : {(TestRunMode.Smoke ? "smoke (fast subset, tightened timeouts)" : "full")}");
Console.WriteLine($"Auth : JWT_SECRET resolved ({System.Text.Encoding.UTF8.GetByteCount(jwtSecret)} bytes)");
Console.WriteLine();
using var httpClient = new HttpClient
@@ -29,6 +42,9 @@ class Program
Timeout = TimeSpan.FromMinutes(15)
};
var defaultToken = JwtTestHelpers.MintValidToken(jwtSecret);
JwtTestHelpers.AttachDefaultAuthorization(httpClient, defaultToken);
try
{
Console.WriteLine("Waiting for API to be ready...");
@@ -36,6 +52,8 @@ class Program
Console.WriteLine("✓ API is ready");
Console.WriteLine();
await JwtIntegrationTests.RunAll(apiUrl, jwtSecret);
if (TestRunMode.Smoke)
{
await RunSmokeSuite(httpClient);
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Npgsql" Version="9.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.3" />
</ItemGroup>
</Project>
@@ -0,0 +1,151 @@
using System.IdentityModel.Tokens.Jwt;
using FluentAssertions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SatelliteProvider.Api.Authentication;
using SatelliteProvider.Tests.TestUtilities;
namespace SatelliteProvider.Tests.Authentication;
public class AuthenticationServiceCollectionExtensionsTests : IDisposable
{
private const string ValidSecret = "test-secret-that-is-definitely-longer-than-32-bytes";
private readonly string? _originalEnv;
public AuthenticationServiceCollectionExtensionsTests()
{
_originalEnv = Environment.GetEnvironmentVariable(AuthenticationServiceCollectionExtensions.JwtSecretEnvVar);
Environment.SetEnvironmentVariable(AuthenticationServiceCollectionExtensions.JwtSecretEnvVar, null);
}
public void Dispose()
{
Environment.SetEnvironmentVariable(AuthenticationServiceCollectionExtensions.JwtSecretEnvVar, _originalEnv);
GC.SuppressFinalize(this);
}
[Fact]
public void AddSatelliteJwt_RegistersJwtBearerScheme()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(("Jwt:Secret", ValidSecret));
// Act
services.AddSatelliteJwt(configuration);
var provider = services.BuildServiceProvider();
var schemeProvider = provider.GetRequiredService<IAuthenticationSchemeProvider>();
var scheme = schemeProvider.GetSchemeAsync(JwtBearerDefaults.AuthenticationScheme).GetAwaiter().GetResult();
// Assert
scheme.Should().NotBeNull("JwtBearer scheme should be registered");
scheme!.HandlerType.Should().Be(typeof(JwtBearerHandler));
}
[Fact]
public void AddSatelliteJwt_ConfiguresTokenValidationParameters_AsPerContract()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(("Jwt:Secret", ValidSecret));
// Act
services.AddSatelliteJwt(configuration);
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(JwtBearerDefaults.AuthenticationScheme);
// Assert
var p = options.TokenValidationParameters;
p.ValidateIssuerSigningKey.Should().BeTrue();
p.ValidateLifetime.Should().BeTrue();
p.ValidateIssuer.Should().BeFalse();
p.ValidateAudience.Should().BeFalse();
p.RequireSignedTokens.Should().BeTrue();
p.RequireExpirationTime.Should().BeTrue();
p.ClockSkew.Should().Be(TimeSpan.FromSeconds(30));
p.IssuerSigningKey.Should().BeOfType<SymmetricSecurityKey>();
}
[Fact]
public void AddSatelliteJwt_ThrowsOnMissingSecret()
{
// Arrange
var services = new ServiceCollection();
var configuration = BuildConfiguration();
// Act
var act = () => services.AddSatelliteJwt(configuration);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*JWT secret is not configured*");
}
[Fact]
public void AddSatelliteJwt_ThrowsOnEmptySecret()
{
// Arrange
var services = new ServiceCollection();
var configuration = BuildConfiguration(("Jwt:Secret", ""));
// Act
var act = () => services.AddSatelliteJwt(configuration);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*JWT secret is not configured*");
}
[Fact]
public void AddSatelliteJwt_ThrowsOnShortSecret()
{
// Arrange
var services = new ServiceCollection();
var configuration = BuildConfiguration(("Jwt:Secret", "too-short-secret"));
// Act
var act = () => services.AddSatelliteJwt(configuration);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*at least 32 bytes*");
}
[Fact]
public void AddSatelliteJwt_PrefersEnvironmentVariableOverConfiguration()
{
// Arrange
const string envSecret = "env-secret-also-longer-than-thirty-two-bytes-for-hmac";
Environment.SetEnvironmentVariable(AuthenticationServiceCollectionExtensions.JwtSecretEnvVar, envSecret);
var services = new ServiceCollection();
services.AddLogging();
var configuration = BuildConfiguration(("Jwt:Secret", "config-secret-also-32-bytes-long-aaaaaaaaaa"));
// Act
services.AddSatelliteJwt(configuration);
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(JwtBearerDefaults.AuthenticationScheme);
var token = JwtTokenFactory.Create(envSecret);
var handler = new JwtSecurityTokenHandler();
var act = () => handler.ValidateToken(token, options.TokenValidationParameters, out _);
// Assert
act.Should().NotThrow("token signed with env secret must validate when env secret takes precedence");
}
private static IConfiguration BuildConfiguration(params (string Key, string Value)[] pairs)
{
var builder = new ConfigurationBuilder();
if (pairs.Length > 0)
{
builder.AddInMemoryCollection(pairs.Select(p => new KeyValuePair<string, string?>(p.Key, p.Value)));
}
return builder.Build();
}
}
@@ -0,0 +1,93 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using Microsoft.IdentityModel.Tokens;
using SatelliteProvider.Tests.TestUtilities;
namespace SatelliteProvider.Tests.Authentication;
public class JwtTokenFactoryTests
{
private const string Secret = "factory-secret-that-is-longer-than-thirty-two-bytes-bytes";
[Fact]
public void Create_ProducesTokenValidatedByMatchingParameters()
{
// Arrange
var token = JwtTokenFactory.Create(Secret, subject: "alice");
var parameters = BuildParameters(Secret);
var handler = new JwtSecurityTokenHandler();
// Act
var principal = handler.ValidateToken(token, parameters, out var validatedToken);
// Assert
principal.Identity!.IsAuthenticated.Should().BeTrue();
principal.FindFirst(JwtRegisteredClaimNames.Sub)!.Value.Should().Be("alice");
validatedToken.Should().BeOfType<JwtSecurityToken>();
}
[Fact]
public void Create_WithExtraClaims_PropagatesClaimsThroughValidation()
{
// Arrange
var claims = new[]
{
new Claim("email", "alice@example.com"),
new Claim("role", "operator"),
new Claim("permissions", "GPS"),
new Claim("permissions", "FL")
};
var token = JwtTokenFactory.Create(Secret, extraClaims: claims);
var handler = new JwtSecurityTokenHandler();
// Act
var principal = handler.ValidateToken(token, BuildParameters(Secret), out _);
// Assert
principal.FindAll("permissions").Select(c => c.Value).Should().BeEquivalentTo(new[] { "GPS", "FL" });
principal.FindFirst("email")!.Value.Should().Be("alice@example.com");
}
[Fact]
public void CreateExpired_TokenFailsValidationWithLifetimeException()
{
// Arrange
var token = JwtTokenFactory.CreateExpired(Secret);
var handler = new JwtSecurityTokenHandler();
// Act
var act = () => handler.ValidateToken(token, BuildParameters(Secret), out _);
// Assert
act.Should().Throw<SecurityTokenExpiredException>();
}
[Fact]
public void TamperSignature_TokenFailsValidationWithSignatureException()
{
// Arrange
var token = JwtTokenFactory.Create(Secret);
var tampered = JwtTokenFactory.TamperSignature(token);
var handler = new JwtSecurityTokenHandler();
// Act
var act = () => handler.ValidateToken(tampered, BuildParameters(Secret), out _);
// Assert
act.Should().Throw<SecurityTokenInvalidSignatureException>();
}
private static TokenValidationParameters BuildParameters(string secret) => new()
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
ValidateIssuer = false,
ValidateAudience = false,
RequireSignedTokens = true,
RequireExpirationTime = true
};
}
@@ -0,0 +1,76 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace SatelliteProvider.Tests.TestUtilities;
public static class JwtTokenFactory
{
public const string DefaultSubject = "test-user";
public static string Create(
string secret,
string subject = DefaultSubject,
TimeSpan? lifetime = null,
IEnumerable<Claim>? extraClaims = null,
string algorithm = SecurityAlgorithms.HmacSha256)
{
ArgumentNullException.ThrowIfNull(secret);
var keyBytes = Encoding.UTF8.GetBytes(secret);
var signingKey = new SymmetricSecurityKey(keyBytes);
var credentials = new SigningCredentials(signingKey, algorithm);
var now = DateTime.UtcNow;
var expires = now.Add(lifetime ?? TimeSpan.FromHours(1));
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, subject),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
};
if (extraClaims is not null)
{
claims.AddRange(extraClaims);
}
var token = new JwtSecurityToken(
issuer: null,
audience: null,
claims: claims,
notBefore: now,
expires: expires,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public static string CreateExpired(string secret, string subject = DefaultSubject)
{
return Create(secret, subject, lifetime: TimeSpan.FromMinutes(-5));
}
public static string TamperSignature(string token)
{
ArgumentException.ThrowIfNullOrEmpty(token);
var parts = token.Split('.');
if (parts.Length != 3)
{
throw new ArgumentException("JWT must have three dot-separated parts.", nameof(token));
}
var signature = parts[2];
if (signature.Length == 0)
{
throw new ArgumentException("JWT signature segment is empty.", nameof(token));
}
var firstChar = signature[0];
var replacement = firstChar == 'A' ? 'B' : 'A';
parts[2] = replacement + signature[1..];
return string.Join('.', parts);
}
}
+13 -6
View File
@@ -22,7 +22,14 @@ The three Layer-3 service components are compile-time siblings: each only refere
- Single-instance deployment, no horizontal scaling requirements (`inferred-from: Channel-based queue, no distributed state`)
- Append-by-source tile storage — multiple producers (Google Maps, UAV upload, future SatAR, …) can each persist a row per `(latitude, longitude, tile_zoom, tile_size_meters)` cell. Reads return the most-recent row across sources, ordered by `captured_at DESC` with deterministic `(updated_at DESC, id DESC)` tie-breaks. The single-row-per-cell-per-source invariant is enforced by the 5-column unique index `idx_tiles_unique_location_source` introduced in migration 013 (AZ-484). The `tiles.version` column is vestigial since AZ-357 dropped year-based cache invalidation in favour of cell-level overwrite. (`inferred-from: tiles table + AZ-484/AZ-357 migrations + tile-storage contract v1.0.0`)
- Fire-and-forget async processing with status polling (`inferred-from: queue + background service + status endpoint`)
- No authentication layer — designed as an internal/trusted network service (`inferred-from: no auth middleware in Program.cs`)
- JWT-validated callers only — every HTTP endpoint requires a valid HS256-signed Bearer token, validated locally against a shared `JWT_SECRET` per the suite-level auth contract (`suite/_docs/10_auth.md`). Issuer/audience are intentionally not validated yet; signature + lifetime + ≥32-byte key are. Per-endpoint permission claims (e.g. `permissions: ["GPS"]` on the UAV upload) layer on top of this baseline.
**Authentication & Authorization** (AZ-487):
- Validation library: `Microsoft.AspNetCore.Authentication.JwtBearer` 8.0.21 (matches the rest of the ASP.NET Core 8 package set).
- Signing key: read from the `JWT_SECRET` environment variable (preferred) or the `Jwt:Secret` configuration key. Startup fails fast if the resolved secret is unset, empty, or shorter than 32 bytes (HMAC-SHA256 minimum per RFC 2104 §3).
- Token contract: `ValidateIssuerSigningKey = true`, `ValidateLifetime = true`, `RequireSignedTokens = true`, `RequireExpirationTime = true`, `ValidateIssuer/Audience = false`, `ClockSkew = 30s`. The 5-minute JwtBearer default is intentionally tightened.
- Authorization model: every endpoint registered in `Program.cs` is decorated with `.RequireAuthorization()`. AZ-488 adds `permissions`-claim policies on top of this baseline (UAV upload requires `GPS`).
- Test infrastructure: `JwtTokenFactory` (unit tests) and `JwtTestHelpers` (integration tests) mint deterministic tokens against the same `JWT_SECRET`; the integration test runner attaches a default Bearer token to its shared `HttpClient` so legacy non-auth tests continue to exercise the protected endpoints unchanged.
**Planned features** (confirmed by user, currently stubs):
- MGRS endpoint — tile access via Military Grid Reference System coordinates
@@ -132,16 +139,16 @@ The N-source storage contract is authoritative in `_docs/02_document/contracts/d
## 7. Security Architecture
**Authentication**: None (internal service, no auth layer)
**Authentication**: HS256 JWT Bearer tokens (AZ-487). Signing key from `JWT_SECRET` env var (≥ 32 bytes, validated at startup). `Microsoft.AspNetCore.Authentication.JwtBearer` validates signature, lifetime, and signing key; issuer and audience are intentionally not validated (suite contract does not specify expected values). ClockSkew tightened from JwtBearer default (5 min) to 30 s. Tokens are minted by the centralized Admin API per `suite/_docs/10_auth.md`.
**Authorization**: None (all endpoints are open)
**Authorization**: Every endpoint requires authentication via `.RequireAuthorization()`. Permission-claim enforcement (e.g. `permissions: ["GPS"]`) is added per-endpoint where needed — AZ-488 introduces it on `POST /api/satellite/upload`. Other endpoints accept any authenticated principal.
**Data protection**:
- At rest: No encryption (tiles stored as plain JPEG files)
- In transit: HTTPS for Google Maps calls; API itself on HTTP
- Secrets management: Google Maps session token in appsettings / env vars
- In transit: HTTPS for Google Maps calls; API itself runs HTTP behind Kestrel (TLS termination is a deployment-layer concern)
- Secrets management: `JWT_SECRET` and `GOOGLE_MAPS_API_KEY` from environment variables / `.env` (gitignored); `.env.example` documents the required keys. Production deployments MUST supply both via the host environment, never via the appsettings files.
**Audit logging**: Serilog writes to file; logs exceptions and processing state transitions
**Audit logging**: Serilog writes to file; logs exceptions and processing state transitions. 401/403 responses are emitted by the JwtBearer middleware via the `WWW-Authenticate` header; no body leakage of internal details.
## 8. Key Architectural Decisions
+7 -4
View File
@@ -36,12 +36,14 @@ Application entry point. Configures DI container, sets up middleware, defines mi
6. Hosted services: `RegionProcessingService`, `RouteProcessingService`
7. CORS policy: `TilesCors` — configured origins from `CorsConfig:AllowedOrigins`, falls back to allow-any
8. JSON options: camelCase, case-insensitive
9. **JWT authentication (AZ-487)**: `AddSatelliteJwt(builder.Configuration)` (extension in `SatelliteProvider.Api.Authentication`) registers `JwtBearer` with `TokenValidationParameters` set per the suite auth contract (signature + lifetime, no issuer/audience validation, 30 s clock skew, ≥ 32-byte HMAC key). Followed by `AddAuthorization()`.
### Startup
1. Database migration via `DatabaseMigrator.RunMigrations()` — throws on failure
2. Creates tiles and ready directories
3. Swagger enabled in Development mode
4. HTTPS redirection, CORS applied
4. Middleware chain (order matters): `UseExceptionHandler``UseHttpsRedirection``UseCors("TilesCors")``UseAuthentication``UseAuthorization` → endpoint mapping.
5. Every `MapGet`/`MapPost` endpoint is decorated with `.RequireAuthorization()`; the framework returns 401 before the handler runs for any anonymous, expired, or invalid-signature request.
### ServeTile Handler
1. Checks `IMemoryCache` for tile bytes (1h absolute, 30min sliding expiration)
@@ -57,7 +59,7 @@ Validates size (10010000m), delegates to `IRegionService.RequestRegionAsync`.
## Dependencies
All project references: Common, DataAccess, Services.
NuGet: `Serilog.AspNetCore`, `Swashbuckle.AspNetCore`, `Microsoft.AspNetCore.OpenApi`, `SixLabors.ImageSharp`, `Newtonsoft.Json`.
NuGet: `Serilog.AspNetCore`, `Swashbuckle.AspNetCore`, `Microsoft.AspNetCore.OpenApi`, `Microsoft.AspNetCore.Authentication.JwtBearer` (8.0.21, AZ-487), `SixLabors.ImageSharp`, `Newtonsoft.Json`.
## Consumers
- HTTP clients (external)
@@ -71,6 +73,7 @@ All configuration sections are consumed here:
- `ConnectionStrings:DefaultConnection`
- `MapConfig`, `StorageConfig`, `ProcessingConfig`
- `CorsConfig:AllowedOrigins`
- `Jwt:Secret` — HMAC-SHA256 signing key for JWT validation (AZ-487). Resolution: `JWT_SECRET` env var (preferred, opaque production secret) → `Jwt:Secret` configuration key (`appsettings.Development.json` placeholder only). Startup fails fast if the resolved value is unset, empty, or shorter than 32 bytes.
- `Serilog` section
## External Integrations
@@ -80,9 +83,9 @@ All configuration sections are consumed here:
## Security
- CORS configured (permissive by default when no origins specified)
- Swagger only in Development
- Swagger only in Development; Bearer token "Authorize" button registered via `AddSecurityDefinition`/`AddSecurityRequirement` (AZ-487)
- HTTPS redirection enabled
- No authentication/authorization implemented
- JWT bearer authentication (AZ-487) — every endpoint requires a valid HS256-signed token. Anonymous, expired, or signature-tampered requests return 401 before the handler runs. Per-endpoint permission claims are layered on top in subsequent PBIs (e.g. AZ-488 requires `permissions: ["GPS"]` on the upload endpoint).
## Tests
Integration tests exercise all endpoints. Unit test project has only a dummy test.
@@ -0,0 +1,49 @@
# Acceptance Criteria Assessment — UAV Batch Upload
**Date**: 2026-05-11
**Mode**: Research Phase 1 (AC & Restrictions Assessment)
**Result**: No changes required — user explicitly validated all 5 critical assumptions in the new-task Step 2 BLOCKING gate immediately preceding this research run.
## Acceptance Criteria
| Criterion | Our Values | Researched Values | Cost/Timeline Impact | Status |
|-----------|------------|-------------------|----------------------|--------|
| Per-tile minimum metadata | lat, lon, tile_zoom, tile_size_meters, captured_at, image bytes | Industry-standard for tile uploads (TMS / OGC Tile API) defines x/y/zoom + time + payload as the minimum viable set | None — matches industry norms | Confirmed |
| Sync 200 + per-tile mixed array response | 200 OK with `[{tileId, status, reason?}]`; 4xx for batch-level failures only | Common pattern in batch APIs; alternative HTTP 207 Multi-Status (RFC 4918) is more correct semantically but rarely used outside WebDAV ecosystems and adds caller complexity for no real gain | None — sync 200 chosen, no change | Confirmed |
| JWT HS256 with `sub` + `exp` only | minimum claims model | NIST SP 800-204A and OAuth 2.0 BCP recommend `iss` + `aud` + `exp` + `sub` for federated; for an internal service with shared HS256 secret, `sub` + `exp` is the documented "trusted-network" minimum (microsoft-jwt-bearer docs) | None | Confirmed |
| Quality gate verdict response-only (not persisted) | string in response, no DB column | Avoids triggering L-001 (Dapper enum bypass); reject reasons are inherently transient — re-uploading should re-run the gate | None — saves a DB column | Confirmed |
| File storage = same `./tiles/{z}/{x}/{y}.jpg` layout as Google Maps | reuse existing layout | The 5-col unique index `(lat, lon, tile_zoom, tile_size_meters, source)` already keeps UAV and GMaps rows separate at the DB level; the file path can collide BUT the per-source UPSERT will overwrite the file owner deterministically (most-recent wins). Single-file-per-cell layout is acceptable IF we agree that the reader gets the most-recently-uploaded JPEG regardless of source. | Acceptable; flagged as a Risk in the eventual task spec — the user may want UAV files in `./tiles/uav/{z}/{x}/{y}.jpg` to keep both bytes addressable for forensics | **Recommend re-confirm in Step 5 Validate Assumptions** |
## Restrictions Assessment
| Restriction | Our Values | Researched Values | Cost/Timeline Impact | Status |
|-------------|------------|-------------------|----------------------|--------|
| .NET 8.0 only | constraint | LTS until November 2026; all libraries researched (`Microsoft.AspNetCore.Authentication.JwtBearer 8.x`, `SixLabors.ImageSharp 3.1.x`) support .NET 8 | None | Confirmed |
| ImageSharp 3.1.11 (existing pin) | constraint | 3.1.11 is current stable in the 3.1 LTS line; APIs for `Image.Identify`, `Image<TPixel>` enumeration are stable since 3.0 | None | Confirmed |
| Npgsql 9.0.2, Dapper 2.1.35 | constraint | No version-specific concerns for the per-source UPSERT path already in `TileRepository` | None | Confirmed |
| No secret manager today | constraint | Forces JWT signing key into env var or appsettings (not Key Vault). User must accept this trade-off; flagged in Security Audit too | None for this cycle | Confirmed |
| Existing `GeofencePolygon` DTO is bbox not polygon | implicit constraint discovered during pre-research grep | Reusing the bbox shape for AZ-488 is consistent with current code; introducing true polygons is a bigger change | Could push AZ-488 from 2 SP to 4-5 SP if true polygons chosen | **Material to AZ-488 sizing — see Q4 in the eventual draft** |
## Key Findings
1. **One genuine open assumption to re-confirm** in new-task Step 5: file storage layout. Same-path-as-GMaps lets the latest source win at the file level too (consistent with the DB read rule), but loses bytes-level forensics for the loser. A separate `./tiles/uav/...` path keeps both — at the cost of a divergent storage convention. The user said "yes, as google maps tiles" in Step 1 so the default is the same path; flagging it once for the spec author.
2. **AZ-488 may be larger than 2 SP** if user wants real polygons. The AZ-485 family research pass should present both options with sizing impact so the user can decide once at spec time, not later during implementation.
3. **No additional AC needed** — the 5 assumptions cover the deliverable surface for this task family. No regulatory / compliance dimension surfaced (this is an internal API — not a public consumer-facing endpoint subject to data-handling regs).
4. **No restrictions to relax** — every constraint listed is forced by either project state (existing libs, no auth, no secret manager) or user decision (sync 200, minimum metadata).
## Sources
- IETF RFC 4918 (HTTP 207 Multi-Status) — read for the response-shape decision; concluded not applicable
- NIST SP 800-204A § 3.4 (Microservice JWT validation) — confirms minimum-claims pattern is acceptable for internal trusted-network services
- `Microsoft.AspNetCore.Authentication.JwtBearer` Microsoft Learn page (verified to be the active 8.x line)
- `SixLabors.ImageSharp` 3.1 release notes on `Image.Identify` stability
- Codebase grep for existing geofence implementation (read locally — no external source)
## BLOCKING decision
**Phase 1 closes without re-blocking the user.** All 5 user-confirmed assumptions stand. The single re-confirmation item (file storage path) will surface naturally at new-task Step 5 (Validate Assumptions); the research draft will pre-fill both options with their trade-offs so the user can decide in one click.
Proceeding to Phase 2 (Problem Research & Solution Draft).
@@ -0,0 +1,358 @@
# Solution Draft 01 — UAV Batch Upload (AZ-485 family)
**Date**: 2026-05-11
**Mode**: Research Mode A (Initial)
**Output Class**: Technical-component selection
**Inputs**: `_docs/02_task_plans/uav-batch-upload/problem.md`; `_docs/02_task_plans/uav-batch-upload/00_research/00_ac_assessment.md`
**Status**: Draft 01 (no prior drafts to assess)
## Core conclusion
The four-task family (AZ-485/486/487/488) is **build-grade-tractable on top of the existing stack — no new dependencies required for AZ-485, AZ-486, or AZ-488; AZ-487 brings in one new built-in NuGet package** (`Microsoft.AspNetCore.Authentication.JwtBearer 8.0.x`, ships with the framework). The only architectural decision worth the user's attention is the **geofence shape (Q4)** — keep the existing bbox or move to polygon/NetTopologySuite — and that decision has a real SP impact on AZ-488 (2 → 4 SP).
## Key findings (5 points)
1. **JWT/AZ-487**: `Microsoft.AspNetCore.Authentication.JwtBearer 8.0.x` (ships with the framework, version-pinned to ASP.NET Core 8) handles HS256 + the user's "validate signature + `exp` only" requirement out of the box via `TokenValidationParameters` with `ValidateIssuer = false`, `ValidateAudience = false`, `ValidateLifetime = true`, and a `SymmetricSecurityKey`. No third-party library justified.
2. **Multipart/AZ-485**: ASP.NET Core 8 minimal API supports `IFormFileCollection` + scalar `[FromForm]` parameters natively. Per-endpoint `MaxRequestBodySize` and `MultipartBodyLengthLimit` overrides apply via endpoint metadata (`IRequestSizeLimitMetadata`, `IFormOptionsMetadata`). `app.MapPost(...).DisableAntiforgery()` is required because antiforgery is enabled by default for forms but irrelevant to a JWT-protected non-browser endpoint.
3. **ImageSharp/AZ-486**: ImageSharp 3.1.11 already-pinned API surface is sufficient — `Image.Identify` for dimensions + format without decoding, `Image.DetectFormat` for magic-bytes check, and `Image<L8>.ProcessPixelRows` for streaming-grade luminance stddev computation. No additional NuGet packages needed. Welford's online algorithm fits the per-row callback shape perfectly.
4. **Geofence/AZ-488**: **Recommend Option A — reuse the existing `GeofencePolygon` bbox shape** (NorthWest + SouthEast corners). Rationale: the codebase already understands bbox; the user said "geofence" without specifying polygon; ray-casting is overkill for an internal whitelist that will likely have ≤ 5 axis-aligned regions. NetTopologySuite is **rejected** for this scope (heavy, +9 MB, includes a full topology engine when we need a 4-line containment check). Option B (true polygons) is **deferred** as a future task if real-world geofences turn out to need shapes beyond rectangles.
5. **File+DB consistency/AZ-485**: **Recommend write-row-then-write-file with idempotent overwrite**. The per-source UPSERT (already in place from AZ-484) makes the row write idempotent; the file write is `File.WriteAllBytesAsync(tempPath); File.Move(tempPath, finalPath, overwrite: true)` for atomic replace. On file write failure: log + return `rejected: storage_failure` per tile in the response; the row stays as authoritative metadata pointing at the (now-missing) file path. Pre-existing GMaps download path has the same coupling (`TileService.DownloadAndStoreTilesAsync`) — match its behavior, not invent a new one.
---
## Project Constraint Matrix
| Constraint Source | Constraint | Impact on Component Selection |
|--|--|--|
| problem.md / user Step 1 | Sync 200 with mixed `[{tileId, status, reason?}]` | Rules out async/202 patterns; rules out HTTP 207 |
| problem.md / user Step 1 | Minimum metadata only | No new request DTO complexity; ~5 fields per tile |
| problem.md / user Step 1 | All 6 quality checks | AZ-486 is structurally a pipeline of 6 small validators |
| problem.md / user Step 1 | JWT HS256, claims `sub`+`exp` only | Rules out IdP/OIDC integration; symmetric key in env |
| problem.md / user Step 1 | Same `./tiles/{z}/{x}/{y}.jpg` path as GMaps | UAV file overwrites GMaps file when uploaded for the same cell — file-level "most recent wins" matches DB-level rule |
| existing code | `GeofencePolygon` is a bbox in this codebase | Polygon would be a new concept; bbox is consistent |
| existing code | `TileSourceConverter.ToWireValue(TileSource.Uav)` already exists from AZ-484 | UAV row writes are 1-line conversion at the boundary |
| existing code | Per-source UPSERT in `TileRepository.InsertAsync` (AZ-484) | UAV InsertAsync = same call site as GMaps; no new repository method |
| existing code | `TileService.DownloadAndStoreTilesAsync` writes file then row | Establish file-write/row-write order parity |
| existing code | No auth middleware | AZ-487 must wire `app.UseAuthentication()` + `.UseAuthorization()` from scratch; tests must construct valid tokens |
| existing code | No rate limiter | Out of scope for this family; recorded as Risk |
| LESSONS L-001 | Dapper TypeHandler<T> bypassed for enum reads | No new persisted enums in this family; verdict stays response-only |
| LESSONS ring-buffer/process | NFR + runner script must land same step | Any AZ-485 perf NFR (e.g., batch throughput) must come with the runner scenario |
| LESSONS ring-buffer/estimation | Test-site counts need grep evidence | new-task Step 4 codebase analysis must grep, not estimate |
| .NET 8.0 LTS only | Pin all package versions to 8.0.x major | `Microsoft.AspNetCore.Authentication.JwtBearer 8.0.x`, no 9.x |
## Component Fit Matrix
### Q1 — JWT auth (AZ-487)
| Candidate | Fit verdict | Notes |
|--|--|--|
| `Microsoft.AspNetCore.Authentication.JwtBearer 8.0.x` | **Selected** | Ships with ASP.NET Core 8; supports HS256 via `SymmetricSecurityKey`; `ValidateIssuer=false`, `ValidateAudience=false`, `ValidateLifetime=true` matches user's "claims = sub + exp only"; per-endpoint `RequireAuthorization()` works on minimal API |
| `OpenIddict 5.x` | Rejected | Full OAuth2/OIDC server framework; orders of magnitude bigger than needed for HS256-validation-only |
| Custom token middleware | Rejected | Reinvents token parsing/validation; security-critical code that should not be hand-rolled |
| `IdentityServer / Duende` | Rejected | Issuer/server, not validator; commercial license |
**Mode pinned**: `JwtBearerOptions.TokenValidationParameters` with explicit symmetric-key shape. Mandatory MVE below.
### Q2 — Multipart batch upload (AZ-485)
| Candidate | Fit verdict | Notes |
|--|--|--|
| Minimal API `IFormFileCollection` + `[FromForm]` scalar fields | **Selected for batches up to ~50 MB** | Native to ASP.NET Core 8; no wire shape issue; FormOptions overridable per-endpoint |
| `MultipartReader` streaming | Deferred | Required only if batches exceed in-memory budget. Not in initial scope; revisit if production telemetry shows batches >50 MB. |
| Separate per-tile POST (no batch) | Rejected — user explicitly chose batch | Would simplify code but contradicts requirement |
**Mode pinned**: `app.MapPost("/api/satellite/uav/upload", (IFormFileCollection files, [FromForm] string metadata, ...) => ...).DisableAntiforgery().RequireAuthorization()`. Per-endpoint size cap via `.WithMetadata(new RequestSizeLimitMetadata { MaxRequestBodySize = 50_000_000 })` and `.WithMetadata(new FormOptionsMetadata { MultipartBodyLengthLimit = 50_000_000 })`.
### Q3 — Quality gate (AZ-486)
| Check | API | Performance |
|--|--|--|
| Format = JPEG | `Image.DetectFormat(stream)` then check `format.Name == "JPEG"` | Header-only read; ~1 ms per tile |
| Dimensions = `MapConfig.TileSizePixels` (256) | `Image.Identify(stream)` returns `ImageInfo { Width, Height }` | Same header-only path; same ~1 ms |
| File size 4 KB ≤ size ≤ 2 MB | `IFormFile.Length` direct check | O(1), no decode |
| `captured_at` within last 365 days | `DateTime.UtcNow - capturedAt ≤ TimeSpan.FromDays(365)` | O(1) |
| Blank/uniform: luminance stddev < 5 | `Image.Load<L8>(stream).ProcessPixelRows(accessor => ...)` Welford online stddev | ~58 ms per 256×256 tile (single-channel decode + one pass) |
| Geofence containment | `GeofenceWhitelist.Contains(lat, lon)` (AZ-488) | O(M) per tile, M ≤ 10 polygons → negligible |
**Mode pinned**: `Image.Identify` first (cheap, gives format + dimensions in one call); only proceed to `Image.Load<L8>` for the blank-detection check after the cheap checks pass. Total per-tile budget under 15 ms.
| Candidate alternatives | Fit verdict |
|--|--|
| Magick.NET / ImageMagick | Rejected — second image library on the same stack; ImageSharp already pinned and sufficient |
| `System.Drawing.Common` | Rejected — Microsoft has marked `System.Drawing.Common` non-cross-platform on .NET 6+; would break Linux container |
| Pre-trained ML "is-this-blank" model | Rejected — overkill; Welford stddev is the canonical, fast baseline |
### Q4 — Geofence shape (AZ-488)
| Candidate | Fit verdict | Sizing impact |
|--|--|--|
| **Reuse `GeofencePolygon` bbox** (NW + SE corners, point-in-bbox check is a 4-comparison if-statement) | **Selected** | AZ-488 = 2 SP as originally scoped |
| True polygon (lat/lon vertex list, ray-casting from scratch) | Deferred | Would push AZ-488 to ~4 SP (new DTO + ray-cast utility + tests for edge cases like point-on-edge, antimeridian crossing) |
| `NetTopologySuite 2.5.x` | Rejected | +9 MB binary, full topology engine; not justified for ≤ 10 axis-aligned whitelisted regions |
Storage: `appsettings.json` array under `UavQualityGate.GeofenceWhitelist[]`, list of `{ northWest: {lat,lon}, southEast: {lat,lon} }`. Empty array = "allow all" (sentinel). Loaded via `IOptions<UavQualityGateConfig>` like every other config in this codebase.
### Q5 — File+DB consistency (AZ-485)
| Candidate | Fit verdict |
|--|--|
| **Write file with `.tmp`, then `InsertAsync` (UPSERT), then `File.Move(temp → final, overwrite: true)`** | **Selected** | Two-phase: file write is recoverable (delete .tmp on failure), DB write is idempotent (UPSERT), final atomic rename. |
| Write row first, then file | Rejected | Race window: row points at non-existent file if process crashes between |
| Distributed transaction across FS + DB (e.g., `TransactionScope`) | Rejected | `System.IO.File` is not a transactional resource manager on Linux — would silently no-op the FS rollback |
| Outbox pattern with retry queue | Deferred | Worth considering if production traffic warrants exactly-once-delivery guarantees; out of scope for this cycle |
## Tech Stack Summary
| Layer | Choice | Version | Justification |
|--|--|--|--|
| HTTP framework | ASP.NET Core minimal API | 8.0 (existing) | Match existing endpoints |
| Auth middleware | `Microsoft.AspNetCore.Authentication.JwtBearer` | 8.0.x | Built-in; HS256 supported |
| Image inspection | `SixLabors.ImageSharp` | 3.1.11 (existing) | Already pinned; supports `Identify`, `Load<L8>`, `ProcessPixelRows` |
| DB access | `Dapper` + `Npgsql` | 2.1.35 / 9.0.2 (existing) | UPSERT via `TileRepository.InsertAsync` (AZ-484) |
| Geofence | Reuse `SatelliteProvider.Common.DTO.GeofencePolygon` | existing | Bbox shape, simple containment check |
| Config | `Microsoft.Extensions.Options` `IOptions<T>` | 8.0.x (existing) | New `UavQualityGateConfig` registered same way as `MapConfig`, etc. |
**No new top-level dependencies on top of the existing stack** other than the JwtBearer package.
## Security analysis (concise)
| Threat | Control | Where |
|--|--|--|
| Forged tokens | HMAC SHA-256 signature validation against shared secret | AZ-487 — `JwtBearerOptions.TokenValidationParameters` |
| Token replay | `exp` claim validation | AZ-487 — `ValidateLifetime = true` |
| Signing key leak | env var, not in source; documented in `.env.example` (matches existing GMaps API key handling) | AZ-487 + AGENTS.md update |
| Path traversal in upload | Tile path is computed from validated lat/lon/zoom server-side, not from any user-supplied filename | AZ-485 — `tile_x/tile_y` derived via existing `GeoUtils.WorldToTilePos` |
| Image-decode attacks (zip bomb, malformed JPEG) | `Image.Identify` uses bounded reads; `Image.Load` wrapped in try/catch around `InvalidImageContentException` and `UnknownImageFormatException`; per-file size cap before decode | AZ-486 |
| Disk fill DoS | Per-batch size cap (`MaxRequestBodySize = 50 MB`); per-file size cap (2 MB); rate limiter recommended but tracked separately from this family | AZ-485 — endpoint metadata; rate limiter is Security Audit I3 deferred |
| JWT logged in logs | `Serilog` request logging configured today does not log Authorization header; verify Pii filter is on | AZ-487 verification step |
## Risks & Mitigations
| Risk | Severity | Mitigation |
|--|--|--|
| File overwrite for same cell across sources causes data loss at the file system level (DB row carries source, file does not) | Medium | Re-confirm with user in new-task Step 5 — option to use `./tiles/uav/{z}/{x}/{y}.jpg` to keep both bytes addressable. Default is "yes, same path" per Step 1 answer. |
| Quality-gate thresholds (blank stddev, age limit, byte size bounds) need real-world tuning | Low | Make every threshold configurable via `UavQualityGateConfig`; ship defaults; tune in production telemetry |
| JWT signing-key rotation procedure | Medium | Out of scope for AZ-487; document the procedure ("change env var + restart") in `_docs/02_document/deployment/`. Shared secret rotation requires a coordinated upload-client + server change, accepted limitation |
| Test infrastructure for JWT (integration tests must mint tokens) | Low | Use `JsonWebTokenHandler` from `System.IdentityModel.Tokens.Jwt` in test code with the same shared secret as the test config; pattern is well-trodden |
| Adversarial UAV uploader sends `captured_at` in the future | Low | Add a 7th implicit check: `captured_at <= DateTime.UtcNow`. Cheap, catches one obvious abuse |
## Minimum Viable Examples (saved verbatim per per-mode API gate)
### MVE-1 — JWT validation (Q1 selected mode)
```csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
var key = builder.Configuration["UavAuth:SigningKey"]
?? throw new InvalidOperationException("UavAuth:SigningKey is required");
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
ClockSkew = TimeSpan.FromSeconds(30),
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapPost("/api/satellite/uav/upload", (IFormFileCollection files, [FromForm] string metadata) =>
{
return Results.Ok(new { received = files.Count });
})
.DisableAntiforgery()
.RequireAuthorization();
```
### MVE-2 — Per-endpoint size limits (Q2 selected mode)
```csharp
using Microsoft.AspNetCore.Http.Metadata;
const long MaxBatchBytes = 50L * 1024 * 1024;
app.MapPost("/api/satellite/uav/upload", handler)
.DisableAntiforgery()
.RequireAuthorization()
.WithMetadata(
new RequestSizeLimitMetadata { MaxRequestBodySize = MaxBatchBytes },
new FormOptionsMetadata
{
MultipartBodyLengthLimit = MaxBatchBytes,
ValueLengthLimit = 16 * 1024,
MultipartBoundaryLengthLimit = 256,
});
```
### MVE-3 — Quality gate (Q3 selected mode)
```csharp
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
public sealed record QualityResult(bool Accepted, string? Reason);
public sealed class TileQualityGate
{
private readonly UavQualityGateConfig _cfg;
public QualityResult Check(IFormFile file, double lat, double lon, DateTime capturedAt, GeofenceWhitelist geofence)
{
if (file.Length < _cfg.MinFileBytes) return new(false, "file_too_small");
if (file.Length > _cfg.MaxFileBytes) return new(false, "file_too_large");
var capturedAge = DateTime.UtcNow - capturedAt;
if (capturedAge > TimeSpan.FromDays(_cfg.MaxAgeDays)) return new(false, "captured_at_too_old");
if (capturedAge < TimeSpan.Zero) return new(false, "captured_at_in_future");
if (!geofence.Contains(lat, lon)) return new(false, "outside_geofence");
using var stream = file.OpenReadStream();
IImageFormat? detectedFormat = Image.DetectFormat(stream);
if (detectedFormat is null || detectedFormat.Name != "JPEG") return new(false, "format_not_jpeg");
stream.Position = 0;
ImageInfo info = Image.Identify(stream);
if (info.Width != _cfg.ExpectedTilePixels || info.Height != _cfg.ExpectedTilePixels)
return new(false, "wrong_dimensions");
stream.Position = 0;
try
{
using var img = Image.Load<L8>(stream);
double mean = 0, m2 = 0; long n = 0;
img.ProcessPixelRows(accessor =>
{
for (int y = 0; y < accessor.Height; y++)
{
var row = accessor.GetRowSpan(y);
for (int x = 0; x < row.Length; x++)
{
double v = row[x].PackedValue;
n++;
double delta = v - mean;
mean += delta / n;
m2 += delta * (v - mean);
}
}
});
double stddev = n > 1 ? Math.Sqrt(m2 / (n - 1)) : 0;
if (stddev < _cfg.MinLuminanceStdDev) return new(false, "image_blank_or_uniform");
}
catch (InvalidImageContentException) { return new(false, "image_corrupt"); }
catch (UnknownImageFormatException) { return new(false, "format_unknown"); }
return new(true, null);
}
}
```
### MVE-4 — Geofence whitelist (Q4 selected mode)
```csharp
public sealed class GeofenceWhitelist
{
private readonly IReadOnlyList<GeofencePolygon> _polygons;
public GeofenceWhitelist(IReadOnlyList<GeofencePolygon> polygons) => _polygons = polygons;
public bool Contains(double lat, double lon)
{
if (_polygons.Count == 0) return true; // sentinel: empty list = allow all
foreach (var p in _polygons)
{
if (p.NorthWest is null || p.SouthEast is null) continue;
if (lat <= p.NorthWest.Lat && lat >= p.SouthEast.Lat
&& lon >= p.NorthWest.Lon && lon <= p.SouthEast.Lon)
return true;
}
return false;
}
}
```
### MVE-5 — File+DB consistency (Q5 selected mode)
```csharp
public sealed class UavTilePersister
{
private readonly ITileRepository _repo;
private readonly StorageConfig _cfg;
private readonly ILogger<UavTilePersister> _log;
public async Task<Guid?> PersistAsync(IFormFile file, double lat, double lon, int zoom,
double tileSizeMeters, DateTime capturedAt, CancellationToken ct)
{
var (tileX, tileY) = GeoUtils.WorldToTilePos(new GeoPoint(lat, lon), zoom);
var finalPath = Path.Combine(_cfg.TilesRoot, zoom.ToString(), tileX.ToString(), tileY + ".jpg");
var tempPath = finalPath + ".uav.tmp";
Directory.CreateDirectory(Path.GetDirectoryName(finalPath)!);
await using (var dst = File.Create(tempPath))
await file.CopyToAsync(dst, ct);
try
{
var entity = new TileEntity
{
Id = Guid.NewGuid(),
TileZoom = zoom, TileX = tileX, TileY = tileY,
Latitude = lat, Longitude = lon,
TileSizeMeters = tileSizeMeters,
TileSizePixels = _cfg.ExpectedTilePixels,
ImageType = "jpg",
Source = TileSourceConverter.ToWireValue(TileSource.Uav),
CapturedAt = capturedAt,
FilePath = Path.GetRelativePath(AppContext.BaseDirectory, finalPath),
};
var id = await _repo.InsertAsync(entity);
File.Move(tempPath, finalPath, overwrite: true);
return id;
}
catch
{
File.Delete(tempPath);
throw;
}
}
}
```
## Sources
| Source | Tier | Citation |
|--|--|--|
| ASP.NET Core 8.0.21 source — JwtBearer public API | L1 | `github.com/dotnet/aspnetcore/blob/v8.0.21/src/Security/Authentication/JwtBearer/src/PublicAPI.Shipped.txt` (via context7 `/dotnet/aspnetcore/v8.0.21`) |
| ASP.NET Core 8.0.21 source — FormOptions, IRequestSizeLimitMetadata | L1 | `github.com/dotnet/aspnetcore/blob/v8.0.21/src/Http/Http/src/PublicAPI.Shipped.txt` |
| ASP.NET Core docs — JWT Bearer config sample | L1 | `learn.microsoft.com/aspnet/core/security/authentication/configure-jwt-bearer-authentication` (via context7 `/dotnet/aspnetcore`) |
| SixLabors.ImageSharp docs — Image.Identify, ProcessPixelRows | L1 | `docs.sixlabors.com/articles/imagesharp/imageinfo` (via context7 `/sixlabors/imagesharp`) |
| Codebase grep — `GeofencePolygon` shape, `TileRepository.InsertAsync` UPSERT | L1 (project source) | `SatelliteProvider.Common/DTO/GeofencePolygon.cs`, `SatelliteProvider.DataAccess/Repositories/TileRepository.cs` |
| AZ-484 task family closure (this repo, cycle 1) | L1 (project history) | `_docs/03_implementation/implementation_report_multi_source_tile_storage_cycle1.md`; `_docs/02_document/contracts/data-access/tile-storage.md` |
## Quality checklist
- [x] Each component selection has an exact-fit verdict against the Project Constraint Matrix
- [x] Per-mode API capability verification done for each library (JwtBearer, ASP.NET Core multipart, ImageSharp)
- [x] MVE saved for every selected mode (5 of them)
- [x] "Do not use" / Rejected list present per question
- [x] L1 sources for every key claim
- [x] Multi-perspective considered: implementer (MVE), security architect (threats table), domain (existing code constraints), contrarian (rejected NTS / OpenIddict / Magick.NET / async-202)
- [x] No conclusions from "gut feel" — every selection has matrix evidence
@@ -0,0 +1,95 @@
# Research Problem — UAV Batch Upload (AZ-485 family)
## Context
- **Project**: SatelliteProvider — .NET 8.0 ASP.NET Core service that downloads/persists satellite imagery tiles. Currently single-source (Google Maps); AZ-484 just shipped multi-source storage (`source` + `captured_at` columns, 5-col unique index, `tile-storage` v1.0.0 frozen contract).
- **Cycle**: 2 (started after cycle 1 closed AZ-484 today, 2026-05-11).
- **Task family** (4 tasks under epic AZ-483, accepted by user):
- **AZ-485** — UAV batch upload endpoint + per-tile persistence loop (5 SP)
- **AZ-486** — Quality gate (6 checks + thresholds + reject reasons) (3 SP)
- **AZ-487** — JWT auth middleware (3 SP)
- **AZ-488** — Geofence whitelist (2 SP)
- **Confirmed user requirements** (from new-task Step 1):
1. Batch upload (multipart with metadata + N image files per request)
2. Minimum metadata only per tile: `latitude`, `longitude`, `tile_zoom`, `tile_size_meters`, `captured_at` (UTC), image bytes
3. Quality gate enforces all of: dimensions, byte size, blank/uniform detection, captured_at age, format=JPEG, geofence containment
4. JWT auth (HS256, signing key in env, required claims: `sub` + valid `exp`)
5. Sync responses: 200 + per-tile `{ tileId, status, reason? }` array; 4xx for auth/format-of-batch failures
6. File storage: same `./tiles/{z}/{x}/{y}.jpg` layout as Google Maps tiles
- **Project constraints to respect**:
- .NET 8.0 only (no .NET 9 features)
- Existing libraries: ImageSharp 3.1.11, Npgsql 9.0.2, Dapper 2.1.35, Serilog 8.0.3
- No auth currently exists in the API (per Security Audit Step 14, 2026-05-11)
- The "geofence" type in the codebase (`SatelliteProvider.Common.DTO.GeofencePolygon`) is actually a bounding box (NorthWest + SouthEast corners), not an arbitrary polygon. There is no existing point-in-polygon implementation.
- The frozen `_docs/02_document/contracts/data-access/tile-storage.md` v1.0.0 is the producer-side contract this work must implement (`source='uav'`, `captured_at` per-row, per-source UPSERT)
- Lessons applied: L-001 (Dapper enum bypass — no new persisted enums planned but the rule applies if any sneak in)
## Specific unknowns to investigate
### Q1 — JWT auth middleware in ASP.NET Core 8 for an internal service (AZ-487)
- Recommended pattern for an internal/trusted-network API that needs to validate HS256 tokens issued by an external system?
- `Microsoft.AspNetCore.Authentication.JwtBearer` (built-in, recommended? version-pinned for ASP.NET Core 8)
- vs. third-party (e.g., custom middleware, OpenIddict, etc.)
- Configuration shape — where does the signing key live? appsettings vs env var vs key vault? (Project constraint: this codebase has no secret manager today.)
- Required claims minimum: just `sub` and unexpired `exp` per user choice. Confirm `JwtBearerOptions.TokenValidationParameters` setup that validates *only* signature + `exp` and tolerates missing `aud`/`iss` (or reject — which is the safer ASP.NET Core 8 default?).
- Swagger integration: how to surface "Bearer <token>" in the Swagger UI for the upload endpoint without changing the existing public endpoints?
- 401 response shape — does the global `GlobalExceptionHandler` need to know about it, or does `[Authorize]` short-circuit before the handler?
- Test strategy — how do current `SatelliteProvider.IntegrationTests` (which call the live API via `HttpClient` per `RouteTestHelpers`) cleanly inject a valid JWT? Pattern for a "test issuer" using the same signing key.
### Q2 — Multipart batch upload in ASP.NET Core 8 minimal API (AZ-485)
- Idiomatic shape for `multipart/form-data` with N files + a JSON metadata document in a minimal API endpoint:
- `[FromForm]` model binding
- vs. manual `MultipartReader` for streaming (relevant if batches are large)
- Request size limits in ASP.NET Core 8 (Kestrel `MaxRequestBodySize` and form options) — defaults and how to override for the upload endpoint only.
- Memory / streaming trade-off: how big can the per-batch `IFormFile[]` get before we should switch to streaming via `MultipartReader`? Practical batch-size guidance for satellite tiles (~50500 KB each).
- Response shape for partial-success batches — common patterns (HTTP 200 with mixed array, HTTP 207 Multi-Status, HTTP 422 with per-item errors).
- Backpressure / concurrency limit on the per-tile persistence loop — should this be sequential or fanned out? Trade-off vs. Postgres connection pool size (current Npgsql defaults).
### Q3 — Image quality heuristics for satellite tiles using ImageSharp 3.1.11 (AZ-486)
- API surface in **ImageSharp 3.1.11 specifically** for:
- Reading width/height without full decode (`Image.IdentifyAsync` returns `ImageInfo`)
- Detecting actual format from magic bytes vs. trusting `Content-Type`
- Computing a "uniformity / blankness" metric efficiently — luminance stddev across the image, or histogram-based, or a perceptual hash
- Performance budget: per-tile quality check ideally under ~10ms on a 256×256 JPEG. Confirm which ImageSharp APIs are streaming vs. full-decode.
- Robustness against adversarial inputs — e.g., a 1-pixel image with a JPEG header. What does ImageSharp 3.1.11 throw, and is there a safe-decode pattern?
- Threshold-tuning approach — recommended way to tune the blank-detection threshold against a small fixture set of real satellite tiles vs. uniform tiles, without having that fixture set today.
### Q4 — Geofence whitelist: bbox vs. polygon (AZ-488)
- The codebase's existing geofence type is a **bounding box** (`NorthWest`+`SouthEast`). Two options for the AZ-488 whitelist:
- **Option A**: reuse the existing bbox shape (simple, consistent with current code, but coarser — a UAV tile inside a non-rectangular real-world allowed area can't be expressed)
- **Option B**: introduce a true polygon (lat/lon list) + ray-casting point-in-polygon, store as JSON in config. Requires writing the ray-cast utility (or pulling in `NetTopologySuite`).
- Recommendation: which one is the right fit given that the user only said "geofence" without specifying shape and the existing code is bbox? Compare with NetTopologySuite as a third option.
- Storage: where does the whitelist live? `appsettings.json` array, dedicated config file, dedicated DB table?
- Performance: typical UAV batch is N tiles vs. M whitelist polygons — is M small enough (e.g., < 10 polygons, < 1000 vertices) that O(N×M) ray-cast per batch is fine?
### Q5 — Per-tile persistence loop: file-system + DB consistency (AZ-485)
- Pattern for "write JPEG to disk, then write DB row" with rollback on either failure:
- Two-phase: write file with a `.tmp` suffix, INSERT, then rename. On INSERT failure, delete `.tmp`.
- vs. write DB row first (ON CONFLICT does UPSERT), then write file. On file failure, what happens to the row? (Could leave a row pointing at a missing file path — bad.)
- Idempotency: if the same (lat, lon, zoom, size, source='uav') tile is uploaded twice, the per-source UPSERT already handles the DB side — but does the file get overwritten cleanly? Atomic file replace pattern.
- Concurrency: two simultaneous batch uploads for adjacent tiles — any shared resource that needs locking?
## Out of scope for this research
- Distributed UAV fleet management, queueing, or real-time streaming (these are upstream concerns the user has not raised)
- Async/202 + status polling response model (user explicitly said sync 200)
- Per-tile encryption, compression beyond JPEG, or alternate image formats
- External IdP integration (HS256 with shared secret only)
- UAV mission/operator/sensor metadata (user explicitly said "not necessary for now")
## Acceptance criteria for the research output
The research deliverable (`solution_draft01.md`) must:
1. Recommend a concrete library + config approach for **Q1 (JWT)**. Include: package name + version, code shape for `builder.Services.AddAuthentication().AddJwtBearer(...)`, key storage recommendation, integration-test pattern.
2. Recommend a concrete request shape + size-limit approach for **Q2 (multipart)**. Include: `[FromForm]` vs streaming decision criteria, response shape choice with rationale, concurrency-limit recommendation.
3. Recommend a concrete API call sequence in **ImageSharp 3.1.11** for **Q3 (quality heuristics)**. Include: blank-detection threshold starting point + tuning plan, performance budget validation, error-handling pattern.
4. Recommend bbox-vs-polygon-vs-NTS for **Q4 (geofence)**. Include: exact-fit verdict per the project's actual operating context (M < 10 polygons, simple ray-cast acceptable, or pull in NTS).
5. Recommend a file+DB consistency pattern for **Q5**. Include: failure modes covered, rollback shape, idempotency story.
6. Include a "do not use" / rejected list for each question — alternatives the research considered and rejected with one-line evidence.
## Execution mode
Mode A — Initial Research. Output class: **Technical-component selection** (recommends specific libraries, ASP.NET Core 8 modes, ImageSharp APIs, optional NTS).
Per-mode API capability verification applies to every recommended library. Saved Minimum Viable Examples required for: `Microsoft.AspNetCore.Authentication.JwtBearer` JWT validation, ASP.NET Core 8 minimal-API multipart, ImageSharp 3.1.11 luminance stddev.
+1 -1
View File
@@ -68,7 +68,7 @@ Roadmap: `_docs/04_refactoring/03-code-quality-refactoring/analysis/refactoring_
| Task | Title | Depends On | Points | Status |
|------|-------|-----------|--------|--------|
| AZ-487 | JWT validation baseline (HS256, JWT_SECRET, all endpoints) | — (consumes suite-level contract `suite/_docs/10_auth.md`) | 2 | To Do |
| AZ-487 | JWT validation baseline (HS256, JWT_SECRET, all endpoints) | — (consumes suite-level contract `suite/_docs/10_auth.md`) | 2 | Done (In Testing) |
| AZ-488 | UAV tile upload endpoint with batch + 5-rule quality gate | AZ-487 (hard prereq), AZ-484 contract `tile-storage.md` v1.0.0 | 8 (over-cap, user-accepted) | To Do |
## Execution Order
@@ -0,0 +1,48 @@
# Batch Report — Batch 01 cycle 2
**Batch**: 01 (cycle 2)
**Tasks**: AZ-487 (JWT validation baseline)
**Date**: 2026-05-11
## Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|------|--------|---------------|-------|-------------|--------|
| AZ-487_jwt_validation_baseline | Done | 12 modified + 6 added (`.env.example`, `SatelliteProvider.Api/Authentication/AuthenticationServiceCollectionExtensions.cs`, `SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs`, `JwtTestHelpers.cs`, `SatelliteProvider.Tests/Authentication/AuthenticationServiceCollectionExtensionsTests.cs`, `JwtTokenFactoryTests.cs`, `SatelliteProvider.Tests/TestUtilities/JwtTokenFactory.cs`) | To run (Step 11) | 8/8 ACs covered | 0 blockers; 2 Low findings (see review) |
## AC Test Coverage: All covered (8 of 8)
## Code Review Verdict: PASS_WITH_WARNINGS
## Auto-Fix Attempts: 0
## Stuck Agents: None
## What was implemented
- New `AuthenticationServiceCollectionExtensions.AddSatelliteJwt(IConfiguration)` — registers `Microsoft.AspNetCore.Authentication.JwtBearer 8.0.21` with `TokenValidationParameters` matching the suite-level auth contract (HS256, `ValidateLifetime`, `RequireSignedTokens`, `RequireExpirationTime`, no issuer/audience validation, 30 s clock skew). Secret resolution prefers `JWT_SECRET` env var, falls back to `Jwt:Secret` configuration, fails fast on missing/short (<32-byte) secret.
- `Program.cs` wires `AddSatelliteJwt` + `AddAuthorization` into DI, adds `UseAuthentication` + `UseAuthorization` middleware after `UseCors`, and decorates every minimal-API endpoint with `.RequireAuthorization()`. Swagger UI gets a Bearer security definition + global security requirement so the "Authorize" button renders.
- Configuration: `appsettings.json` adds an empty `Jwt:Secret` placeholder; `appsettings.Development.json` ships a clearly-tagged dev placeholder (≥32 bytes). The `JWT_SECRET` env var overrides both when set.
- Test infrastructure:
- `SatelliteProvider.Tests.TestUtilities.JwtTokenFactory` — mints, expires, and tampers tokens using the same secret as the API.
- `SatelliteProvider.Tests.Authentication.AuthenticationServiceCollectionExtensionsTests` — 6 tests covering registration, parameter shape, missing/empty/short secret handling, and env-var-precedence semantics.
- `SatelliteProvider.Tests.Authentication.JwtTokenFactoryTests` — 4 tests covering token mint + claims propagation + expired + tampered.
- `SatelliteProvider.IntegrationTests.JwtTestHelpers` — runner-side helpers for token mint + bearer attachment.
- `SatelliteProvider.IntegrationTests.JwtIntegrationTests` — 5 end-to-end checks (anon → 401, expired → 401, tampered → 401, valid → handler, Swagger advertises Bearer).
- `SatelliteProvider.IntegrationTests.Program` — resolves `JWT_SECRET` at startup, mints a default token, attaches it to the shared `HttpClient` so legacy non-auth tests continue to pass against protected endpoints.
- Docker / scripts:
- `docker-compose.yml`: api gets `JWT_SECRET=${JWT_SECRET}`.
- `docker-compose.tests.yml`: integration-tests gets the same env var (api inherits via `extends`).
- `scripts/run-tests.sh`: loads `JWT_SECRET` from `.env` (alongside `GOOGLE_MAPS_API_KEY`), rejects missing/short values, exports for compose runs.
- `.env.example` (new): documents `GOOGLE_MAPS_API_KEY` and `JWT_SECRET` with generation guidance.
- `.env` (gitignored): dev `JWT_SECRET` added locally.
- Docs:
- `architecture.md` § Architecture Vision: added Authentication & Authorization sub-section.
- `architecture.md` § Security Architecture: replaced "no auth" prose with JWT description; documented placeholder semantics and operator obligations.
- `modules/api_program.md`: updated DI Registration (#9), Startup (middleware chain), Dependencies (JwtBearer 8.0.21), Configuration (`Jwt:Secret` + env var precedence), Security (per-endpoint `.RequireAuthorization()`).
## Open follow-ups (non-blocking)
- **Doc-folder choice** (F1 in review): the task spec referenced `_docs/02_document/components/01_web_api/description.md`, which does not exist. Updated `modules/api_program.md` + `architecture.md` instead. User should decide whether to add a stub `01_web_api` component folder for symmetry, or to formalize the "WebApi has no `components/*` folder" pattern.
- **Cross-component coordination**: per the AZ-487 spec, `gps-denied-onboard` and the mission planner UI must attach Bearer tokens to their outbound calls before this change ships to `dev`. Flagged for the operator to coordinate before deploy (Step 16).
## Next Batch: AZ-488 (UAV tile upload endpoint with batch + 5-rule quality gate)
AZ-488 is an 8 SP task (user-accepted over-cap). It introduces a new endpoint, DTOs, quality gate, configuration class, and ~15 unit + integration tests. Recommend a fresh conversation for context freshness before starting (per `protocols.md` Context Budget Heuristic).
@@ -0,0 +1,81 @@
# Code Review Report — Batch 01 cycle 2
**Batch**: AZ-487 (JWT validation baseline)
**Date**: 2026-05-11
**Verdict**: PASS_WITH_WARNINGS
## Findings
| # | Severity | Category | File:Line | Title |
|---|----------|----------|-----------|-------|
| 1 | Low | Style | _docs/02_document/components/01_web_api/description.md | Task spec referenced a doc path that does not exist in the codebase |
| 2 | Low | Security | SatelliteProvider.Api/appsettings.Development.json | Dev-only JWT secret placeholder is committed (intentional per spec) |
### Finding Details
**F1: Task spec referenced a doc path that does not exist in the codebase** (Low / Style)
- Location: `_docs/02_document/components/01_web_api/description.md` (referenced; does not exist)
- Description: The AZ-487 task spec lists `_docs/02_document/components/01_web_api/description.md` as a doc to update. The codebase's component-doc folders are `01_common`, `02_data_access`, `03_tile_downloader`, `04_region_processing`, `05_route_management` — there is no `01_web_api` folder. The WebApi component's documentation lives in `_docs/02_document/modules/api_program.md`.
- Suggestion: Either (a) create the missing folder with a brief stub that defers to `api_program.md`, or (b) update the task spec for AZ-488 to point at `modules/api_program.md` and acknowledge that WebApi has no `components/*` folder. This batch chose (b) — updated `architecture.md` § Architecture Vision + § Security Architecture and `modules/api_program.md`. Surface to user for a doc-organization decision.
- Task: AZ-487
**F2: Dev-only JWT secret placeholder is committed** (Low / Security)
- Location: `SatelliteProvider.Api/appsettings.Development.json`
- Description: The dev placeholder `DEV-ONLY-DO-NOT-USE-IN-PROD-replace-with-real-secret-via-JWT_SECRET-env-var` (78 bytes) is committed to the repo. Anyone with read access could mint a token signed with that secret. The mitigations (per task spec) are: (i) only loaded when `ASPNETCORE_ENVIRONMENT=Development`, (ii) the `JWT_SECRET` env var overrides it whenever set, (iii) the placeholder text itself signals it must be replaced.
- Suggestion: Accept as-is for dev ergonomics; production environments must set `JWT_SECRET` (validated in `scripts/run-tests.sh` and at startup). Document on the README that this dev placeholder is intentional.
- Task: AZ-487
## Phase Notes
### Phase 1 — Context Loading
- Task spec read: `_docs/02_tasks/todo/AZ-487_jwt_validation_baseline.md`
- Suite contract referenced: `suite/_docs/10_auth.md` (consumed via implementation, not modified)
- Module layout, architecture doc, existing test patterns reviewed.
### Phase 2 — Spec Compliance
All 8 acceptance criteria have at least one automated test:
| AC | Description | Test |
|----|-------------|------|
| AC-1 | Anonymous → 401 | `JwtIntegrationTests.AnonymousRequest_To_AnyEndpoint_Returns401` |
| AC-2 | Expired → 401 | `JwtIntegrationTests.ExpiredToken_Returns401` |
| AC-3 | Invalid signature → 401 | `JwtIntegrationTests.InvalidSignature_Returns401` |
| AC-4 | Valid token reaches handler | `JwtIntegrationTests.ValidToken_Returns200_OnHealthyEndpoint` |
| AC-5 | Missing/short secret → fail fast | `AuthenticationServiceCollectionExtensionsTests.AddSatelliteJwt_ThrowsOnMissingSecret/Empty/Short` |
| AC-6 | `HttpContext.User` exposes claims | `JwtTokenFactoryTests.Create_WithExtraClaims_PropagatesClaimsThroughValidation` (exercises same `JwtSecurityTokenHandler.ValidateToken` path JwtBearer uses to populate `HttpContext.User`) |
| AC-7 | Swagger Authorize button | `JwtIntegrationTests.SwaggerDocument_AdvertisesBearerSecurityScheme` (OpenAPI document declares `Bearer` security scheme) |
| AC-8 | All existing tests pass | Verified at Step 11 (test-run gate); shared `HttpClient` attaches default Bearer token in `IntegrationTests/Program.cs` |
Contract verification: AZ-487 is a consumer of `suite/_docs/10_auth.md`. The implementation matches the consumed contract (HS256, ≥32-byte HMAC key, no issuer/audience validation, expiration required).
### Phase 3 — Code Quality
- New code follows SRP (`AuthenticationServiceCollectionExtensions` does one thing).
- Tests follow AAA pattern with explicit comments (matches project style).
- No bare catches introduced. The pre-existing empty catch in `IntegrationTests/Program.cs::WaitForApiReady` is out of scope (pre-existing, not modified).
- No comments narrating obvious code.
### Phase 4 — Security Quick-Scan
- No SQL/command-injection vectors introduced.
- No sensitive data in logs (error messages mention secret length, not value).
- Dev placeholder secret: see F2.
### Phase 5 — Performance Scan
- JWT validation per request is microsecond-scale HMAC + claims parsing; no I/O, no caching needed (per NFR).
- No N+1, no unbounded fetches, no new blocking calls.
### Phase 6 — Cross-Task Consistency
Single-task batch; not applicable.
### Phase 7 — Architecture Compliance
- All new code in `SatelliteProvider.Api/Authentication/` — owned by the WebApi component per `module-layout.md`.
- Imports stay within the allowed dependency table (`Microsoft.AspNetCore.Authentication.JwtBearer`, `Microsoft.IdentityModel.Tokens` are external NuGet packages, not other components).
- Test code lives under `SatelliteProvider.Tests/` and `SatelliteProvider.IntegrationTests/` (separate test projects per layout rule 5).
- No new cross-sibling ProjectReferences. No new cyclic dependencies. No duplicate cross-component symbols.
## Baseline Delta
`_docs/02_document/architecture_compliance_baseline.md` is not present in the repo; baseline delta section omitted.
## Verdict Rationale
Two Low findings, both already accepted in the task spec or surfacable as doc-organization decisions. No Critical, High, or Medium findings. Verdict: **PASS_WITH_WARNINGS** — proceed to commit per implement skill Step 10.
+4 -4
View File
@@ -4,11 +4,11 @@
flow: existing-code
step: 10
name: Implement
status: not_started
status: in_progress
sub_step:
phase: 0
name: awaiting-invocation
detail: ""
phase: 7
name: batch-loop
detail: "batch 1 of 2 done (AZ-487); batch 2 (AZ-488) pending"
retry_count: 0
cycle: 2
tracker: jira
+1
View File
@@ -18,6 +18,7 @@ services:
- API_URL=http://api:8080
- INTEGRATION_TESTS_MODE=${INTEGRATION_TESTS_MODE:-full}
- DB_CONNECTION_STRING=Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres
- JWT_SECRET=${JWT_SECRET}
volumes:
- ./ready:/app/ready
- ./tiles:/app/tiles
+1
View File
@@ -29,6 +29,7 @@ services:
- ASPNETCORE_URLS=http://+:8080
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres
- MapConfig__ApiKey=${GOOGLE_MAPS_API_KEY}
- JWT_SECRET=${JWT_SECRET}
volumes:
- ./tiles:/app/tiles
- ./ready:/app/ready
+16 -1
View File
@@ -23,6 +23,9 @@ Flags:
Environment:
GOOGLE_MAPS_API_KEY Required for any integration test mode (loaded from .env or shell env).
JWT_SECRET Required for any integration test mode. Shared HMAC secret used by the
API and the integration test runner; must be at least 32 bytes (UTF-8).
Loaded from .env or shell env.
EOF
}
@@ -66,7 +69,7 @@ if [[ "$mode" == "unit" ]]; then
exit 0
fi
if [[ -z "${GOOGLE_MAPS_API_KEY:-}" ]] && [[ -f "$PROJECT_ROOT/.env" ]]; then
if { [[ -z "${GOOGLE_MAPS_API_KEY:-}" ]] || [[ -z "${JWT_SECRET:-}" ]]; } && [[ -f "$PROJECT_ROOT/.env" ]]; then
set -o allexport
# shellcheck disable=SC1091
source "$PROJECT_ROOT/.env"
@@ -78,6 +81,18 @@ if [[ -z "${GOOGLE_MAPS_API_KEY:-}" ]]; then
exit 3
fi
if [[ -z "${JWT_SECRET:-}" ]]; then
echo "ERROR: JWT_SECRET is not set (export it or add to .env). API will fail to start without it."
exit 3
fi
jwt_secret_bytes=${#JWT_SECRET}
if (( jwt_secret_bytes < 32 )); then
echo "ERROR: JWT_SECRET is ${jwt_secret_bytes} bytes; HMAC-SHA256 requires at least 32 bytes."
exit 3
fi
export JWT_SECRET
echo "Step 1: Unit tests"
docker run --rm -v "$PROJECT_ROOT:/src" -w /src mcr.microsoft.com/dotnet/sdk:8.0 \
sh -c "dotnet restore SatelliteProvider.sln && dotnet test SatelliteProvider.Tests/SatelliteProvider.Tests.csproj --no-restore --configuration Release --collect:'XPlat Code Coverage' --results-directory /src/TestResults --logger 'console;verbosity=normal'"