Gateway authorization: unmappable policy register and inventory
Origin: SPRINT_20260904_003_Router_gateway_authorization_fail_open.md (ROA-1). Verified against commit 6f71ae03e5 (the original source scan) and devops/compose/docker-compose.stella-services.yml at that commit (deployment posture). The fail-closed flip, the zero-claim register and the dead-knob deletion are ROA-1(b), verified against f27cacfc08 on 2026-09-07. Re-verify: dotnet test src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/StellaOps.Architecture.Contracts.Tests.csproj (class GatewayUnmappablePolicyConformanceTests), and rg -n "RequireAssertion\(" src --glob '!**/__Tests/**' --glob '!**/*.Tests/**' --glob '!**/__Obsoleted/**'.
The mechanism, in three links
- The host cannot enforce its own policies on the router path.
AspNetRouterRequestDispatcher(src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs) builds anHttpContextand invokesendpoint.RequestDelegatedirectly.UseAuthorization()is middleware; it never runs for a router-dispatched request, so a minimal-APIRequireAuthorization(policy)is NOT a backstop there.Router__AuthorizationTrustModeonly selects how the principal is populated (signed envelope vs headers); the bypass is unconditional. - The gateway is therefore the only enforcement point, and until 2026-09-07 it failed open.
src/Router/StellaOps.Gateway.WebService/Authorization/AuthorizationMiddleware.cslooks up the endpoint’s published claim requirements; when the list was empty it called_next, so an authenticated caller with any token passed. ROA-1(b) closed that branch — see “The fail-closed flip” below. The middleware now refuses an empty claim list unless the service has positively said the endpoint requires only authentication. - Requirements are published by the service, and assertion policies publish nothing.
DefaultAuthorizationClaimMappermapsClaimsAuthorizationRequirement,RolesAuthorizationRequirement,NameAuthorizationRequirement, and any requirement type exposingRequiredScopes/Scopes/AllowedScopes(+RequireAllScopes). ARequireAssertion(...)lambda is an opaqueAssertionRequirement: it maps to no claim, so the endpoint is published with zero claim requirements. Since ROA-1 the mapper logs this at Warning, once per (policy, requirement type), naming the policy and the service.
Consequence for a service author: scope protection cannot be assumed on any endpoint until its policy registration has been checked for RequireAssertion versus a typed requirement. A direct probe against the host cannot answer that question — the host’s own middleware runs there — only a router-path probe or reading the registration can.
The register (unmappable-policy-register.json)
Shrink-only, modelled on docs/architecture/build-boundary/legacy-edge-register.json. One pin per production file with its count of RequireAssertion call sites; the count may only decrease, a resolved pin is deleted, and a new call site anywhere fails GatewayUnmappablePolicyConformanceTests.Estate_EveryRequireAssertionPolicyIsPinnedAndShrinkOnly (unpinned, grown, and STALE pins all fail, so the register cannot outlive its cause). A second gate, Estate_EveryCustomAuthorizationRequirementExposesMappableScopes, fails any production IAuthorizationRequirement type that exposes none of the scope properties the mapper reads.
The fix for a new registration is never a pin. It is a typed requirement the mapper can read, and which typed requirement depends on what the host actually registers — determine it, do not assume:
- The host runs the resource-server stack (
AddStellaOpsResourceServerAuthentication, which registers theStellaOpsBearerscheme andStellaOpsScopeAuthorizationHandler) ⇒AddStellaOpsScopePolicy/AddStellaOpsAnyScopePolicy, orRequireStellaOpsScopes/RequireAnyStellaOpsScopeswhen an existingRequireAuthenticatedUser()must be kept. Notify’s Authority-enabled branch is the worked example (ROA-2): its three sibling policies already used these, so the two assertion policies converted like-for-like. - The host does not ⇒ a family-local requirement exposing
RequiredScopes+RequireAllScopes, evaluated by a family-local handler over the host’s own predicate, with policies and handler registered as ONE unit.GraphScopeRequirement(src/Graph/StellaOps.Graph.Api/Security/),AdvisoryAiScopeRequirement(src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Security/) andAirGapScopeRequirement(src/__Obsoleted/AirGap/StellaOps.AirGap.Controller/Security/, frozen) are the instances.
Two traps this ordering exists to avoid. Using the resource-server helpers in a host that does not register that scheme binds the policy to a scheme that is absent and needs a handler that is not there — it does not fail open, it breaks the direct path. And the question is per branch, not per host: AirGap’s AddAuthorization block is unconditional while its authentication is not, so its Testing/development branch (HeaderScopeAuthenticationHandler) would have taken scheme-bound policies it cannot satisfy. Registering policies without their handler has the same effect — a typed requirement with no evaluator denies everything, which ROA-1 hit in two test-composed hosts.
Register history: seeded 2026-09-05 with 66 call sites; shrunk to 60 by the AdvisoryAI conversion (ROA-1) and to 54 by the Notify + AirGap conversions (ROA-2), all in the same sprint (shrinkLog in the JSON).
Inventory of the RequireAssertion registrations (66 at seed, 54 after ROA-2)
Reachability is the deployment posture per docker-compose.stella-services.yml:
- REACHABLE-UNENFORCED — router-dispatched host, registration in the branch compose takes. Every endpoint behind it is authenticated-only at the gateway today.
- BRANCH-NOT-TAKEN — registration lives in an Authority-disabled / anonymous-fallback branch that the shipped compose posture does not take. Not unenforced today; becomes so in any deployment that disables Authority (where the host itself is open by design).
- NOT-DEPLOYED — host absent from the compose stack.
- DEAD — zero callers.
- UNVERIFIED — not determinable statically (none in this inventory; every row resolved).
| Family | File | Lines | Count | Shape | Scopes checked | Reachability |
|---|---|---|---|---|---|---|
| Scanner | src/Scanner/StellaOps.Scanner.WebService/Program.cs | 1151-1177 | 26 | permit-all (_ => true) | none | BRANCH-NOT-TAKEN — else of scanner:Authority:Enabled; compose ships SCANNER_SCANNER__AUTHORITY__ENABLED=true, under which all 26 policies are AddStellaOpsScopePolicy/AddStellaOpsAnyScopePolicy |
| Notify | src/Notify/StellaOps.Notify.WebService/Program.cs | 678, 686 (pre-conversion) | 2 → 0 | any-of | Operator: notify.operator|notify.admin; NotifyEscalate: notify.escalate|notify.operator|notify.admin | REACHABLE-UNENFORCED at seed — CONVERTED in ROA-2 (9d6f4b2c03) to StellaOpsScopeRequirement via RequireAnyStellaOpsScopes; live closure needs a redeploy of notify-web |
| Notify | same | 710-726 | 5 | permit-all | none | BRANCH-NOT-TAKEN — allowAnonymousFallback; compose ships false |
| Notify | same | 762-794 | 5 | any-of / single | Viewer, AssuranceReportingViewer, Operator, Admin, NotifyEscalate over notify.*, policy:read, policy:audit, notify.escalate | BRANCH-NOT-TAKEN — development-JWT branch (Authority disabled) |
src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs | 141-145, 331 | 6 → 0 | — | — | RESOLVED BY DELETION, not by conversion (SPRINT_20260722_016 AUTH-10, 2026-09-11). The host was retired to src/__Obsoleted/Authority/ when the AUTH-9 fold went live. Nothing enforced anywhere changed: five of the six were the Authority-disabled else branch the deployed compose never took (ISSUERDIRECTORY__AUTHORITY__ENABLED=true), and the sixth was the already-DEAD RequireScope helper with no callers. The live surface is served by authority from StellaOps.IssuerDirectory.Api, whose policies are typed. Register: totalCallSites 48 → 42. | |
| Evidence (Attestor) | src/Evidence/__Libraries/StellaOps.Attestor.Application/Security/AttestorPolicies.cs | 93, 98, 104 (post-conversion) | 3 → 0 | any-of OR trusted-network bypass, no RequireAuthenticatedUser() | write: attest:create (22 routes); verify: attest:read|attest:create (2 + 2 shared); read: attest:read|attest:create (26 routes) | REACHABLE-UNENFORCED at seed — CONVERTED in ROA-3 to AttestorScopeRequirement + AttestorScopeAuthorizationHandler (AddAttestorAuthorization). The bypass SURVIVES the conversion — see “The bypass and the conversion are independent” below; AttestorBypassTenantHeaderTests passes unchanged (4/4). Live closure needs an evidence-web redeploy |
| Evidence (Attestor) | same | 114, 129 (post-conversion) | 3 → 0 | any-of, RequireAuthenticatedUser() present | watchlist read (4 routes) / write (3) / admin (0 routes) over trust:* and legacy watchlist:* / watchlist.* | REACHABLE-UNENFORCED at seed — CONVERTED in ROA-3. read/write are typed AttestorScopeRequirement keeping RequireAuthenticatedUser(); watchlist:admin was DELETED, not converted — it guarded zero routes and the admin capability is enforced in-handler by WatchlistEndpoints.IsAdmin |
| AdvisoryAI | src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Security/AdvisoryAIPolicies.cs | 54, 64, 73, 86, 95, 101 (pre-conversion) | 6 → 0 | any-of / single | View: advisory-ai:view|operate|admin; Operate: operate|admin; Admin; Health: ops.health; OpsMemory Read/Write | REACHABLE-UNENFORCED at seed — CONVERTED in ROA-1 (19258fb497) to AdvisoryAiScopeRequirement; live closure needed a redeploy of the AdvisoryAI host, which landed 2026-09-05; the compose key is now advisoryai-web |
| AirGap (retired OK-5) | src/__Obsoleted/AirGap/StellaOps.AirGap.Controller/Program.cs | 54, 56, 58, 69 (pre-conversion) | 4 → 0 | single | airgap:status:read, airgap:seal, airgap:import, airgap:status:read | REACHABLE-UNENFORCED at seed — CONVERTED in ROA-2 (9d6f4b2c03) to AirGapScopeRequirement (registrations moved to Security/AirGapPolicies.cs); RESOLVED BY RETIREMENT 2026-09-11 (SPRINT_20260722_025 OK-5) — the redeploy this row was waiting for can never happen: the host is frozen at src/__Obsoleted/AirGap/StellaOps.AirGap.Controller/ and stellaops-airgap-controller is stopped. The /system/airgap surface that survives is Policy Engine’s (live probe 2026-09-14: 401, error body "service":"policy-engine"), and posture custody is Platform’s environment_state; neither inherits this host’s seed registrations |
| Workflow | src/Workflow/StellaOps.Workflow.WebService/Authorization/WorkflowPolicies.cs | 66, 77, 87 | 3 | any-of / single | View: workflow:view|operate|admin; Operate: operate|admin; Admin | NOT-DEPLOYED — WebService removed from compose 2026-07-06 (Slot 46); unenforced the day it returns |
| Signals | src/Signals/StellaOps.Signals/Program.cs | 429, 430, 434 | 3 | permit-all | none | BRANCH-NOT-TAKEN — anonymous-harness branch; compose ships Signals__Authority__Enabled=true |
Totals at seed (66): REACHABLE-UNENFORCED 18 (Notify 2, Attestor 6, AdvisoryAI 6, AirGap 4), BRANCH-NOT-TAKEN 44 (Scanner 26, Notify 10, IssuerDirectory 5, Signals 3), NOT-DEPLOYED 3 (Workflow), DEAD 1 (IssuerDirectory helper), UNVERIFIED 0. After ROA-1 (60): REACHABLE-UNENFORCED 12 (Notify 2, Attestor 6, AirGap 4). After ROA-2 (54): REACHABLE-UNENFORCED 6, all Attestor. After ROA-3 (48): REACHABLE-UNENFORCED 0 — every production RequireAssertion registration that the shipped compose posture actually reaches is now a typed, mappable requirement. The 48 that remain are 44 BRANCH-NOT-TAKEN, 3 NOT-DEPLOYED and 1 DEAD.
The bypass and the conversion are independent (ROA-3 corrects ROA-2’s premise)
Superseded, 2026-09-05. This section replaces “Why Attestor is deferred (ROA-2)”. If you find that heading, or ROA-2’s matching sprint bullet, do NOT re-derive “converting a
RequireAssertionpolicy removes its bypass” from either — the reasoning below is why that is wrong, and the owner adopted this correction on 2026-09-05.
ROA-2 deferred the Attestor six on a stated premise: “a typed scope requirement has no second branch, so converting removes the bypass a live keyless consumer depends on.” ROA-3 converted them without removing anything, because that premise is true only of StellaOpsScopeRequirement, whose SHARED StellaOpsScopeAuthorizationHandler owns the predicate. A family-local requirement is a data carrier for the mapper while its OWN handler owns the decision, so it can publish RequiredScopes to the gateway and still evaluate HasAnyScope(...) || IsServiceBypass(...) on the direct path. AttestorScopeRequirement carries AllowTrustedNetworkBypass for exactly the three policies that had the second branch; AttestorScopeAuthorizationHandler evaluates it.
The published claim set is exactly correct for the gateway path either way, which is what makes this faithful rather than a fudge: IsServiceBypass requires the Authorization header to be ABSENT, and the gateway refuses to forward a request without a token (RequiresAuthentication=true). The bypass branch therefore can never fire for a router-dispatched request, so on the gateway path the policy reduces exactly to its scope set.
Evidence the direct path is untouched: AttestorBypassTenantHeaderTests — the suite that pins the tokenless in-range POST /api/evidence/v1/rekor/entries returning 200 and persisting the caller-supplied tenant — passes unchanged, 4/4, against the converted registrations.
Removing the bypass is still the right end state, and it is now a separate change with a named precondition: four in-estate consumers submit to the Attestor routes with no Authorization header and no identity envelope, and each needs a token-bearing identity first.
The keyless consumer inventory (ROA-3 STEP 1, measured 2026-09-05)
Each row was confirmed by reading the outbound client registration, not inferred from the route.
| Consumer | Registration | Uses | Identity today |
|---|---|---|---|
Scanner ScanAttestationTransparencySubmitter | src/Scanner/StellaOps.Scanner.WebService/Program.cs:586-600 — AddHttpClient with no message handler | POST /api/v1/rekor/entries, attest:create | none; the source comment states the bypass is the auth |
ReleaseOrchestrator DeploymentAttestationTransparencySubmitter | .../StellaOps.ReleaseOrchestrator.WebApi/Program.cs:1114-1132 — AddHttpClient with no message handler | POST /api/v1/rekor/entries, attest:create | none; same comment |
Findings.Ledger AttestorVexOverrideTransparencySubmitter | .../StellaOps.Findings.Ledger.Application/FindingsLedgerApplicationServiceCollectionExtensions.cs:459-470 — AddHttpClient with no message handler | POST /api/v1/rekor/entries, attest:create | none; same comment |
Excititor AttestorRemoteAttestationClient | src/Concelier/StellaOps.Excititor.WebService/Program.cs:303 — bare AddHttpClient<,> | POST /api/v1/attestations:sign, POST /api/v1/rekor/entries | none; sets only the tenant header. Host is not deployed — no excititor-web container exists on this estate |
ReleaseOrchestrator HttpScanAttestationVerifier, HttpProvenanceAttestationResolver | Services/HttpScanAttestationVerifier.cs:237-262, Services/ProvenanceAttestationResolver.cs:221-262 | attestor read routes | A broken token path that lands on the bypass. Both request the scope "attestor:read" (ProvenanceAttestationResolver.cs:79) — a POLICY NAME, not a catalog scope, and not in stellaops-release-dispatch’s allowedScopes. Authority answers invalid_scope, the catch logs “querying anonymously”, the request goes out tokenless, and the bypass authorizes it |
Two callers that look like bypass consumers and are not:
- Policy Engine
HttpAttestorClient(src/Policy/StellaOps.Policy.Engine/Attestation/HttpAttestorClient.cs:60-90) sends a signed gateway identity envelope carryingScopes = [attest:create].UseIdentityEnvelopeAuthentication(IdentityEnvelopeMiddlewareExtensions.cs:120-146) hydrates an authenticated principal with onescopeclaim per envelope scope, which is exactly whatAttestorScopeAssertion.HasAnyScopereads. It survives the conversion and would survive the bypass removal. - CLI
AttestorLinkClient(src/Cli/StellaOps.Cli/Services/AttestorLinkClient.cs:167-176) mints a bearer throughIStellaOpsTokenClient; its file header records the same policy-name-versus-scope-claim defect having already been fixed there.
Not currently reachable, which is why removing the bypass breaks nothing today and everything tomorrow. All four keyless consumers are configured against http://attestor.stella-ops.local (or :8442). The predecessor stellaops-attestor container is absent; evidence-web took the NEW aliases evidence.stella-ops.local / evidence-web.stella-ops.local (devops/compose/docker-compose.evidence.yml:336-345) precisely so the two files could not both serve. Measured from inside stellaops-scanner-web on 2026-09-05: attestor.stella-ops.local resolves to 127.1.0.6 (a host loopback publish for the absent container) while evidence.stella-ops.local resolves to 172.19.0.44. Every submission has been failing as BackendUnavailable and degrading the report to self-signed since the cutover. Re-pointing them is the separate, owner-planned EVD-HTTP program (docs/implplan/_evidence/20260903-evd-prewindow-repoint-plan.md, stage 7(b), blocked on G2/B2/B3).
What the bypass removal needs, per consumer. Scanner and ReleaseOrchestrator each already hold a first-party client (stellaops-scanner-web, stellaops-release-dispatch) and an IStellaOpsTokenClient; each needs a token-attaching DelegatingHandler on the attestor HttpClient — the EvidenceLockerServiceTokenHandler shape, taking the per-request tenant from the X-StellaOps-TenantId header the submitters already set — plus an attest:create grant on its client, and RO additionally needs attest:read and the attestor:read → attest:read default corrected on both readers. Findings-web and Excititor-web have no suitable outbound identity: findings-web’s only AddStellaOpsAuthClient is the doctor registrar (StellaOps.Findings.WebService/Program.cs:103, scope platform.doctor.register), and widening a doctor identity to sign attestations is a posture decision; excititor-web registers no auth client at all and is not deployed. Those two are an owner call, not an implementer’s, which is why ROA-3 converted the policies and left the bypass in place rather than half-migrating two of four identities.
One more finding, recorded here because it changes what “removing the bypass” buys: the consolidated Evidence host registers AddStellaOpsResourceServerAuthentication with the DEFAULT section (StellaOps.Evidence.WebService/Program.cs:159), so StellaOpsResourceServerOptions.BypassNetworks IS bound there and StellaOpsBypassEvaluator (mode Strict) still grants a tokenless in-range caller any StellaOpsScopeRequirement that is not in NeverBypassableScopes. Deleting AttestorBypassEvaluator alone would therefore NOT close the host’s tokenless in-network surface — the resource-server bypass is a second, independent door. Any posture change has to address both.
ROA-4 outcome (2026-09-05): every inventoried consumer presents an identity; both bypass doors are shut
Re-measured first on the ROA-3 image (e82b1de4dc): through the gateway a scope-less token is 403 on every attestor route, attest:read cannot sign or submit, only attest:create writes; a tokenless caller inside 172.19.0.0/16 gets 401 Bearer on verify, entry GET and submit. The bypass branch was already inert (NetworkMask.Contains refuses an IPv4-mapped IPv6 RemoteIpAddress before comparing a byte, and the dual-stack http://+:8080 listener always presents one), so its removal changed no live behaviour and the posture question resolved as “delete the bypass having established that nothing was using it”.
| Consumer | Identity now | Grant | Proof |
|---|---|---|---|
Scanner ScanAttestationTransparencySubmitter | ScanAttestationServiceTokenHandler on the typed client — one attest:create token per tenant, tenant from X-StellaOps-TenantId | stellaops-scanner-web + attest:create | handler tests 5/5; live mint + submit |
ReleaseOrchestrator DeploymentAttestationTransparencySubmitter | DeploymentAttestationServiceTokenHandler, same shape | stellaops-release-dispatch + attest:create attest:read | handler tests 5/5; live mint + submit |
ReleaseOrchestrator readers (HttpScanAttestationVerifier, ProvenanceAttestationResolver) | already minted per tenant; the default scope was the POLICY NAME attestor:read (EVD-9 review D-B) — corrected to the claim attest:read in both options and both fallbacks | as above | live mint of attest:read |
Findings.Ledger AttestorVexOverrideTransparencySubmitter | VexOverrideAttestationServiceTokenHandler (Auth.Client added to the Application library, an allowed client SDK) | stellaops-findings-doctor widened with attest:create — findings-web holds ONE auth client, so the registrar-named client IS the host identity; a rename is the Findings owner’s follow-up | handler tests 5/5; live mint |
Excititor AttestorRemoteAttestationClient | AttestorRemoteAttestationServiceTokenHandler; the host now registers AddStellaOpsAuthClient from Excititor:Attestation:Attestor:Identity and fails closed at startup when the Attestor section is configured without it | new stellaops-excititor-web (attest:create), secret EXCITITOR_WEB_AUTHORITY_CLIENT_SECRET | handler tests 4/4; live mint (host not deployed here) |
Every handler sends NOTHING it cannot mint: no tenant header, no registered auth client, or a failed mint leaves the request unauthenticated and the Attestor refuses it visibly — never a tokenless write.
A fifth tokenless consumer the ROA-3 inventory did not list: Excititor’s Rekor verifier (RekorHttpClient in StellaOps.Excititor.Attestation, registered by the Excititor:Attestation:Rekor section, census EVD-HTTP-04) reads GET api/v1/rekor/entries/{uuid} on the internal Attestor tokenless. It is a generic transparency-log client with no tenant in its shape, the host is not deployed, and it is refused (401) after ROA-4 like any anonymous caller. Re-homing it belongs with Excititor’s deployment, not this row.
Both doors, closed together, in code and in compose. AttestorScopeRequirement lost AllowTrustedNetworkBypass, AttestorBypassEvaluator is deleted and AttestorScopeAuthorizationHandler evaluates the scope set alone; StellaOps.Evidence.WebService/Program.cs sets StellaOpsBypassMode.Disabled and clears BypassNetworks in the resource-server configure lambda, so a stray Authority:ResourceServer:BypassNetworks entry cannot reopen the shared door; docker-compose.evidence.yml declares no bypass key and EvidenceConsolidationComposeConformanceTests pins the absence on both roles. The anonymous X-StellaOps-TenantId tenant vehicle (ANCHOR-04-1) went with the bypass — the tenant comes from the token claim only. AttestorBypassTenantHeaderTests asserts the NEW behaviour on a host whose configuration still names a matching bypass CIDR: tokenless in-range → 401, nothing persisted (red-proved against the pre-ROA-4 code: 2 of 4 fail there).
Reconciling the sprint’s “80” — raw, production, reachable
Three numbers, because they answer three different questions:
| Count | Value | What it is |
|---|---|---|
Raw, every .cs under src/ | 128 occurrences / 46 files at seed commit 6f71ae03e5 (rg -c "RequireAssertion\s*\("; grows with every test fixture that mentions the token, including ROA-1’s own), or 80 as the sprint row counted it | Includes test scaffolding (Concelier, ReleaseOrchestrator, Notify, Scanner, Platform, AirGap test projects; EvidenceLockerWebApplicationFactory.cs:267 in the retiring predecessor’s tests; EvidenceHostCompositionTests.cs:469), src/__Obsoleted/, and comment mentions. None of these can be “converted”; a register seeded from this number would carry entries that never burn down. |
| Production registrations | 66 at seed, 60 after ROA-1, 54 after ROA-2 | What the register pins and the conformance scan counts: RequireAssertion call sites in non-test, non-obsoleted src/, comments and strings stripped. The row’s 80 minus ExportCenter 11 (StellaOps.ExportCenter.Tests/), EvidenceLocker 1 (StellaOps.EvidenceLocker.Tests/ — test projects not under a __Tests/ folder, which is how a __Tests-only filter counted them) and Authority’s 2 // comment mentions in IssuerDirectory Program.cs. |
| Production AND gateway-reachable in the shipped posture | 18 at seed, 12 after ROA-1, 6 after ROA-2 (all Attestor) | The number the decision on (b) turns on: registrations in a router-dispatched host, in the branch compose takes. Everything else is an Authority-disabled branch (44), an undeployed host (3) or dead code (1). |
Finding 2: Router__OnMissingAuthorization was a dead knob — DELETED 2026-09-07
Found by the EVD-9 review lane on 2026-09-05, verified in source, resolved by ROA-1(b).
devops/compose/.envsetROUTER_ON_MISSING_AUTHORIZATION=Fail(since 2026-06-02) and every router-enabled compose service carriedRouter__OnMissingAuthorization.Failis not a member ofMissingAuthorizationBehavior(RequireExplicit | AllowAuthenticated | WarnAndAllow,StellaRouterBridgeOptions.cs:198-217).- It did not fail because the key was never bound: hosts bind
Router:*intoStellaRouterOptionsBase(StellaRouterIntegrationHelper.AddRouterMicroservice→Get<StellaRouterOptionsBase>()), which has noOnMissingAuthorizationproperty, so the binder dropped the key — valid or invalid, no exception.TryAddStellaRouternever mapped it either.StellaRouterOptions.OnMissingAuthorizationtherefore kept its defaultWarnAndAllow(StellaRouterOptions.cs:72-73), whichStellaRouterExtensions.cs:61copies over the bridge’s own stricter defaultRequireExplicit. Effective estate posture:WarnAndAllow, whatever the configuration said. - What this knob governs: endpoints with no authorization metadata at all (
!HasAuthorization && !AllowAnonymous,AspNetCoreEndpointDiscoveryProvider.cs:192-216) — published as authenticated-only with a warning instead of failing discovery. It is a distinct layer from theRequireAssertiongap above (those endpoints haveHasAuthorization = true); neither the flip norRequireExplicitcloses the other one. - Resolution (ROA-1(b), 2026-09-07): every deployment mention was deleted rather than wired. The key is gone from 14 compose files,
devops/compose/README.md, both env examples and the release bundle (regenerated, not hand-edited). Wiring was rejected on evidence, not preference: the only fail-closed value,RequireExplicit, throws at startup for any endpoint lacking[Authorize]/[AllowAnonymous], and the inventory that decision needs does not exist — the first measurement of it is in the next section, and Graph alone has 23 such routes. A knob whose single shipped value equals the code default is ceremony; a knob that reads as a safety valve and binds to nothing is worse.StellaRouterOptions.OnMissingAuthorizationremains a code-level option onAddStellaRouter. - Guards:
GatewayUnmappablePolicyConformanceTests.Compose_NeverSetsTheUnboundOnMissingAuthorizationKeyfails if the key reappears anywhere underdevops/or.gitea/(red-proved 2026-09-07 against the 22 occurrences in the not-yet-regenerated release bundle, which it named line by line);RouterOnMissingAuthorization_IsNotBoundFromConfiguration_PinnedUntilWiredstill pins the property so wiring it stays deliberate. The predecessor gate,Compose_EveryOnMissingAuthorizationValue_IsARealEnumMember, was deleted with the key — and it had a hole worth recording: it scanned only*.ymlunderdevops/composeplus the local.env, sostellaops.env.exampleand the bundle’s.env.exampleboth carriedFailunder a green gate.
The fail-closed flip (ROA-1(b), landed 2026-09-07)
AuthorizationMiddleware no longer passes an authenticated request through on an empty claim list. The rule, in the order it is evaluated:
The ROA-1(b) rule, verbatim.
AuthorizationMiddlewareevaluates, in this order:
endpoint shape gateway AllowAnonymouspass not authenticated, not anonymous 401 claims published enforce them zero claims, no named policy declared pass — a bare RequireAuthorization()/[Authorize], or no authorization metadata at all. Authentication IS the whole rule the service declared, and the 401 gate already applied itzero claims, named policy, AuthorizationFullyMappedpass — the service resolved the policy and found only an authentication requirement zero claims, named policy, anything else 403 — either a requirement the mapper cannot read, or a service build that does not report. Both mean the gateway does not know the rule it is meant to enforce The distinction is PUBLISHED by the service, never inferred by the gateway:
EndpointDescriptor.AuthorizationFullyMapped(default false) andUnmappedAuthorizationRequirementsare set byDefaultAuthorizationClaimMapper, the only component that knows whether it could read a requirement. Keying the refusal on the DECLARED-POLICY list is what bounds the blast radius to sets enumerable from source and gated shrink-only; keying it on “claims are empty” also catches every authentication-only endpoint, which is an estate-wide outage.
The 48 RequireAssertion registrations pinned above and the one zeroClaimByDesign row ARE those enumerable sets, and both gates are shrink-only, so the bound stays true. The default is the unsafe-looking one on purpose: a descriptor from a service built before this field says nothing, and “says nothing” must not mean “trusted”.
What that means for a rollout, stated plainly. The flip bites immediately for un-rebuilt services for their named-policy zero-claim endpoints — that is what makes a gateway-only redeploy worth doing — and it needs graph-api rebuilt in the same window, because Graph’s controller fallback is exactly that shape and its descriptors will not carry the new flag until it is. The three carried ReachGraph controllers (v1/cve-mappings, v1/reachgraphs, v1/reachability) are the routes at stake. GraphAuthenticationOnlyPolicyPublicationTests measures the assurance on the real Graph host so the precondition is a test result rather than a belief.
Pinned by PublishedAuthorizationSemanticsTests on the real chain (framework policy → mapper → wire → claims store → gateway): an assertion policy is now refused whether the framework would have denied it or allowed it, an opaque custom requirement is refused, and an authentication-only policy is still served to an authenticated caller and still 401 to an anonymous one. The row that pinned the old divergence, AssertionPolicy_DeniedByFramework_ButPassesGateway_UntilFailClosedLands, was deleted in the same change. Red-proved 2026-09-07 by restoring the fail-open branch: 5 rows fail, three of them with Expected: 403 / Actual: 204.
The layer the flip does NOT close, measured
An endpoint with no authorization metadata at all declares no policy, so the gateway passes it to any authenticated caller — before and after the flip. That is Finding 2’s layer, and this is its first real measurement, taken from the production Graph host’s own discovery output on 2026-09-07 (GraphAuthenticationOnlyPolicyPublicationTests run, AspNetCoreEndpointDiscoveryProvider warnings):
23 distinct graph-api routes carry no authorization metadata, each published twice (legacy and canonical alias) for 45 descriptor rows. They include POST /graph/query, POST /graph/search, GET /search, GET /graphs, GET /graphs/{graphId}, GET /paths, POST /graph/lineage, POST /graph/diff, POST /graph/export, GET /nodes/{nodeId}/adjacency, GET /assets/{assetId}/snapshot and the saved-views GET/POST/DELETE trio.
This is recorded, not fixed: it is Graph’s authorization posture, not the Router defect ROA-1 is about, and closing it means giving each route a scope or taking the RequireExplicit decision estate-wide.
Treat it as a FINDING, not as a pending knob decision (orchestrator ruling, 2026-09-07). The knob that was supposed to govern this never existed as configuration and has now been deleted, so nothing about a knob is outstanding. What is outstanding is a measured exposure: routes the gateway serves to any authenticated caller because the service declared no rule at all. The 23 above are the first datum; the estate-wide inventory is the next row’s work, and RequireExplicit — which refuses to start a host carrying an unannotated endpoint — cannot be turned on before that inventory exists.
What preceded remediation (b) — the checklist, and how it closed
Recorded as it stood, with each item’s outcome, because the ordering is the reusable part.
- Convert or re-home every live unmappable registration. Done before the flip: AdvisoryAI 6 (ROA-1), Notify 2 + AirGap 4 (ROA-2), Attestor 6 (ROA-3), each with a live redeploy.
reachableUnenforcedNowreached 0 at ROA-3 and the flip landed against that number, which is why it changed no live verdict on any converted family. - Decide the fate of the BRANCH-NOT-TAKEN rows (44), NOT-DEPLOYED (3) and DEAD (1). They stay pinned and the register did not shrink at the flip. They are Authority-disabled branches the gateway already answers 401 for (the branch registers permit-all policies but the endpoints are still not
AllowAnonymous, so the unauthenticated gate fires first), an undeployed host, and a helper with no callers. The flip changes their outcome from “pass” to “refuse” the day one of those branches is taken, which is the intended posture — converting them is not a precondition for it. - Inventory the endpoints that publish zero claims for reasons OTHER than an unmappable policy, so (b) does not turn those into 403. Done, and it split in two:
- Named policies with no claim-bearing requirement: exactly ONE in production
src/—GraphPolicies.Authenticated. Pinned underzeroClaimByDesignwith its reason and gated shrink-only byEstate_EveryZeroClaimAuthorizationPolicyIsPinnedAndShrinkOnly(red-proved by planting one insrc/Signals/StellaOps.Signals/Program.cs, which failed naming the file). - Endpoint-level authentication-only gates — a bare
RequireAuthorization()/[Authorize](12 production call sites) and endpoints with no authorization metadata at all. Not pinned and not refused: they declare no policy, so the rule does not fire. Measured for Graph in the section above.
- Named policies with no claim-bearing requirement: exactly ONE in production
- Delete the pin that held the old behaviour. Done, replaced by its inverse plus two rows the old pin did not cover.
Re-verifying this document
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/StellaOps.Architecture.Contracts.Tests.csproj -Class "StellaOps.Architecture.Contracts.Tests.GatewayUnmappablePolicyConformanceTests"— both registers, both detectors, the dead-knob gates.dotnet test src/Router/__Tests/StellaOps.Gateway.WebService.Tests/StellaOps.Gateway.WebService.Tests.csproj— the flip on the real publication chain.pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Graph/__Tests/StellaOps.Graph.Api.Tests/StellaOps.Graph.Api.Tests.csproj -Class "StellaOps.Graph.Api.Tests.GraphAuthenticationOnlyPolicyPublicationTests"— the one zero-claim-by-design policy, measured on its own host.
