diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json
index 3d13e1fe8..3b7cfb38b 100644
--- a/devops/compose/router-gateway-local.json
+++ b/devops/compose/router-gateway-local.json
@@ -5,6 +5,15 @@
"AllowAnonymous": true,
"EnableLegacyHeaders": true,
"AllowScopeHeader": false,
+ "ApprovedAuthPassthroughPrefixes": [
+ "/connect",
+ "/console",
+ "/authority",
+ "/doctor",
+ "/api",
+ "/policy/shadow",
+ "/policy/simulations"
+ ],
"Authority": {
"Issuer": "https://authority.stella-ops.local/",
"RequireHttpsMetadata": false,
@@ -225,6 +234,12 @@
"TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow",
"PreserveAuthHeaders": true
},
+ {
+ "Type": "ReverseProxy",
+ "Path": "/policy/simulations",
+ "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations",
+ "PreserveAuthHeaders": true
+ },
{
"Type": "ReverseProxy",
"Path": "/api/v1/advisory-ai/adapters",
diff --git a/docs/implplan/SPRINT_20260309_018_Router_policy_simulation_frontdoor_translation.md b/docs/implplan/SPRINT_20260309_018_Router_policy_simulation_frontdoor_translation.md
new file mode 100644
index 000000000..a23cee6f2
--- /dev/null
+++ b/docs/implplan/SPRINT_20260309_018_Router_policy_simulation_frontdoor_translation.md
@@ -0,0 +1,50 @@
+# Sprint 20260309-018 - Router Policy Simulation Frontdoor Translation
+
+## Topic & Scope
+- Restore frontdoor reachability for the live Policy Simulation history tools after the backend compatibility handlers were repaired.
+- Fix the actual frontdoor root cause: router auth passthrough approval drift for `/policy/shadow*` and `/policy/simulations*`, not just raw path translation.
+- Verify the repaired paths with direct frontdoor probes and authenticated Playwright navigation against `https://stella-ops.local`.
+- Working directory: `src/Router/StellaOps.Gateway.WebService`.
+- Allowed coordination edits: `devops/compose/router-gateway-local.json`, `devops/compose/router-gateway-local.reverseproxy.json`, `docs/modules/router/architecture.md`, `src/Router/__Tests/StellaOps.Gateway.WebService.Tests/**`.
+- Expected evidence: gateway auth policy diff, router config diff, focused direct HTTP probes, authenticated Playwright route/action artifacts.
+
+## Dependencies & Concurrency
+- Depends on `SPRINT_20260309_011_Platform_live_remaining_route_contract_repair.md` for the backend `/policy/simulations*` handlers and focused gateway tests.
+- Safe parallelism: do not touch unrelated route rewrites already in progress in the router JSON files; stage only the policy simulation auth/passthrough hunks for this commit.
+
+## Documentation Prerequisites
+- `AGENTS.md`
+- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
+- `docs/qa/feature-checks/FLOW.md`
+- `docs/modules/policy/architecture.md`
+
+## Delivery Tracker
+
+### ROUTER-POLICY-SIM-018-001 - Align policy simulation frontdoor auth passthrough
+Status: DOING
+Dependency: none
+Owners: Developer, QA
+Task description:
+- Extend the canonical local router config, reverse-proxy fallback config, and source gateway defaults so authenticated frontdoor requests for Policy simulation history, compare, verify, and pin actions reach `policy-gateway.stella-ops.local` with DPoP/JWT passthrough preserved.
+- Keep the gateway's approved passthrough allow-list explicit and auditable instead of silently depending on a stale hardcoded prefix set.
+- Preserve auth headers and avoid disturbing unrelated dirty route edits from other agents.
+
+Completion criteria:
+- [ ] `https://stella-ops.local/policy/shadow/results`, `.../simulations/history`, `.../compare`, and `.../{id}/verify` no longer fail because gateway auth passthrough was stripped.
+- [ ] Only the policy simulation passthrough hunks are staged for the commit.
+- [ ] Authenticated Playwright can load the live history page and exercise its key actions through the frontdoor.
+
+## Execution Log
+| Date (UTC) | Update | Owner |
+| --- | --- | --- |
+| 2026-03-09 | Sprint created after live retesting proved the Policy gateway served the simulation history contract while the frontdoor still returned `404` because the canonical router config translated `/policy/shadow*` but not `/policy/simulations*`. | Developer |
+| 2026-03-09 | Live router logs showed the browser was already sending auth for `/policy/shadow/results` and `/policy/simulations/history`, but the gateway stripped Authorization/DPoP because the prefixes were missing from the approved passthrough allow-list. This sprint now fixes the gateway/config drift directly. | Developer |
+
+## Decisions & Risks
+- Decision: keep auth passthrough fail-closed, but move the approved prefix set into explicit gateway/config data so live route additions do not silently drift away from the code path that strips auth headers.
+- Decision: keep the policy fix scoped to `/policy/shadow` and `/policy/simulations` rather than broadening every `/policy/*` route.
+- Risk: the router JSON files are already dirty from unrelated route work; stage only the specific policy passthrough additions and leave the rest untouched.
+
+## Next Checkpoints
+- 2026-03-09: land the gateway/config passthrough fix and redeploy the frontdoor.
+- 2026-03-09: rerun authenticated Policy Simulation history navigation with Playwright.
diff --git a/docs/modules/router/architecture.md b/docs/modules/router/architecture.md
index f973c5813..b1ae80d40 100644
--- a/docs/modules/router/architecture.md
+++ b/docs/modules/router/architecture.md
@@ -301,7 +301,8 @@ Request ─►│ ForwardedHeaders │
- Per-request tenant override is disabled by default and only works when explicitly enabled with `Gateway:Auth:EnableTenantOverride=true` and the requested tenant exists in `stellaops:allowed_tenants`.
- Authorization/DPoP passthrough is fail-closed:
- route must be configured with `PreserveAuthHeaders=true`, and
-- route prefix must also be in the approved passthrough allow-list (`/connect`, `/console`, `/authority`, `/doctor`, `/api`).
+- route prefix must also be in the approved passthrough allow-list configured under `Gateway:Auth:ApprovedAuthPassthroughPrefixes`.
+- local frontdoor configs approve `/connect`, `/console`, `/authority`, `/doctor`, `/api`, `/policy/shadow`, and `/policy/simulations` so live policy compatibility endpoints can preserve DPoP/JWT passthrough without broadening unrelated routes.
- Tenant override attempts are logged with deterministic fields including route, actor, requested tenant, and resolved tenant.
### Connection State
diff --git a/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptions.cs b/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptions.cs
index af1a0c6c6..19e2d1735 100644
--- a/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptions.cs
+++ b/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptions.cs
@@ -190,6 +190,19 @@ public sealed class GatewayAuthOptions
///
public bool EnableTenantOverride { get; set; } = false;
+ ///
+ /// Approved route prefixes where Authorization/DPoP passthrough may be enabled.
+ /// Routes still need PreserveAuthHeaders=true to preserve auth headers.
+ ///
+ public List ApprovedAuthPassthroughPrefixes { get; set; } =
+ [
+ "/connect",
+ "/console",
+ "/authority",
+ "/doctor",
+ "/api"
+ ];
+
///
/// Emit signed identity envelope headers for router-dispatched requests.
///
diff --git a/src/Router/StellaOps.Gateway.WebService/Program.cs b/src/Router/StellaOps.Gateway.WebService/Program.cs
index 14de2dc25..d3e510890 100644
--- a/src/Router/StellaOps.Gateway.WebService/Program.cs
+++ b/src/Router/StellaOps.Gateway.WebService/Program.cs
@@ -131,6 +131,7 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
.Where(r => r.PreserveAuthHeaders)
.Select(r => r.Path)
.ToList(),
+ ApprovedAuthPassthroughPrefixes = [.. bootstrapOptions.Auth.ApprovedAuthPassthroughPrefixes],
EnableTenantOverride = bootstrapOptions.Auth.EnableTenantOverride
});
diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json
index 57df8946b..a9905ea7b 100644
--- a/src/Router/StellaOps.Gateway.WebService/appsettings.json
+++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json
@@ -42,6 +42,15 @@
"DpopEnabled": true,
"MtlsEnabled": false,
"AllowAnonymous": true,
+ "ApprovedAuthPassthroughPrefixes": [
+ "/connect",
+ "/console",
+ "/authority",
+ "/doctor",
+ "/api",
+ "/policy/shadow",
+ "/policy/simulations"
+ ],
"Authority": {
"Issuer": "https://authority.stella-ops.local",
"RequireHttpsMetadata": false,
@@ -76,6 +85,8 @@
{ "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs" },
{ "Type": "ReverseProxy", "Path": "/v1/runs", "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs" },
{ "Type": "ReverseProxy", "Path": "/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
+ { "Type": "ReverseProxy", "Path": "/policy/simulations", "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations", "PreserveAuthHeaders": true },
+ { "Type": "ReverseProxy", "Path": "/policy/shadow", "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true },
diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/IdentityHeaderPolicyMiddlewareTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/IdentityHeaderPolicyMiddlewareTests.cs
index 390935ac3..f4a90d9b6 100644
--- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/IdentityHeaderPolicyMiddlewareTests.cs
+++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/IdentityHeaderPolicyMiddlewareTests.cs
@@ -415,6 +415,23 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
Assert.Equal("proof-value", context.Request.Headers["DPoP"].ToString());
}
+ [Fact]
+ public async Task InvokeAsync_PreservesAuthorizationHeadersForConfiguredPolicyPrefix()
+ {
+ _options.JwtPassthroughPrefixes = ["/policy/shadow", "/policy/simulations"];
+ _options.ApprovedAuthPassthroughPrefixes = ["/connect", "/policy/shadow", "/policy/simulations"];
+ var middleware = CreateMiddleware();
+ var context = CreateHttpContext("/policy/shadow/results");
+ context.Request.Headers.Authorization = "DPoP token-value";
+ context.Request.Headers["DPoP"] = "proof-value";
+
+ await middleware.InvokeAsync(context);
+
+ Assert.True(_nextCalled);
+ Assert.Equal("DPoP token-value", context.Request.Headers.Authorization.ToString());
+ Assert.Equal("proof-value", context.Request.Headers["DPoP"].ToString());
+ }
+
[Fact]
public async Task InvokeAsync_StripsAuthorizationHeadersWhenConfiguredPrefixIsNotApproved()
{