diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 1e21552827..cf4653b990 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -53,6 +53,23 @@ corsHandler → AppsPage → StatusPage → Http4s510 → Http4s600 → Http4s50 HTTP Response (with standard headers) ``` +### Version enable/disable semantics + +Two Props govern which API versions are served: `api_disabled_versions` and `api_enabled_versions` (allowlist; empty means "all"). They are enforced **once at startup**, by `Http4sApp.gate`: + +```scala +private def gate(version: ScannedApiVersion, routes: HttpRoutes[IO]): HttpRoutes[IO] = + if (APIUtil.versionIsAllowed(version)) routes else HttpRoutes.empty[IO] +``` + +A disabled version's top-level routes are replaced with `HttpRoutes.empty[IO]`, so a direct `GET /obp/vX.Y.Z/...` falls through to the Lift bridge and 404s. + +**Cascade is intentionally unaffected.** Each `Http4sXxx` has a path-rewriting bridge to the next-lower version that calls `code.api.vN.HttpNxx.wrappedRoutesVNxxServices` *directly*, bypassing `Http4sApp.gate`. `ResourceDocMiddleware` does **not** re-check `implementedInApiVersion` per request either (`ResourceDocMiddleware.isEndpointEnabled` deliberately has no `versionAllowed` parameter — `ResourceDocMiddlewareEnableDisableTest` pins this). So an endpoint originally declared in v2.0.0 stays reachable via `/obp/v4.0.0/...` even when v2.0.0 is disabled, as long as v4.0.0 is enabled. + +This preserves the documented OBP-API contract: newer versions act as the supported entry point for older endpoints' functionality. Operators can retire a version's *URL prefix* with `api_disabled_versions` without losing the underlying endpoints from newer prefixes. To retire a specific endpoint everywhere, use `api_disabled_endpoints` (operationId list) — that **is** enforced per request by the middleware and so kills the endpoint on every prefix it would otherwise be reachable from. + +A brief regression in early 2026-05 inverted this: a `versionAllowed` check was added inside the middleware, making `api_disabled_versions` kill cascaded reachability too. Restored 2026-05-26. If you're tempted to put the per-request version check back, read the `isEndpointEnabled` docstring first — it spells out the design rationale, and the "version-level gating is delegated to Http4sApp.gate" feature in the unit test will fail loudly. + ### Lift bridge — `Http4sLiftWebBridge.scala` Handles any request not matched by a native http4s route: diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 6b90978674..8f7b4efbc9 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -10,7 +10,7 @@ import code.api.util.newstyle.ViewNewStyle import code.api.util.{APIUtil, ApiRole, CallContext, NewStyle} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ -import com.openbankproject.commons.util.{ApiShortVersions, ScannedApiVersion} +import com.openbankproject.commons.util.ApiShortVersions import net.liftweb.common.{Box, Empty, Failure, Full} import org.http4s._ import org.http4s.headers.`Content-Type` @@ -82,26 +82,34 @@ object ResourceDocMiddleware extends MdcLoggable { } /** - * Pure decision: is this ResourceDoc enabled given the four enable/disable Props? + * Pure decision: is this ResourceDoc enabled given the endpoint-level Props? * - * Semantics — matches `APIUtil.getAllowedResourceDocs` / `versionIsAllowed`: + * Semantics: * - if operationId is in disabledOperationIds → disabled * - if enabledOperationIds non-empty and op not in it → disabled - * - if version is not allowed → disabled * - otherwise → enabled * + * Version-level enable/disable (`api_disabled_versions` / `api_enabled_versions`) + * is deliberately NOT enforced here. It is applied once at startup by + * `Http4sApp.gate`, which makes a disabled version's top-level routes empty so + * direct `/obp/vX.Y.Z/...` traffic falls through to a 404. The middleware does + * not re-check `implementedInApiVersion` per request because doing so blocks + * the documented OBP-API behaviour: disabling, say, v2.0.0 retires the + * `/obp/v2.0.0/...` prefix but the v2.0.0-origin endpoints stay reachable via + * any newer-version prefix (v3.0.0, v4.0.0, ...) the operator has kept enabled. + * That cascading surface is intentional — it lets newer versions act as the + * stable, supported entry point for older endpoints' functionality. + * * Extracted from `apply` so the decision can be unit-tested without standing up * a middleware instance or mutating global Props. */ def isEndpointEnabled( rd: ResourceDoc, disabledOperationIds: Set[String], - enabledOperationIds: Set[String], - versionAllowed: ScannedApiVersion => Boolean + enabledOperationIds: Set[String] ): Boolean = !disabledOperationIds.contains(rd.operationId) && - (enabledOperationIds.isEmpty || enabledOperationIds.contains(rd.operationId)) && - versionAllowed(rd.implementedInApiVersion) + (enabledOperationIds.isEmpty || enabledOperationIds.contains(rd.operationId)) /** * Middleware factory: wraps HttpRoutes with ResourceDoc validation. @@ -113,15 +121,19 @@ object ResourceDocMiddleware extends MdcLoggable { Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => // Read enable/disable Props per request so runtime changes (e.g. `setPropsValues` in // tests or live config reloads) take effect immediately. Cost is a few Lift Props - // lookups — negligible per request, but lets disabled endpoints/versions be toggled - // without restarting the server. A disabled endpoint or version yields OptionT.none - // so the request falls through to the next handler in the chain (typically the Lift - // bridge), mirroring the absent-route behavior of Lift's startup filter. + // lookups — negligible per request, but lets disabled endpoints be toggled without + // restarting the server. A disabled endpoint yields OptionT.none so the request + // falls through to the next handler in the chain (typically the Lift bridge). + // + // Version-level enable/disable is NOT re-checked here — that's enforced once at + // startup by `Http4sApp.gate` for the URL prefix the request arrives at, so that + // disabling vX.Y.Z retires `/obp/vX.Y.Z/...` but leaves the same endpoints + // reachable via newer enabled prefixes through the cascade. See + // `isEndpointEnabled`'s docstring for the rationale. val disabledOperationIds = APIUtil.getDisabledEndpointOperationIds().toSet val enabledOperationIds = APIUtil.getEnabledEndpointOperationIds().toSet def endpointIsEnabled(rd: ResourceDoc): Boolean = - isEndpointEnabled(rd, disabledOperationIds, enabledOperationIds, - v => APIUtil.versionIsAllowed(v)) + isEndpointEnabled(rd, disabledOperationIds, enabledOperationIds) val apiVersionFromPath = req.uri.path.segments.map(_.encoded).toList match { case apiPathZero :: version :: _ if apiPathZero == APIUtil.getPropsValue("apiPathZero", "obp") => version case _ => ApiShortVersions.`v7.0.0`.toString diff --git a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala index 7f781783ef..c7fc9b3d7e 100644 --- a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala +++ b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisablePropsTest.scala @@ -2,6 +2,7 @@ package code.api.util.http4s import cats.effect.IO import cats.effect.unsafe.IORuntime +import code.api.v4_0_0.Http4s400 import code.api.v7_0_0.Http4s700 import code.setup.ServerSetup import fs2.Stream @@ -12,9 +13,20 @@ import org.scalatest.{GivenWhenThen, Tag} * Integration test for the enable/disable Props wiring inside `ResourceDocMiddleware`. * * Drives `Http4s700.wrappedRoutesV700Services` in-process — no TCP, no DB. Verifies that - * setting the four Props (`api_disabled_endpoints`, `api_enabled_endpoints`, - * `api_disabled_versions`, `api_enabled_versions`) actually changes routing behaviour at - * request time. + * the endpoint-level Props (`api_disabled_endpoints`, `api_enabled_endpoints`) actually + * change routing behaviour at request time, AND pins the deliberate non-enforcement of + * the version-level Props (`api_disabled_versions`, `api_enabled_versions`) at the + * middleware layer. + * + * Why version Props are NOT enforced here: + * They are enforced once at startup by `Http4sApp.gate` for the URL prefix the + * request arrives at. The middleware deliberately does not re-check + * `implementedInApiVersion` per request, so that disabling vX retires + * `/obp/vX/...` but leaves vX's endpoints reachable via newer enabled prefixes + * through the cascade. See `ResourceDocMiddleware.isEndpointEnabled` for the + * rationale. Because this test drives `Http4s700.wrappedRoutesV700Services` + * directly (bypassing `Http4sApp.gate`), `api_disabled_versions=[v7.0.0]` does + * not turn `/obp/v7.0.0/...` into 404s — and that is the contract this file pins. * * Why a separate test class from `ResourceDocMiddlewareEnableDisableTest`: * That test pins the pure decision logic (`isEndpointEnabled`). This one pins the @@ -38,6 +50,13 @@ class ResourceDocMiddlewareEnableDisablePropsTest extends ServerSetup with Given implicit val runtime: IORuntime = IORuntime.global private val app = Http4s700.wrappedRoutesV700Services.orNotFound + // A second app rooted at v4.0.0's wrapped routes — includes the v400ToV310Bridge. + // Used by the cascade scenarios below to prove that a v3.1.0-origin endpoint + // reached via `/obp/v4.0.0/...` still serves when v3.1.0 is in + // `api_disabled_versions`. The middleware running inside Http4s310 no longer + // enforces the version Prop, so the cascade is unaffected. + private val v4App = Http4s400.wrappedRoutesV400Services.orNotFound + // OperationIds match `APIUtil.buildOperationId(v, partialFunctionName)` → // s"$fullyQualifiedVersion-$name". v7.0.0's fully qualified form is "OBPv7.0.0". private val rootOpId = "OBPv7.0.0-root" @@ -52,6 +71,12 @@ class ResourceDocMiddlewareEnableDisablePropsTest extends ServerSetup with Given app.run(req).unsafeRunSync().status.code } + private def getV4(path: String): Int = { + val req = Request[IO](Method.GET, Uri.unsafeFromString(path), headers = Headers.empty, + body = Stream.empty) + v4App.run(req).unsafeRunSync().status.code + } + feature("ResourceDocMiddleware — Props wiring at request time") { scenario("Baseline: no Props set → /root returns 200", EnableDisablePropsTag) { @@ -101,17 +126,19 @@ class ResourceDocMiddlewareEnableDisablePropsTest extends ServerSetup with Given status shouldBe 200 } - scenario("api_disabled_versions disables every endpoint of that version", EnableDisablePropsTag) { - Given("api_disabled_versions=[v7.0.0]") + scenario("api_disabled_versions is NOT enforced by the middleware — cascade-friendly", EnableDisablePropsTag) { + Given("api_disabled_versions=[v7.0.0] (would historically have killed every v7 endpoint)") setPropsValues("api_disabled_versions" -> "[v7.0.0]") - When("requesting two unrelated v7 endpoints") + When("requesting two unrelated v7 endpoints directly against Http4s700's wrapped routes") val rootStatus = get(rootPath) val banksStatus = get(banksPath) - Then("both are short-circuited by the middleware → 404") - rootStatus shouldBe 404 - banksStatus shouldBe 404 + Then("both still serve — the middleware deliberately ignores version-level Props") + And("(the prefix-level gate lives in Http4sApp.gate, which this test bypasses)") + And("this is the behaviour that lets older endpoints stay reachable via newer enabled prefixes") + rootStatus shouldBe 200 + banksStatus shouldBe 200 } scenario("Disabled-endpoint wins over enabled-endpoint when same id is in both", EnableDisablePropsTag) { @@ -128,7 +155,7 @@ class ResourceDocMiddlewareEnableDisablePropsTest extends ServerSetup with Given status shouldBe 404 } - scenario("api_disabled_versions overrides an explicit api_enabled_endpoints entry", EnableDisablePropsTag) { + scenario("api_disabled_versions does NOT override api_enabled_endpoints at the middleware", EnableDisablePropsTag) { Given(s"api_disabled_versions=[v7.0.0] AND api_enabled_endpoints=[$rootOpId]") setPropsValues( "api_disabled_versions" -> "[v7.0.0]", @@ -138,8 +165,9 @@ class ResourceDocMiddlewareEnableDisablePropsTest extends ServerSetup with Given When("requesting GET /obp/v7.0.0/root") val status = get(rootPath) - Then("the version gate wins → 404") - status shouldBe 404 + Then("the endpoint serves — only the endpoint-level allowlist matters in the middleware") + And("(the version-level gate is enforced separately by Http4sApp.gate at the URL prefix)") + status shouldBe 200 } scenario("After Props reset, baseline behavior is restored", EnableDisablePropsTag) { @@ -150,4 +178,48 @@ class ResourceDocMiddlewareEnableDisablePropsTest extends ServerSetup with Given status shouldBe 200 } } + + // ─── Cascade reachability across a disabled middle version ────────────────── + // + // Driving `Http4s400.wrappedRoutesV400Services` directly exercises: + // 1. Http4s400's own routes via its middleware (no /certs match → falls through) + // 2. v400ToV310Bridge — rewrites `/obp/v4.0.0/...` to `/obp/v3.1.0/...` and + // calls `Http4s310.wrappedRoutesV310Services` directly (bypasses Http4sApp.gate) + // 3. Http4s310's middleware + own routes (matches /certs → serves) + // + // `getServerJWK` (GET /certs) is a no-auth no-DB endpoint declared only in + // Http4s310 — newer versions don't redeclare it. Perfect for proving that an + // endpoint originally registered against a "middle" version is still reachable + // from a newer version's prefix even when the middle version is disabled. + // + // If a future change reintroduces a per-request `implementedInApiVersion` + // check inside `ResourceDocMiddleware`, the second scenario flips to 404 and + // a reviewer is forced to revisit the design before merging. That's the + // safety net that pins the cascade contract end-to-end. + feature("ResourceDocMiddleware — cascade reachability survives api_disabled_versions on the middle version") { + + val certsViaV4 = "/obp/v4.0.0/certs" + + scenario("Baseline: cascade reaches /certs from /obp/v4.0.0 via v400ToV310Bridge", EnableDisablePropsTag) { + Given("no enable/disable Props set") + When("requesting GET /obp/v4.0.0/certs against Http4s400.wrappedRoutesV400Services") + val status = getV4(certsViaV4) + Then("Http4s400 has no /certs route → v400ToV310Bridge serves it from Http4s310") + status shouldBe 200 + } + + scenario("api_disabled_versions=[v3.1.0] does NOT break the v4→v3.1 cascade", EnableDisablePropsTag) { + Given("api_disabled_versions=[v3.1.0] — at some point during the migration to http4s, the intended design was broken and this would have killed cascaded reachability") + setPropsValues("api_disabled_versions" -> "[v3.1.0]") + + When("requesting GET /obp/v4.0.0/certs through the v400ToV310Bridge") + val status = getV4(certsViaV4) + + Then("the endpoint still serves — the middleware does not enforce version-level disable") + And("(direct /obp/v3.1.0/certs would 404 in production via Http4sApp.gate, but the gate") + And("is not exercised by driving wrappedRoutesV400Services directly — the cascade test is") + And("specifically about ResourceDocMiddleware not killing the bridge dispatch)") + status shouldBe 200 + } + } } diff --git a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisableTest.scala b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisableTest.scala index f20ab6ca5d..60c2254545 100644 --- a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisableTest.scala +++ b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMiddlewareEnableDisableTest.scala @@ -9,20 +9,18 @@ import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag} /** * Unit tests for `ResourceDocMiddleware.isEndpointEnabled`. * - * Covers the gating logic the middleware applies on every request, matching the - * semantics of `APIUtil.getAllowedResourceDocs` / `APIUtil.versionIsAllowed` used - * on the Lift path: + * Covers the gating logic the middleware applies on every request: * * - `api_disabled_endpoints` — operationIds blocked by this Prop * - `api_enabled_endpoints` — allowlist; if non-empty, only listed operationIds pass - * - `api_disabled_versions` / `api_enabled_versions` — modelled here as a - * `ScannedApiVersion => Boolean` so the decision can be tested without - * mutating global Props. * - * Integration of the four real Props (`APIUtil.getDisabledEndpointOperationIds` - * etc.) into the middleware happens once at middleware construction; that wiring - * is verified by compile-time type-checking of `apply` plus the existing pure- - * function tests in `APIUtilHeavyTest`. This file pins the *composition* logic. + * Version-level enable/disable (`api_disabled_versions` / `api_enabled_versions`) is + * NOT enforced here — it is applied once at startup by `Http4sApp.gate` for the URL + * prefix the request arrives at. This preserves the documented cascading behaviour: + * disabling vX retires `/obp/vX/...` but leaves vX's endpoints reachable via newer + * enabled prefixes. The "version-level gating is delegated" feature below pins that + * contract — anyone restoring a per-request version check in the middleware will + * break those scenarios. */ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers with GivenWhenThen { @@ -44,9 +42,6 @@ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers w roles = None ) - private val allowAllVersions: ScannedApiVersion => Boolean = _ => true - private val denyAllVersions: ScannedApiVersion => Boolean = _ => false - feature("ResourceDocMiddleware.isEndpointEnabled — endpoint-level gating") { scenario("baseline: no Props set → endpoint is enabled", EnableDisableTag) { @@ -55,7 +50,7 @@ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers w When("isEndpointEnabled is called") val result = ResourceDocMiddleware.isEndpointEnabled( - rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty, allowAllVersions + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty ) Then("the endpoint is enabled") @@ -68,7 +63,7 @@ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers w When("isEndpointEnabled is called with that operationId disabled") val result = ResourceDocMiddleware.isEndpointEnabled( - rd, disabledOperationIds = Set(rd.operationId), enabledOperationIds = Set.empty, allowAllVersions + rd, disabledOperationIds = Set(rd.operationId), enabledOperationIds = Set.empty ) Then("the endpoint is disabled") @@ -82,7 +77,7 @@ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers w When("isEndpointEnabled is called against the allowlist") val result = ResourceDocMiddleware.isEndpointEnabled( - rd, disabledOperationIds = Set.empty, enabledOperationIds = Set(other.operationId), allowAllVersions + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set(other.operationId) ) Then("the endpoint is disabled (allowlist excludes it)") @@ -95,7 +90,7 @@ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers w When("isEndpointEnabled is called against the allowlist") val result = ResourceDocMiddleware.isEndpointEnabled( - rd, disabledOperationIds = Set.empty, enabledOperationIds = Set(rd.operationId), allowAllVersions + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set(rd.operationId) ) Then("the endpoint is enabled") @@ -108,7 +103,7 @@ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers w When("isEndpointEnabled is called") val result = ResourceDocMiddleware.isEndpointEnabled( - rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty, allowAllVersions + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty ) Then("the endpoint is enabled — empty allowlist means 'no restriction'") @@ -123,8 +118,7 @@ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers w val result = ResourceDocMiddleware.isEndpointEnabled( rd, disabledOperationIds = Set(rd.operationId), - enabledOperationIds = Set(rd.operationId), - allowAllVersions + enabledOperationIds = Set(rd.operationId) ) Then("disabled wins") @@ -132,53 +126,58 @@ class ResourceDocMiddlewareEnableDisableTest extends FeatureSpec with Matchers w } } - feature("ResourceDocMiddleware.isEndpointEnabled — version-level gating") { - - scenario("version is disabled → endpoint is disabled regardless of endpoint Props", EnableDisableTag) { - Given("a ResourceDoc whose version is denied") - val rd = doc("getBank", version = ApiVersion.v7_0_0) - - When("isEndpointEnabled is called with versionAllowed returning false") + feature("ResourceDocMiddleware.isEndpointEnabled — version-level gating is delegated to Http4sApp.gate") { + + // These scenarios encode an intentional design decision: the middleware does NOT + // re-check `implementedInApiVersion` against `api_disabled_versions` / + // `api_enabled_versions`. Version-level disable is handled once at startup by + // `Http4sApp.gate`, which empties the disabled version's top-level routes so + // direct `/obp/vX.Y.Z/...` traffic falls through to a 404. Cascaded traffic + // (e.g. `/obp/v4.0.0/foo` resolving to a v2.0.0-origin endpoint) keeps working + // when v2.0.0 is disabled and v4.0.0 is enabled — that is the documented OBP + // behaviour where newer versions act as the supported entry point for older + // endpoints' functionality. If a future change reintroduces a per-request + // version check inside `isEndpointEnabled`, these scenarios will flip and a + // reviewer will be forced to revisit the design before merging. + + scenario("isEndpointEnabled has no `versionAllowed` parameter — pins the API shape", EnableDisableTag) { + Given("a ResourceDoc on any version, e.g. v6") + val rd = doc("getBank", version = ApiVersion.v6_0_0) + + When("isEndpointEnabled is called with both Props sets empty") val result = ResourceDocMiddleware.isEndpointEnabled( - rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty, denyAllVersions + rd, disabledOperationIds = Set.empty, enabledOperationIds = Set.empty ) - Then("the endpoint is disabled") - result shouldBe false + Then("the endpoint is enabled — version is not part of the decision") + result shouldBe true + } + + scenario("the version on the ResourceDoc never influences the decision", EnableDisableTag) { + Given("two ResourceDocs that differ only in version") + val rd6 = doc("getBank", version = ApiVersion.v6_0_0) + val rd7 = doc("getBank", version = ApiVersion.v7_0_0) + + When("isEndpointEnabled is called for each with identical Props") + val r6 = ResourceDocMiddleware.isEndpointEnabled(rd6, Set.empty, Set.empty) + val r7 = ResourceDocMiddleware.isEndpointEnabled(rd7, Set.empty, Set.empty) + + Then("both are enabled — the middleware treats version as out-of-scope") + r6 shouldBe true + r7 shouldBe true } - scenario("version is disabled overrides an explicit enabled-endpoints entry → disabled", EnableDisableTag) { - Given("a ResourceDoc explicitly enabled by operationId but on a disabled version") - val rd = doc("getBank", version = ApiVersion.v7_0_0) + scenario("endpoint-level disable still applies regardless of version", EnableDisableTag) { + Given("a v6 ResourceDoc whose operationId is in api_disabled_endpoints") + val rd = doc("getBank", version = ApiVersion.v6_0_0) - When("isEndpointEnabled is called with the version denied") + When("isEndpointEnabled is called") val result = ResourceDocMiddleware.isEndpointEnabled( - rd, - disabledOperationIds = Set.empty, - enabledOperationIds = Set(rd.operationId), - denyAllVersions + rd, disabledOperationIds = Set(rd.operationId), enabledOperationIds = Set.empty ) - Then("the version gate wins — endpoint is disabled") + Then("the endpoint is disabled — endpoint-level Props are unaffected by this change") result shouldBe false } - - scenario("per-version gating: only the doc's own version matters", EnableDisableTag) { - Given("a versionAllowed function that allows v7 but denies v6") - val rd7 = doc("getBank", version = ApiVersion.v7_0_0) - val rd6 = doc("getBank", version = ApiVersion.v6_0_0) - val versionAllowed: ScannedApiVersion => Boolean = { - case v if v == ApiVersion.v7_0_0 => true - case _ => false - } - - When("isEndpointEnabled is called for each ResourceDoc") - val v7Result = ResourceDocMiddleware.isEndpointEnabled(rd7, Set.empty, Set.empty, versionAllowed) - val v6Result = ResourceDocMiddleware.isEndpointEnabled(rd6, Set.empty, Set.empty, versionAllowed) - - Then("v7 is enabled and v6 is disabled") - v7Result shouldBe true - v6Result shouldBe false - } } } diff --git a/release_notes.md b/release_notes.md index 28aa398a33..40ac58476d 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,6 +3,27 @@ ### Most recent changes at top of file ``` Date Commit Action +26/05/2026 TBD BEHAVIOUR RESTORE: api_disabled_versions / api_enabled_versions + once again retire only the URL prefix, not the underlying endpoints + on newer prefixes — restoring the pre-http4s-migration cascade + contract. Specifically: if v2.0.0 is in api_disabled_versions, then + /obp/v2.0.0/foo returns 404 (handled at startup by Http4sApp.gate), + but the same endpoint reached via /obp/v4.0.0/foo (any enabled + newer version) continues to serve through the path-rewriting + cascade. A regression in early May 2026 had inverted this by + adding a per-request implementedInApiVersion check inside + ResourceDocMiddleware; that check is now removed. + + Who is affected: installations that set api_disabled_versions / + api_enabled_versions AND relied on the May-2026 strict behaviour + to remove an older version's endpoints from newer prefixes. To + retain that strict behaviour, switch to api_disabled_endpoints + (operationId list) — that Prop is still enforced per request by + the middleware and kills the endpoint on every prefix it would + otherwise be reachable from. + + See LIFT_HTTP4S_MIGRATION.md § "Version enable/disable semantics" + for the contract; ResourceDocMiddlewareEnableDisableTest pins it. 05/03/2026 TBD BREAKING CHANGE: Removed allow_entitlements_or_scopes config flag. This global flag allowed consumer scopes as an alternative to user entitlements for ALL endpoints. It has been replaced by per-endpoint