This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5199-ab79ea44d0e6d12097fcb11674b092341af01761 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 2ebef323605b156bcaaeb9eb02454797059758a2 Author: Yicong Huang <[email protected]> AuthorDate: Sun May 31 16:47:20 2026 -0700 fix: enforce @RolesAllowed on microservice resources (#5199) ### What changes were proposed in this PR? Re-applies #5049 (Jersey `@RolesAllowed` enforcement on `config-service`, `computing-unit-managing-service`, and `workflow-compiling-service`) and additionally marks the two pre-login `ConfigResource` endpoints — `/api/config/gui` and `/api/config/user-system` — as `@PermitAll`. Those endpoints are loaded by `GuiConfigService.load()` in the Angular `APP_INITIALIZER` before any login, so once role enforcement is on they must keep returning `200` to unauthenticated callers; missing this was what broke bootstrap and got #5049 reverted in #5173. Everything outside `config-service` matches #5049 byte-for-byte. ### Any related issues, documentation, or discussions? Closes: #4904 Prior attempt: #5049, reverted by #5173. The bootstrap root cause was diagnosed inline at https://github.com/apache/texera/pull/5049#issuecomment-4527214062. ### How was this PR tested? Added `ConfigResourceAuthSpec`: wires `ConfigResource` through the same `JwtAuthFilter` + `RolesAllowedDynamicFeature` pipeline production uses (via Dropwizard's `ResourceExtension`) and fires HTTP requests with no `Authorization` header. - `GET /config/gui` → expects `200` - `GET /config/user-system` → expects `200` - `GET /auth-probe` (an in-test `@RolesAllowed` resource) → expects `403` Manually tested. The `403` sanity guard ensures the feature is actually enforcing, so a future "200 everywhere" regression cannot silently slip through. Kept the three `*ServiceRunSpec` structural tests from #5049 verifying that `RolesAllowedDynamicFeature` is registered. Manual reproduction with `curl` against a local dev server confirmed the unauthenticated bootstrap path returns `200` while a low-role JWT against an annotated endpoint returns `403`. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.7 in compliance with ASF. --------- Signed-off-by: Yicong Huang <[email protected]> Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]> Co-authored-by: Matthew B. <[email protected]> --- amber/LICENSE-binary-java | 1 + amber/NOTICE-binary | 43 ++++++++ build.sbt | 2 +- common/auth/build.sbt | 1 + .../org/apache/texera/auth/JwtAuthFilter.scala | 9 ++ computing-unit-managing-service/build.sbt | 7 ++ .../service/ComputingUnitManagingService.scala | 29 +++-- .../ComputingUnitManagingServiceRunSpec.scala | 48 +++++++++ .../org/apache/texera/service/ConfigService.scala | 26 +++-- .../texera/service/resource/ConfigResource.scala | 10 +- .../texera/service/ConfigServiceRunSpec.scala | 48 +++++++++ .../service/resource/ConfigResourceAuthSpec.scala | 117 +++++++++++++++++++++ workflow-compiling-service/LICENSE-binary | 3 + workflow-compiling-service/build.sbt | 1 + .../texera/service/WorkflowCompilingService.scala | 23 +++- .../service/WorkflowCompilingServiceRunSpec.scala | 48 +++++++++ 16 files changed, 393 insertions(+), 23 deletions(-) diff --git a/amber/LICENSE-binary-java b/amber/LICENSE-binary-java index 86cdb8c8f4..fba8dd9cd2 100644 --- a/amber/LICENSE-binary-java +++ b/amber/LICENSE-binary-java @@ -631,6 +631,7 @@ licensed with GPL-2.0 with Classpath Exception) -------------------------------------------------------------------------------- Scala/Java jars: + - jakarta.annotation.jakarta.annotation-api-2.1.1.jar - jakarta.ws.rs.jakarta.ws.rs-api-3.0.0.jar - javax.ws.rs.javax.ws.rs-api-2.1.1.jar - org.jgrapht.jgrapht-core-1.4.0.jar diff --git a/amber/NOTICE-binary b/amber/NOTICE-binary index d4408e53c3..5c8c0b0ae8 100644 --- a/amber/NOTICE-binary +++ b/amber/NOTICE-binary @@ -1483,6 +1483,49 @@ please check the country's laws, regulations and policies concerning the import, possession, or use, and re-export of encryption software, to see if this is permitted. +-------------------------------------------------------------------------------- +Jakarta Annotations API (jakarta.annotation-api 2.1.1) +-------------------------------------------------------------------------------- + +# Notices for Jakarta Annotations + +This content is produced and maintained by the Jakarta Annotations project. + + * Project home: https://projects.eclipse.org/projects/ee4j.ca + +## Trademarks + +Jakarta Annotations is a trademark of the Eclipse Foundation. + +## Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License v. 2.0 which is available at +http://www.eclipse.org/legal/epl-2.0. This Source Code may also be made +available under the following Secondary Licenses when the conditions for such +availability set forth in the Eclipse Public License v. 2.0 are satisfied: GNU +General Public License, version 2 with the GNU Classpath Exception which is +available at https://www.gnu.org/software/classpath/license.html. + +SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + +## Source Code + +The project maintains the following source code repositories: + + * https://github.com/eclipse-ee4j/common-annotations-api + +## Third-party Content + +## Cryptography + +Content may contain encryption software. The country in which you are currently +may have restrictions on the import, possession, and use, and/or re-export to +another country, of encryption software. BEFORE using any encryption software, +please check the country's laws, regulations and policies concerning the import, +possession, or use, and re-export of encryption software, to see if this is +permitted. + -------------------------------------------------------------------------------- Jakarta RESTful Web Services API (jakarta.ws.rs-api 3.0.x / 3.1.0) -------------------------------------------------------------------------------- diff --git a/build.sbt b/build.sbt index 3767e7a6c2..cfdd06864b 100644 --- a/build.sbt +++ b/build.sbt @@ -125,7 +125,7 @@ lazy val FileService = (project in file("file-service")) lazy val WorkflowOperator = (project in file("common/workflow-operator")).settings(asfLicensingSettingsWithVendored).dependsOn(WorkflowCore) lazy val WorkflowCompilingService = (project in file("workflow-compiling-service")) - .dependsOn(WorkflowOperator, Config) + .dependsOn(WorkflowOperator, Auth, Config) .settings(asfLicensingSettings) .settings( dependencyOverrides ++= Seq( diff --git a/common/auth/build.sbt b/common/auth/build.sbt index a33da64fea..742cf95eea 100644 --- a/common/auth/build.sbt +++ b/common/auth/build.sbt @@ -57,6 +57,7 @@ libraryDependencies ++= Seq( "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5", // for LazyLogging "org.bitbucket.b_c" % "jose4j" % "0.9.6", // for jwt parser "jakarta.ws.rs" % "jakarta.ws.rs-api" % "3.0.0", // for JwtAuthFilter + "jakarta.annotation" % "jakarta.annotation-api" % "2.1.1", // for @Priority on JwtAuthFilter "jakarta.servlet" % "jakarta.servlet-api" % "5.0.0" % "provided", // for RequestLoggingFilter "org.eclipse.jetty" % "jetty-servlet" % "11.0.24" % "provided", // for FilterHolder "org.scalatest" %% "scalatest" % "3.2.17" % Test diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala b/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala index 5698515630..cedc86573d 100644 --- a/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala +++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala @@ -20,6 +20,8 @@ package org.apache.texera.auth import com.typesafe.scalalogging.LazyLogging +import jakarta.annotation.Priority +import jakarta.ws.rs.Priorities import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter} import jakarta.ws.rs.core.{HttpHeaders, SecurityContext} import jakarta.ws.rs.ext.Provider @@ -27,7 +29,14 @@ import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum import java.security.Principal +// Must run before Jersey's RolesAllowedRequestFilter (which sits at +// Priorities.AUTHORIZATION = 2000). Without an explicit @Priority, this +// filter defaults to Priorities.USER (5000) and would run *after* the +// role check, so a request bearing a valid JWT would still be rejected +// because the SecurityContext hasn't been populated yet. Pinning to +// AUTHENTICATION (1000) restores the standard auth → authz ordering. @Provider +@Priority(Priorities.AUTHENTICATION) class JwtAuthFilter extends ContainerRequestFilter with LazyLogging { override def filter(requestContext: ContainerRequestContext): Unit = { diff --git a/computing-unit-managing-service/build.sbt b/computing-unit-managing-service/build.sbt index 3d385d33d3..1c39a6b03d 100644 --- a/computing-unit-managing-service/build.sbt +++ b/computing-unit-managing-service/build.sbt @@ -34,6 +34,13 @@ Universal / mappings := AddMetaInfLicenseFiles.distMappings( // Dependency Versions val dropwizardVersion = "4.0.7" +val mockitoVersion = "5.4.0" + +// Test Dependencies +libraryDependencies ++= Seq( + "org.scalatest" %% "scalatest" % "3.2.17" % Test, + "org.mockito" % "mockito-core" % mockitoVersion % Test +) // Dependencies libraryDependencies ++= Seq( diff --git a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala index a15ced30a2..6184cf545a 100644 --- a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala +++ b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala @@ -32,6 +32,7 @@ import org.apache.texera.service.resource.{ ComputingUnitManagingResource, HealthCheckResource } +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature import java.nio.file.Path class ComputingUnitManagingService extends Application[ComputingUnitManagingServiceConfiguration] { @@ -53,21 +54,16 @@ class ComputingUnitManagingService extends Application[ComputingUnitManagingServ configuration: ComputingUnitManagingServiceConfiguration, environment: Environment ): Unit = { - SqlServer.initConnection( - StorageConfig.jdbcUrl, - StorageConfig.jdbcUsername, - StorageConfig.jdbcPassword - ) // Register http resources environment.jersey.setUrlPattern("/api/*") environment.jersey.register(classOf[HealthCheckResource]) - // Register JWT authentication filter - environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) + ComputingUnitManagingService.registerAuthFeatures(environment) - // Enable @Auth annotation for injecting SessionUser - environment.jersey.register( - new io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser]) + SqlServer.initConnection( + StorageConfig.jdbcUrl, + StorageConfig.jdbcUsername, + StorageConfig.jdbcPassword ) environment.jersey().register(new ComputingUnitManagingResource) @@ -79,6 +75,19 @@ class ComputingUnitManagingService extends Application[ComputingUnitManagingServ } object ComputingUnitManagingService { + // Registers JWT auth, @Auth injection, and @RolesAllowed enforcement. + def registerAuthFeatures(environment: Environment): Unit = { + // Register JWT authentication filter + environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) + + // Enable @Auth annotation for injecting SessionUser + environment.jersey.register( + new io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser]) + ) + + // Enforce @RolesAllowed annotations on resource methods + environment.jersey.register(classOf[RolesAllowedDynamicFeature]) + } def main(args: Array[String]): Unit = { val configFilePath = Path diff --git a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala new file mode 100644 index 0000000000..d27f5725ac --- /dev/null +++ b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service + +import io.dropwizard.auth.{AuthDynamicFeature, AuthValueFactoryProvider} +import io.dropwizard.core.setup.Environment +import io.dropwizard.jersey.setup.JerseyEnvironment +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature +import org.mockito.Mockito.{mock, verify, when} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ComputingUnitManagingServiceRunSpec extends AnyFlatSpec with Matchers { + + // Verifies that the @RolesAllowed annotations on resource methods are actually + // enforced by Jersey, which requires RolesAllowedDynamicFeature, AuthDynamicFeature, + // and AuthValueFactoryProvider.Binder to be registered on the Jersey environment. + "ComputingUnitManagingService.registerAuthFeatures" should "register auth + RolesAllowedDynamicFeature on the Jersey environment" in { + val jersey = mock(classOf[JerseyEnvironment]) + val env = mock(classOf[Environment]) + when(env.jersey).thenReturn(jersey) + + ComputingUnitManagingService.registerAuthFeatures(env) + + verify(jersey).register(classOf[RolesAllowedDynamicFeature]) + verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature])) + verify(jersey).register( + org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]]) + ) + } +} diff --git a/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala b/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala index c787016c27..5b2712f26e 100644 --- a/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala +++ b/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala @@ -31,6 +31,7 @@ import org.apache.texera.config.DefaultsConfig import org.apache.texera.dao.SqlServer import org.apache.texera.service.resource.{ConfigResource, HealthCheckResource} import org.eclipse.jetty.server.session.SessionHandler +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature import org.jooq.impl.DSL import java.nio.file.Path @@ -63,13 +64,7 @@ class ConfigService extends Application[ConfigServiceConfiguration] with LazyLog environment.jersey.register(classOf[HealthCheckResource]) - // Register JWT authentication filter - environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) - - // Enable @Auth annotation for injecting SessionUser - environment.jersey.register( - new io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser]) - ) + ConfigService.registerAuthFeatures(environment) environment.jersey.register(new ConfigResource) @@ -109,6 +104,23 @@ class ConfigService extends Application[ConfigServiceConfiguration] with LazyLog } object ConfigService { + // Registers JWT auth, @Auth injection, and @RolesAllowed enforcement. + // Mirrors ComputingUnitManagingService.registerAuthFeatures and + // WorkflowCompilingService.registerAuthFeatures so the three services + // don't drift apart. + def registerAuthFeatures(environment: Environment): Unit = { + // Register JWT authentication filter + environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) + + // Enable @Auth annotation for injecting SessionUser + environment.jersey.register( + new io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser]) + ) + + // Enforce @RolesAllowed annotations on resource methods + environment.jersey.register(classOf[RolesAllowedDynamicFeature]) + } + def main(args: Array[String]): Unit = { val configFilePath = Path .of(sys.env.getOrElse("TEXERA_HOME", ".")) diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala index 2614719040..ace8e8618d 100644 --- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala +++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala @@ -19,7 +19,7 @@ package org.apache.texera.service.resource -import jakarta.annotation.security.RolesAllowed +import jakarta.annotation.security.PermitAll import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.{GET, Path, Produces} import org.apache.texera.config.{AuthConfig, ComputingUnitConfig, GuiConfig, UserSystemConfig} @@ -28,8 +28,12 @@ import org.apache.texera.config.{AuthConfig, ComputingUnitConfig, GuiConfig, Use @Produces(Array(MediaType.APPLICATION_JSON)) class ConfigResource { + // These two endpoints are fetched by the frontend during app initialization, + // before any login, so they must answer unauthenticated callers — hence @PermitAll. + // They are the only endpoints in this resource, so role enforcement gates nothing + // here; @PermitAll is what keeps them reachable when role enforcement is enabled. @GET - @RolesAllowed(Array("REGULAR", "ADMIN")) + @PermitAll @Path("/gui") def getGuiConfig: Map[String, Any] = Map( @@ -64,7 +68,7 @@ class ConfigResource { ) @GET - @RolesAllowed(Array("REGULAR", "ADMIN")) + @PermitAll @Path("/user-system") def getUserSystemConfig: Map[String, Any] = Map( diff --git a/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala b/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala new file mode 100644 index 0000000000..5bbf1ff007 --- /dev/null +++ b/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service + +import io.dropwizard.auth.{AuthDynamicFeature, AuthValueFactoryProvider} +import io.dropwizard.core.setup.Environment +import io.dropwizard.jersey.setup.JerseyEnvironment +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature +import org.mockito.Mockito.{mock, verify, when} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ConfigServiceRunSpec extends AnyFlatSpec with Matchers { + + // ConfigResource's own endpoints are @PermitAll, but the service still registers + // RolesAllowedDynamicFeature so that any @RolesAllowed endpoint is enforced by + // Jersey. This verifies the helper actually runs the three registrations. + "ConfigService.registerAuthFeatures" should "register auth + RolesAllowedDynamicFeature on the Jersey environment" in { + val jersey = mock(classOf[JerseyEnvironment]) + val env = mock(classOf[Environment]) + when(env.jersey).thenReturn(jersey) + + ConfigService.registerAuthFeatures(env) + + verify(jersey).register(classOf[RolesAllowedDynamicFeature]) + verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature])) + verify(jersey).register( + org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]]) + ) + } +} diff --git a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala new file mode 100644 index 0000000000..56cde8c263 --- /dev/null +++ b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import io.dropwizard.jackson.Jackson +import io.dropwizard.testing.junit5.ResourceExtension +import jakarta.annotation.security.RolesAllowed +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.{GET, Path, Produces} +import org.apache.texera.auth.{JwtAuth, JwtAuthFilter} +import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum +import org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +// Wires ConfigResource through the same Jersey auth pipeline production uses +// (JwtAuthFilter + RolesAllowedDynamicFeature) and fires HTTP requests with no +// Authorization header. Regression guard for the bootstrap break that caused +// PR #5049 to be reverted in #5173: /config/gui and /config/user-system are +// loaded by the frontend's APP_INITIALIZER before any login, so they must +// return 200 to unauthenticated callers even with role enforcement enabled. +class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll { + + // Mirror production's mapper: ConfigService bootstraps Dropwizard's default mapper + // (Jackson.newObjectMapper) and registers DefaultScalaModule on top. Same call here. + private val testMapper: ObjectMapper = + Jackson.newObjectMapper().registerModule(DefaultScalaModule) + + private val resources: ResourceExtension = ResourceExtension + .builder() + .setMapper(testMapper) + .addProvider(classOf[JwtAuthFilter]) + .addProvider(classOf[RolesAllowedDynamicFeature]) + .addResource(new ConfigResource) + .addResource(new ConfigResourceAuthSpec.ProtectedProbe) + .build() + + override protected def beforeAll(): Unit = resources.before() + override protected def afterAll(): Unit = resources.after() + + "GET /config/gui" should "return 200 without an Authorization header" in { + val response = resources.target("/config/gui").request(MediaType.APPLICATION_JSON).get() + response.getStatus shouldBe 200 + } + + "GET /config/user-system" should "return 200 without an Authorization header" in { + val response = + resources.target("/config/user-system").request(MediaType.APPLICATION_JSON).get() + response.getStatus shouldBe 200 + } + + "GET an @RolesAllowed endpoint" should "return 403 without an Authorization header" in { + // Sanity: with no SecurityContext set by JwtAuthFilter, RolesAllowedDynamicFeature + // must reject. Catches the case where the feature is registered but somehow + // disabled (e.g. swallowed exception during setup). + val response = + resources.target("/auth-probe").request(MediaType.APPLICATION_JSON).get() + response.getStatus shouldBe 403 + } + + it should "return 200 with a valid Bearer token whose role matches @RolesAllowed" in { + // Positive-direction sibling to the previous test. Without this, a filter- + // priority bug that lets RolesAllowedRequestFilter run *before* JwtAuthFilter + // is invisible to the spec: the no-auth case still 403s, the @PermitAll cases + // still 200, and the only path that actually exercises auth → authz ordering + // is "valid JWT → 200". Manual integration testing of PR #5199 found this: + // a real admin JWT was getting 403 on every @RolesAllowed endpoint until + // JwtAuthFilter was pinned to Priorities.AUTHENTICATION. + val u = new User() + u.setUid(1) + u.setName("test-admin") + u.setEmail("[email protected]") + u.setGoogleId(null) + u.setRole(UserRoleEnum.ADMIN) + val token = JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1)) + val response = resources + .target("/auth-probe") + .request(MediaType.APPLICATION_JSON) + .header("Authorization", s"Bearer $token") + .get() + response.getStatus shouldBe 200 + } +} + +object ConfigResourceAuthSpec { + // A deliberately @RolesAllowed companion to ConfigResource, so the same setup also + // proves the feature actually rejects when it should — a 200 on the @PermitAll + // endpoints would otherwise be consistent with the feature being silently no-op'd. + @Path("/auth-probe") + @Produces(Array(MediaType.APPLICATION_JSON)) + class ProtectedProbe { + @GET + @RolesAllowed(Array("REGULAR", "ADMIN")) + def probe: String = "should never reach this" + } +} diff --git a/workflow-compiling-service/LICENSE-binary b/workflow-compiling-service/LICENSE-binary index 5b7548a4ed..ed6a9e1d26 100644 --- a/workflow-compiling-service/LICENSE-binary +++ b/workflow-compiling-service/LICENSE-binary @@ -281,6 +281,7 @@ Scala/Java jars: - commons-pool.commons-pool-1.6.jar - dev.failsafe.failsafe-3.3.2.jar - io.airlift.aircompressor-0.27.jar + - io.dropwizard.dropwizard-auth-4.0.7.jar - io.dropwizard.dropwizard-configuration-4.0.7.jar - io.dropwizard.dropwizard-core-4.0.7.jar - io.dropwizard.dropwizard-health-4.0.7.jar @@ -296,6 +297,7 @@ Scala/Java jars: - io.dropwizard.dropwizard-validation-4.0.7.jar - io.dropwizard.logback.logback-throttling-appender-1.4.2.jar - io.dropwizard.metrics.metrics-annotation-4.2.25.jar + - io.dropwizard.metrics.metrics-caffeine-4.2.25.jar - io.dropwizard.metrics.metrics-core-4.2.25.jar - io.dropwizard.metrics.metrics-healthchecks-4.2.25.jar - io.dropwizard.metrics.metrics-jakarta-servlets-4.2.25.jar @@ -419,6 +421,7 @@ Scala/Java jars: - org.apache.yetus.audience-annotations-0.13.0.jar - org.apache.zookeeper.zookeeper-3.5.6.jar - org.apache.zookeeper.zookeeper-jute-3.5.6.jar + - org.bitbucket.b_c.jose4j-0.9.6.jar - org.eclipse.jetty.jetty-http-11.0.20.jar - org.eclipse.jetty.jetty-io-11.0.20.jar - org.eclipse.jetty.jetty-security-11.0.20.jar diff --git a/workflow-compiling-service/build.sbt b/workflow-compiling-service/build.sbt index 9560751d00..95a6926992 100644 --- a/workflow-compiling-service/build.sbt +++ b/workflow-compiling-service/build.sbt @@ -84,5 +84,6 @@ libraryDependencies ++= Seq( // Core Dependencies libraryDependencies ++= Seq( "io.dropwizard" % "dropwizard-core" % dropwizardVersion, + "io.dropwizard" % "dropwizard-auth" % dropwizardVersion, // Dropwizard Authentication module "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.18.6" ) diff --git a/workflow-compiling-service/src/main/scala/org/apache/texera/service/WorkflowCompilingService.scala b/workflow-compiling-service/src/main/scala/org/apache/texera/service/WorkflowCompilingService.scala index 40fb3a2dd8..8dc573aaf8 100644 --- a/workflow-compiling-service/src/main/scala/org/apache/texera/service/WorkflowCompilingService.scala +++ b/workflow-compiling-service/src/main/scala/org/apache/texera/service/WorkflowCompilingService.scala @@ -20,14 +20,17 @@ package org.apache.texera.service import com.fasterxml.jackson.module.scala.DefaultScalaModule +import io.dropwizard.auth.AuthDynamicFeature import io.dropwizard.configuration.{EnvironmentVariableSubstitutor, SubstitutingSourceProvider} import io.dropwizard.core.Application import io.dropwizard.core.setup.{Bootstrap, Environment} import org.apache.texera.amber.config.StorageConfig import org.apache.texera.amber.util.ObjectMapperUtils +import org.apache.texera.auth.{JwtAuthFilter, SessionUser} import org.apache.texera.dao.SqlServer import org.apache.texera.service.resource.{HealthCheckResource, WorkflowCompilationResource} import org.eclipse.jetty.servlet.FilterHolder +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature import java.nio.file.Path @@ -53,14 +56,16 @@ class WorkflowCompilingService extends Application[WorkflowCompilingServiceConfi // serve backend at /api environment.jersey.setUrlPattern("/api/*") + environment.jersey.register(classOf[HealthCheckResource]) + + WorkflowCompilingService.registerAuthFeatures(environment) + SqlServer.initConnection( StorageConfig.jdbcUrl, StorageConfig.jdbcUsername, StorageConfig.jdbcPassword ) - environment.jersey.register(classOf[HealthCheckResource]) - // register the compilation endpoint environment.jersey.register(classOf[WorkflowCompilationResource]) @@ -90,6 +95,20 @@ class WorkflowCompilingService extends Application[WorkflowCompilingServiceConfi } object WorkflowCompilingService { + // Registers JWT auth, @Auth injection, and @RolesAllowed enforcement. + def registerAuthFeatures(environment: Environment): Unit = { + // Register JWT authentication filter + environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) + + // Enable @Auth annotation for injecting SessionUser + environment.jersey.register( + new io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser]) + ) + + // Enforce @RolesAllowed annotations on resource methods + environment.jersey.register(classOf[RolesAllowedDynamicFeature]) + } + def main(args: Array[String]): Unit = { // set the configuration file's path val configFilePath = Path diff --git a/workflow-compiling-service/src/test/scala/org/apache/texera/service/WorkflowCompilingServiceRunSpec.scala b/workflow-compiling-service/src/test/scala/org/apache/texera/service/WorkflowCompilingServiceRunSpec.scala new file mode 100644 index 0000000000..ff5da1b561 --- /dev/null +++ b/workflow-compiling-service/src/test/scala/org/apache/texera/service/WorkflowCompilingServiceRunSpec.scala @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service + +import io.dropwizard.auth.{AuthDynamicFeature, AuthValueFactoryProvider} +import io.dropwizard.core.setup.Environment +import io.dropwizard.jersey.setup.JerseyEnvironment +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature +import org.mockito.Mockito.{mock, verify, when} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class WorkflowCompilingServiceRunSpec extends AnyFlatSpec with Matchers { + + // Verifies that the @RolesAllowed annotations on resource methods are actually + // enforced by Jersey, which requires RolesAllowedDynamicFeature, AuthDynamicFeature, + // and AuthValueFactoryProvider.Binder to be registered on the Jersey environment. + "WorkflowCompilingService.registerAuthFeatures" should "register auth + RolesAllowedDynamicFeature on the Jersey environment" in { + val jersey = mock(classOf[JerseyEnvironment]) + val env = mock(classOf[Environment]) + when(env.jersey).thenReturn(jersey) + + WorkflowCompilingService.registerAuthFeatures(env) + + verify(jersey).register(classOf[RolesAllowedDynamicFeature]) + verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature])) + verify(jersey).register( + org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]]) + ) + } +}
