diff --git a/SVSim.EmulatedEntrypoint/Configuration/AdminOptions.cs b/SVSim.EmulatedEntrypoint/Configuration/AdminOptions.cs new file mode 100644 index 00000000..289e5b15 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Configuration/AdminOptions.cs @@ -0,0 +1,17 @@ +namespace SVSim.EmulatedEntrypoint.Configuration; + +/// +/// Config for the /admin/* util endpoints. Bound from the "Admin" section of appsettings. +/// +public class AdminOptions +{ + public const string SectionName = "Admin"; + + /// + /// Shared secret required in the X-Admin-Secret header on protected admin endpoints + /// (see ). Empty / whitespace means + /// the endpoint is disabled — the filter fails closed so an unconfigured deployment never + /// exposes the admin surface. + /// + public string ImportSecret { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index dddb7413..09e7713f 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -16,8 +16,10 @@ using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; /// -/// Util endpoints for bootstrapping the dev environment. Anonymous-allowed today — security -/// audit pending (don't expose these to the public internet). +/// Util endpoints for bootstrapping the dev environment. Actions are gated by +/// : callers must send the shared secret from +/// Admin:ImportSecret in the X-Admin-Secret header. Missing/blank config +/// disables the endpoint (fail-closed). /// public class AdminController : SVSimController { @@ -41,6 +43,7 @@ public class AdminController : SVSimController /// Only essential fields are imported today — extend as needed. /// [AllowAnonymous] + [RequireAdminSecret] [HttpPost("import_viewer")] public async Task> ImportViewer(ImportViewerRequest request) { diff --git a/SVSim.EmulatedEntrypoint/Infrastructure/AdminSecretOperationFilter.cs b/SVSim.EmulatedEntrypoint/Infrastructure/AdminSecretOperationFilter.cs new file mode 100644 index 00000000..91707ad6 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Infrastructure/AdminSecretOperationFilter.cs @@ -0,0 +1,39 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace SVSim.EmulatedEntrypoint.Infrastructure; + +/// +/// Attaches the security requirement to +/// Swagger operations whose action carries . The +/// matching is registered by Program.cs under the +/// same scheme id () so the Swagger UI shows an Authorize dialog and, +/// once populated, sends the header on gated endpoints only. +/// +public sealed class AdminSecretOperationFilter : IOperationFilter +{ + public const string SchemeId = "AdminSecret"; + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var hasAttribute = context.MethodInfo.GetCustomAttributes(true) + .OfType().Any() + || (context.MethodInfo.DeclaringType?.GetCustomAttributes(true) + .OfType().Any() ?? false); + + if (!hasAttribute) return; + + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = SchemeId, + } + }] = Array.Empty() + }); + } +} diff --git a/SVSim.EmulatedEntrypoint/Infrastructure/RequireAdminSecretAttribute.cs b/SVSim.EmulatedEntrypoint/Infrastructure/RequireAdminSecretAttribute.cs new file mode 100644 index 00000000..184d29ca --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Infrastructure/RequireAdminSecretAttribute.cs @@ -0,0 +1,57 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SVSim.EmulatedEntrypoint.Configuration; + +namespace SVSim.EmulatedEntrypoint.Infrastructure; + +/// +/// Gates a controller or action on a shared secret carried in the X-Admin-Secret header, +/// compared against . Runs as an authorization filter so +/// unauthorized requests short-circuit before model binding. +/// +/// Fail-closed: if the configured secret is null/empty the endpoint is treated as disabled and +/// every request gets a 401 (with a warning logged once per request). This means a deployment +/// that forgets to set the secret leaves the endpoint locked, not open. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)] +public sealed class RequireAdminSecretAttribute : Attribute, IAuthorizationFilter +{ + public const string HeaderName = "X-Admin-Secret"; + + public void OnAuthorization(AuthorizationFilterContext context) + { + var services = context.HttpContext.RequestServices; + var options = services.GetRequiredService>().Value; + var logger = services.GetRequiredService>(); + + if (string.IsNullOrWhiteSpace(options.ImportSecret)) + { + logger.LogWarning( + "Rejecting request to {Path}: Admin:ImportSecret is not configured. " + + "Set it in appsettings (or the NPGSQL_ADMIN__IMPORTSECRET env var) to enable the endpoint.", + context.HttpContext.Request.Path); + context.Result = new UnauthorizedResult(); + return; + } + + if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var provided) + || provided.Count == 0 + || string.IsNullOrEmpty(provided[0])) + { + context.Result = new UnauthorizedResult(); + return; + } + + var providedBytes = Encoding.UTF8.GetBytes(provided[0]!); + var expectedBytes = Encoding.UTF8.GetBytes(options.ImportSecret); + if (!CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes)) + { + context.Result = new UnauthorizedResult(); + } + } +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 6c5c9164..bc51293f 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -15,6 +15,7 @@ using SVSim.Database.Services.Friend; using SVSim.Database.Services.Replay; using SVSim.EmulatedEntrypoint.Configuration; using SVSim.EmulatedEntrypoint.Extensions; +using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Matching; using SVSim.EmulatedEntrypoint.Middlewares; using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; @@ -64,6 +65,19 @@ public class Program // Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs // BasicPuzzle.StartRequest) by qualifying schema ids with the full type name. c.CustomSchemaIds(t => t.FullName?.Replace("+", ".")); + + // Register the X-Admin-Secret shared-secret header so the Swagger UI shows an + // Authorize dialog for it. AdminSecretOperationFilter then attaches the requirement + // only to endpoints that carry [RequireAdminSecret], keeping the padlock off + // ordinary game endpoints. + c.AddSecurityDefinition(AdminSecretOperationFilter.SchemeId, new Microsoft.OpenApi.Models.OpenApiSecurityScheme + { + Name = RequireAdminSecretAttribute.HeaderName, + In = Microsoft.OpenApi.Models.ParameterLocation.Header, + Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey, + Description = "Shared secret for /admin/* endpoints (matches Admin:ImportSecret in appsettings).", + }); + c.OperationFilter(); }); builder.Services.AddHttpLogging(opt => { @@ -71,6 +85,7 @@ public class Program }); builder.Services.Configure(builder.Configuration.GetSection(DeckOptions.SectionName)); + builder.Services.Configure(builder.Configuration.GetSection(AdminOptions.SectionName)); #region Database Services diff --git a/SVSim.EmulatedEntrypoint/appsettings.Testing.json b/SVSim.EmulatedEntrypoint/appsettings.Testing.json index 0a42f07d..1c1480dd 100644 --- a/SVSim.EmulatedEntrypoint/appsettings.Testing.json +++ b/SVSim.EmulatedEntrypoint/appsettings.Testing.json @@ -6,5 +6,10 @@ "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Warning", "Microsoft.EntityFrameworkCore": "Warning" } + }, + "Admin": { + // Fixed secret so AdminControllerTests can send X-Admin-Secret without going through + // a per-test config knob. Tests read it back via AdminControllerTests.TestSecret. + "ImportSecret": "test-admin-secret" } } diff --git a/SVSim.EmulatedEntrypoint/appsettings.json b/SVSim.EmulatedEntrypoint/appsettings.json index d0efef73..bb983d85 100644 --- a/SVSim.EmulatedEntrypoint/appsettings.json +++ b/SVSim.EmulatedEntrypoint/appsettings.json @@ -27,6 +27,13 @@ "Deck": { "MaxDeckSlots": 36 }, + "Admin": { + // Shared secret required in the X-Admin-Secret header on /admin/* util endpoints + // (see RequireAdminSecretAttribute). Leaving this blank disables the endpoint entirely + // — fail-closed so an unconfigured deployment never exposes admin surface. Override with + // the ADMIN__IMPORTSECRET env var. + "ImportSecret": "replaceme" + }, "BattleNode": { // Socket.IO v2 endpoint URL echoed to the client on /*/do_matching success. // Wire format: "host[:port]/socket.io/" — no scheme prefix, trailing slash REQUIRED. diff --git a/SVSim.UnitTests/Controllers/AdminControllerTests.cs b/SVSim.UnitTests/Controllers/AdminControllerTests.cs index bc2bbe2c..1af38478 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -14,10 +14,12 @@ using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; /// -/// End-to-end coverage for /admin/import_viewer. The endpoint is [AllowAnonymous] so -/// these tests don't need to seed a viewer first; the fresh-user path exercises the just-fixed -/// nav-graph NRE inside ViewerRepository.RegisterViewer, and the existing-user path -/// exercises the owned-type lookup used to dedupe by Steam id. +/// End-to-end coverage for /admin/import_viewer. The endpoint is [AllowAnonymous] + +/// [RequireAdminSecret], so tests reach it through +/// which bakes in the X-Admin-Secret header from appsettings.Testing.json. The +/// fresh-user path exercises the nav-graph NRE fix inside ViewerRepository.RegisterViewer; +/// the existing-user path exercises the owned-type Steam-id lookup. Negative-path tests at the +/// bottom of the file cover the header gate itself. /// public class AdminControllerTests { @@ -27,7 +29,7 @@ public class AdminControllerTests public async Task ImportViewer_fresh_user_creates_viewer_and_returns_ids() { using var factory = new SVSimTestFactory(); - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { @@ -71,7 +73,7 @@ public class AdminControllerTests const ulong steamId = 76_561_198_555_666_777UL; long seededId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Original Name"); - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, @@ -107,7 +109,7 @@ public class AdminControllerTests public async Task ImportViewer_missing_steam_id_returns_400() { using var factory = new SVSimTestFactory(); - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { @@ -122,7 +124,7 @@ public class AdminControllerTests public async Task ImportViewer_imports_owned_cards_and_skips_unknown() { using var factory = new SVSimTestFactory(); - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); // 10001001 is in the minimal test card set; 99999999 is not. var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -156,7 +158,7 @@ public class AdminControllerTests public async Task ImportViewer_clamps_card_count_to_max_copies() { using var factory = new SVSimTestFactory(); - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { @@ -181,7 +183,7 @@ public class AdminControllerTests long viewerId = await factory.SeedViewerAsync(steamId: steamId); await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3); - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, @@ -207,7 +209,7 @@ public class AdminControllerTests // Registers the ItemEntry master row (70001) and gives an initial owned count to be replaced. await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 1); - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, @@ -242,7 +244,7 @@ public class AdminControllerTests leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id; } - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, @@ -294,7 +296,7 @@ public class AdminControllerTests leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id; } - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, @@ -329,7 +331,7 @@ public class AdminControllerTests public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported() { using var factory = new SVSimTestFactory(); - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { @@ -380,7 +382,7 @@ public class AdminControllerTests } """; - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var response = await client.PostAsync("/admin/import_viewer", content); @@ -438,7 +440,7 @@ public class AdminControllerTests } """; - using var client = factory.CreateClient(); + using var client = factory.CreateAdminClient(); using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var response = await client.PostAsync("/admin/import_viewer", content); @@ -457,7 +459,7 @@ public class AdminControllerTests public async Task ImportViewer_MissionMeta_RoundTrips() { using var factory = new SVSimTestFactory(); - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000001UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -501,7 +503,7 @@ public class AdminControllerTests }); await db.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000002UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -532,7 +534,7 @@ public class AdminControllerTests public async Task ImportViewer_Missions_UnknownMissionIdSkipped() { using var factory = new SVSimTestFactory(); - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000003UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -566,7 +568,7 @@ public class AdminControllerTests }); await seedDb.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000004UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -604,7 +606,7 @@ public class AdminControllerTests new MissionCatalogEntry { Id = 9004, LotType = 2, EventType = "battle_win_total", EventArg = null }); await seedDb.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000005UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -651,7 +653,7 @@ public class AdminControllerTests }); await db.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000007UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -686,7 +688,7 @@ public class AdminControllerTests public async Task ImportViewer_Achievements_UnknownTypeSkipped() { using var factory = new SVSimTestFactory(); - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000008UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -723,7 +725,7 @@ public class AdminControllerTests new AchievementCatalogEntry { AchievementType = 602, Level = 1, EventType = "battle_win_total", EventArg = null }); await seedDb.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000009UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -773,7 +775,7 @@ public class AdminControllerTests }); await seedDb.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000006UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -810,7 +812,7 @@ public class AdminControllerTests db.StoryWorlds.Add(new StoryWorld { Id = 20_000_005 }); // Event await db.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000007UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -849,7 +851,7 @@ public class AdminControllerTests db.StoryWorlds.Add(new StoryWorld { Id = 11 }); // sub_chapter (own row) await db.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000008UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -877,7 +879,7 @@ public class AdminControllerTests public async Task ImportViewer_StoryProgress_UnknownStoryIdSkipped() { using var factory = new SVSimTestFactory(); - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000009UL; var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest @@ -913,7 +915,7 @@ public class AdminControllerTests await db.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); const ulong steamId = 70000000000000020UL; // First POST: both missions present (explicit slots to avoid the (ViewerId,Slot) unique index). @@ -980,7 +982,7 @@ public class AdminControllerTests await db.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); const ulong steamId = 70000000000000021UL; // First POST: both achievements present. @@ -1043,7 +1045,7 @@ public class AdminControllerTests db.StoryWorlds.Add(new StoryWorld { Id = 50 }); await db.SaveChangesAsync(); } - var client = factory.CreateClient(); + var client = factory.CreateAdminClient(); ulong steamId = 70000000000000010UL; var req = new ImportViewerRequest @@ -1069,4 +1071,35 @@ public class AdminControllerTests Assert.That(verifyDb.ViewerStoryProgress.Count(p => p.ViewerId == viewerId), Is.EqualTo(1)); Assert.That(verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId), Is.EqualTo(2)); } + + // --- X-Admin-Secret gate --- + + [Test] + public async Task ImportViewer_without_secret_header_returns_401() + { + using var factory = new SVSimTestFactory(); + using var client = factory.CreateClient(); // no X-Admin-Secret header + + var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = 76_561_198_999_999_001UL + }); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task ImportViewer_with_wrong_secret_returns_401() + { + using var factory = new SVSimTestFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Admin-Secret", "not-the-real-secret"); + + var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = 76_561_198_999_999_002UL + }); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } } diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index 6d17883a..0471e806 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -397,6 +397,20 @@ internal class SVSimTestFactory : WebApplicationFactory return client; } + /// + /// Shared secret baked into appsettings.Testing.json for /admin/* gating. Kept + /// here so tests can construct requests with or without a valid header via the same source. + /// + public const string AdminSecret = "test-admin-secret"; + + /// Convenience: bake the X-Admin-Secret header into a fresh client. + public HttpClient CreateAdminClient() + { + var client = CreateClient(); + client.DefaultRequestHeaders.Add("X-Admin-Secret", AdminSecret); + return client; + } + /// /// Inserts a deck for the viewer via the real /// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests