diff --git a/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala b/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala index 21d367e2bb1..e501a012339 100644 --- a/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala +++ b/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala @@ -34,6 +34,7 @@ import org.apache.texera.service.resource.{ LiteLLMProxyResource } import org.eclipse.jetty.server.session.SessionHandler +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature import java.nio.file.Path class AccessControlService extends Application[AccessControlServiceConfiguration] with LazyLogging { @@ -78,6 +79,9 @@ class AccessControlService extends Application[AccessControlServiceConfiguration new io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser]) ) + // Enforce @RolesAllowed annotations on resource methods + environment.jersey.register(classOf[RolesAllowedDynamicFeature]) + // Record USER_LAST_ACTIVE_TIME on every matched, completed request. // Lives only in this service because authenticated client sessions // contact access-control-service often enough to capture activity diff --git a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala index 0cd52f49194..b8542662b81 100644 --- a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala +++ b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala @@ -20,6 +20,7 @@ package org.apache.texera.service.resource import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.typesafe.scalalogging.LazyLogging +import jakarta.annotation.security.{PermitAll, RolesAllowed} import jakarta.ws.rs.client.{Client, ClientBuilder, Entity} import jakarta.ws.rs.core._ import jakarta.ws.rs.{Consumes, GET, POST, Path, Produces} @@ -191,6 +192,7 @@ object AccessControlResource extends LazyLogging { } } @Produces(Array(MediaType.APPLICATION_JSON)) +@PermitAll @Path("/auth") class AccessControlResource extends LazyLogging { @@ -218,6 +220,7 @@ class AccessControlResource extends LazyLogging { @Path("/chat") @Produces(Array(MediaType.APPLICATION_JSON)) @Consumes(Array(MediaType.APPLICATION_JSON)) +@RolesAllowed(Array("REGULAR", "ADMIN")) class LiteLLMProxyResource extends LazyLogging { private val client: Client = ClientBuilder.newClient() @@ -289,6 +292,7 @@ class LiteLLMProxyResource extends LazyLogging { @Path("/models") @Produces(Array(MediaType.APPLICATION_JSON)) +@RolesAllowed(Array("REGULAR", "ADMIN")) class LiteLLMModelsResource extends LazyLogging { private val client: Client = ClientBuilder.newClient() diff --git a/access-control-service/src/test/scala/org/apache/texera/service/AccessControlServiceRunSpec.scala b/access-control-service/src/test/scala/org/apache/texera/service/AccessControlServiceRunSpec.scala index 89979ee816f..4b4d82f171f 100644 --- a/access-control-service/src/test/scala/org/apache/texera/service/AccessControlServiceRunSpec.scala +++ b/access-control-service/src/test/scala/org/apache/texera/service/AccessControlServiceRunSpec.scala @@ -24,6 +24,7 @@ import io.dropwizard.jersey.setup.JerseyEnvironment import io.dropwizard.jetty.MutableServletContextHandler import io.dropwizard.jetty.setup.ServletEnvironment import org.apache.texera.service.activity.UserActivityEventListener +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature import org.mockito.ArgumentMatchers.isA import org.mockito.Mockito.{mock, verify, when} import org.scalatest.flatspec.AnyFlatSpec @@ -31,7 +32,7 @@ import org.scalatest.matchers.should.Matchers class AccessControlServiceRunSpec extends AnyFlatSpec with Matchers { - "AccessControlService.run" should "register UserActivityEventListener on the Jersey environment" in { + private def runWithMockEnv(): JerseyEnvironment = { val jersey = mock(classOf[JerseyEnvironment]) val servlets = mock(classOf[ServletEnvironment]) val context = mock(classOf[MutableServletContextHandler]) @@ -40,10 +41,20 @@ class AccessControlServiceRunSpec extends AnyFlatSpec with Matchers { when(env.servlets).thenReturn(servlets) when(env.getApplicationContext).thenReturn(context) - val service = new AccessControlService - service.run(mock(classOf[AccessControlServiceConfiguration]), env) + new AccessControlService().run(mock(classOf[AccessControlServiceConfiguration]), env) + jersey + } + "AccessControlService.run" should "register UserActivityEventListener on the Jersey environment" in { + val jersey = runWithMockEnv() verify(jersey).register(isA(classOf[UserActivityEventListener])) verify(jersey).setUrlPattern("/api/*") } + + // Without RolesAllowedDynamicFeature registered, @RolesAllowed on LiteLLM + // resources and @PermitAll on AccessControlResource have no effect. + it should "register RolesAllowedDynamicFeature so role annotations are enforced" in { + val jersey = runWithMockEnv() + verify(jersey).register(classOf[RolesAllowedDynamicFeature]) + } } diff --git a/access-control-service/src/test/scala/org/apache/texera/service/resource/AccessControlResourcePermissionsSpec.scala b/access-control-service/src/test/scala/org/apache/texera/service/resource/AccessControlResourcePermissionsSpec.scala new file mode 100644 index 00000000000..e2f01cb6afb --- /dev/null +++ b/access-control-service/src/test/scala/org/apache/texera/service/resource/AccessControlResourcePermissionsSpec.scala @@ -0,0 +1,50 @@ +/* + * 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 jakarta.annotation.security.{PermitAll, RolesAllowed} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class AccessControlResourcePermissionsSpec extends AnyFlatSpec with Matchers { + + // /auth/* is the ExtAuth endpoint Envoy calls — it validates JWTs itself. + // Requiring a JWT-bearing role here would be circular and lock the gateway out. + "AccessControlResource" should "be @PermitAll because it IS the auth check" in { + val permit = classOf[AccessControlResource].getAnnotation(classOf[PermitAll]) + val roles = classOf[AccessControlResource].getAnnotation(classOf[RolesAllowed]) + permit should not be null + roles shouldBe null + } + + // /chat/* proxies copilot requests to LiteLLM; only logged-in users should burn LLM credits. + "LiteLLMProxyResource" should "require REGULAR or ADMIN role" in { + val roles = classOf[LiteLLMProxyResource].getAnnotation(classOf[RolesAllowed]) + roles should not be null + roles.value() should contain theSameElementsAs Array("REGULAR", "ADMIN") + } + + // /models is used by the copilot UI; same reasoning as the proxy. + "LiteLLMModelsResource" should "require REGULAR or ADMIN role" in { + val roles = classOf[LiteLLMModelsResource].getAnnotation(classOf[RolesAllowed]) + roles should not be null + roles.value() should contain theSameElementsAs Array("REGULAR", "ADMIN") + } +} diff --git a/amber/LICENSE-binary-java b/amber/LICENSE-binary-java index 86cdb8c8f4d..fba8dd9cd29 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/src/main/scala/org/apache/texera/web/resource/auth/AuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/AuthResource.scala index 0f99da681d5..ad2e253185a 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/auth/AuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/AuthResource.scala @@ -110,7 +110,7 @@ class AuthResource { val user = new User user.setName(username) user.setEmail(username) - user.setRole(UserRoleEnum.RESTRICTED) + user.setRole(UserRoleEnum.REGULAR) // hash the plain text password user.setPassword(new StrongPasswordEncryptor().encryptPassword(request.password)) userDao.insert(user) diff --git a/common/auth/build.sbt b/common/auth/build.sbt index a33da64fea5..742cf95eea2 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 56985156302..893fa3fa22f 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 @@ -28,6 +30,7 @@ import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum import java.security.Principal @Provider +@Priority(Priorities.AUTHENTICATION) class JwtAuthFilter extends ContainerRequestFilter with LazyLogging { override def filter(requestContext: ContainerRequestContext): Unit = { diff --git a/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala new file mode 100644 index 00000000000..f78c1451aad --- /dev/null +++ b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala @@ -0,0 +1,44 @@ +/* + * 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.auth + +import jakarta.annotation.Priority +import jakarta.ws.rs.Priorities +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class JwtAuthFilterSpec extends AnyFlatSpec with Matchers { + + // Regression guard: without an explicit @Priority(AUTHENTICATION) on this + // filter, Jersey defaults to Priorities.USER (5000), which runs AFTER + // RolesAllowedRequestFilter (AUTHORIZATION = 2000). The authz filter would + // then see no principal and 403 every authenticated request with + // "User not authorized." — even for ADMIN tokens. + "JwtAuthFilter" should "carry @Priority(Priorities.AUTHENTICATION) so it runs before authorization" in { + val priority = classOf[JwtAuthFilter].getAnnotation(classOf[Priority]) + priority should not be null + priority.value() shouldBe Priorities.AUTHENTICATION + } + + it should "run before Jersey's RolesAllowedRequestFilter priority" in { + val priority = classOf[JwtAuthFilter].getAnnotation(classOf[Priority]).value() + priority should be < Priorities.AUTHORIZATION + } +} 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 2614719040c..31e32104974 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} @@ -29,7 +29,7 @@ import org.apache.texera.config.{AuthConfig, ComputingUnitConfig, GuiConfig, Use class ConfigResource { @GET - @RolesAllowed(Array("REGULAR", "ADMIN")) + @PermitAll @Path("/gui") def getGuiConfig: Map[String, Any] = Map( @@ -64,7 +64,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/resource/ConfigResourceSpec.scala b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala new file mode 100644 index 00000000000..c0857199fc2 --- /dev/null +++ b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala @@ -0,0 +1,43 @@ +/* + * 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 jakarta.annotation.security.{PermitAll, RolesAllowed} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ConfigResourceSpec extends AnyFlatSpec with Matchers { + + // Both endpoints are called by Angular's APP_INITIALIZER before the user has + // a JWT (the GUI uses /config/gui to decide whether to show local-login or + // Google-login). They MUST be @PermitAll — tagging them @RolesAllowed makes + // the SPA's bootstrap fail with 403 and the whole site appears dead. + "ConfigResource.getGuiConfig" should "be @PermitAll so it loads before login" in { + val method = classOf[ConfigResource].getMethod("getGuiConfig") + method.getAnnotation(classOf[PermitAll]) should not be null + method.getAnnotation(classOf[RolesAllowed]) shouldBe null + } + + "ConfigResource.getUserSystemConfig" should "be @PermitAll so it loads before login" in { + val method = classOf[ConfigResource].getMethod("getUserSystemConfig") + method.getAnnotation(classOf[PermitAll]) should not be null + method.getAnnotation(classOf[RolesAllowed]) shouldBe null + } +} diff --git a/file-service/src/main/scala/org/apache/texera/service/FileService.scala b/file-service/src/main/scala/org/apache/texera/service/FileService.scala index cc4174682fe..8542a9383d6 100644 --- a/file-service/src/main/scala/org/apache/texera/service/FileService.scala +++ b/file-service/src/main/scala/org/apache/texera/service/FileService.scala @@ -40,6 +40,7 @@ import org.apache.texera.service.resource.{ import org.apache.texera.service.util.S3StorageClient import org.apache.texera.service.util.LargeBinaryManager import org.eclipse.jetty.server.session.SessionHandler +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature import java.nio.file.Path class FileService extends Application[FileServiceConfiguration] with LazyLogging { @@ -89,6 +90,9 @@ class FileService extends Application[FileServiceConfiguration] with LazyLogging new io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser]) ) + // Enforce @RolesAllowed annotations on resource methods + environment.jersey.register(classOf[RolesAllowedDynamicFeature]) + environment.jersey.register(classOf[DatasetResource]) environment.jersey.register(classOf[DatasetAccessResource]) diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index 46457c9454e..4ac9b81773c 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -20,7 +20,7 @@ package org.apache.texera.service.resource import io.dropwizard.auth.Auth -import jakarta.annotation.security.RolesAllowed +import jakarta.annotation.security.{PermitAll, RolesAllowed} import jakarta.ws.rs._ import jakarta.ws.rs.core._ import org.apache.texera.amber.config.StorageConfig @@ -653,6 +653,7 @@ class DatasetResource { } @GET + @PermitAll @Path("/public-presign-download") def getPublicPresignedUrl( @QueryParam("filePath") encodedUrl: String, @@ -663,6 +664,7 @@ class DatasetResource { } @GET + @PermitAll @Path("/public-presign-download-s3") def getPublicPresignedUrlWithS3( @QueryParam("filePath") encodedUrl: String, @@ -1151,6 +1153,7 @@ class DatasetResource { } @GET + @PermitAll @Path("/{name}/publicVersion/list") def getPublicDatasetVersionList( @PathParam("name") did: Integer @@ -1297,6 +1300,7 @@ class DatasetResource { } @GET + @PermitAll @Path("/{did}/publicVersion/{dvid}/rootFileNodes") def retrievePublicDatasetVersionRootFileNodes( @PathParam("did") did: Integer, @@ -1317,6 +1321,7 @@ class DatasetResource { } @GET + @PermitAll @Path("/public/{did}") def getPublicDataset( @PathParam("did") did: Integer @@ -2141,6 +2146,7 @@ class DatasetResource { * @return 307 Temporary Redirect to cover image */ @GET + @PermitAll @Path("/{did}/cover") def getDatasetCover( @PathParam("did") did: Integer, diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourcePermissionsSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourcePermissionsSpec.scala new file mode 100644 index 00000000000..4b16d391b12 --- /dev/null +++ b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourcePermissionsSpec.scala @@ -0,0 +1,51 @@ +/* + * 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 jakarta.annotation.security.PermitAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class DatasetResourcePermissionsSpec extends AnyFlatSpec with Matchers { + + // Once RolesAllowedDynamicFeature is registered in FileService, every method + // on DatasetResource is enforced. These endpoints serve unauthenticated hub + // visitors browsing public datasets — they must remain reachable without a JWT. + private val publicEndpointMethods = Seq( + "getPublicPresignedUrl", + "getPublicPresignedUrlWithS3", + "getPublicDatasetVersionList", + "retrievePublicDatasetVersionRootFileNodes", + "getPublicDataset", + "getDatasetCover" + ) + + publicEndpointMethods.foreach { methodName => + s"DatasetResource.$methodName" should "be @PermitAll so unauthenticated visitors can hit it" in { + val methods = classOf[DatasetResource].getMethods.filter(_.getName == methodName) + methods should not be empty + methods.foreach { m => + withClue(s"method $methodName missing @PermitAll: ") { + m.getAnnotation(classOf[PermitAll]) should not be null + } + } + } + } +}