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