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-5743-4a93b70f0d8af0220557e42db8e4c19ce2b21e7e in repository https://gitbox.apache.org/repos/asf/texera.git
commit 71fce70c3d8e58e8247a85e2927260fa0d5e2dcd Author: Matthew B. <[email protected]> AuthorDate: Wed Jun 24 14:40:14 2026 -0700 feat(auth): enforce role annotation coverage at service startup (#5743) ### What changes were proposed in this PR? - Add `RoleAnnotationEnforcer` in `common/auth`: a pure, reflection-based check whose `findUnannotatedEndpoints` flags every HTTP-mapped resource method (detected generically via the JAX-RS `@HttpMethod` meta-annotation, covering GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS and custom verbs) that lacks `@RolesAllowed`/`@PermitAll`/`@DenyAll` at either the method or its resource class. - Wire all five #5199 microservices (config, access-control, computing-unit-managing, workflow-compiling, file) to call `enforceRoleAnnotations(environment)` once every resource has been registered in `run()`, reading the live Jersey `ResourceConfig`; an unannotated endpoint now logs and throws `IllegalStateException` so the service fails to boot instead of exposing a silent public endpoint. - Add `RoleAnnotationEnforcerSpec` for the enforcer logic plus a per-service guard test (over each service's real registered resources) so a forgotten annotation fails in CI without needing a full boot. - Scope is the five microservices only; amber is intentionally left out because its pre-existing endpoints are not all annotated yet and would fail the check (a separate cleanup). ### Any related issues, documentation, discussions? Closes: #5742 ### How was this PR tested? - Run `sbt "Auth/testOnly *RoleAnnotationEnforcerSpec"`, expect 16 cases green. Coverage of `findUnannotatedEndpoints`: all-annotated passes; an unannotated `@GET` is flagged; class-level annotation covers methods; `@PermitAll`/`@DenyAll` accepted; non-HTTP methods ignored; no resources returns empty; holes across multiple resources reported as fully-qualified `Class#method`; verbs beyond GET/POST/DELETE (`@PUT`/`@PATCH`/`@HEAD`/`@OPTIONS`) detected via the `@HttpMethod` meta-annotation; a security annotation inherited from a superclass method counts as covered; a subclass class-level annotation covers an inherited endpoint; an inherited unannotated endpoint is flagged against the scanned subclass; duplicate resources are de-duplicated. Coverage of `enforce`: throws (message names the service and offending method, and lists every offender) and does not throw on clean input or empty input. Verified locally. - Run `sbt "ConfigService/testOnly *ConfigServiceRunSpec"` (and the equivalent `AccessControlService`/`ComputingUnitManagingService`/`WorkflowCompilingService`/`FileService` RunSpecs); expect the new "registered resources should all declare access control" guard to pass, confirming each service's real endpoints are fully annotated. - Reviewer check for the regression: drop a new `@GET` with no role annotation onto any wired resource and start the service (or run that service's RunSpec); expect an `IllegalStateException` naming `Class#method` and a failed boot. - Local environment limitation: this machine runs JDK 25, where the repo's existing Mockito `*RunSpec` cases cannot mock `JerseyEnvironment` and file-service tests hit a JaCoCo 0.8.11 instrumentation crash on an unrelated class; these are pre-existing toolchain issues (baseline fails identically) and run on CI's supported JDK. The new non-mock guard tests were verified locally for config/access-control/computing-unit-managing/workflow-compiling. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.8 in compliance with ASF --------- Signed-off-by: Matthew B. <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../texera/service/AccessControlService.scala | 23 +- .../service/AccessControlServiceRunSpec.scala | 22 +- common/auth/build.sbt | 6 +- .../org/apache/texera/auth/AuthFeatures.scala | 46 ++++ .../texera/auth/RoleAnnotationEnforcer.scala | 87 +++++++ .../org/apache/texera/auth/AuthFeaturesSpec.scala | 16 +- .../texera/auth/RoleAnnotationEnforcerSpec.scala | 251 +++++++++++++++++++++ .../service/ComputingUnitManagingService.scala | 31 +-- .../ComputingUnitManagingServiceRunSpec.scala | 37 ++- .../org/apache/texera/service/ConfigService.scala | 31 +-- .../texera/service/ConfigServiceRunSpec.scala | 29 +-- .../org/apache/texera/service/FileService.scala | 23 +- .../apache/texera/service/FileServiceRunSpec.scala | 43 ++++ .../texera/service/WorkflowCompilingService.scala | 26 +-- .../service/WorkflowCompilingServiceRunSpec.scala | 29 +-- 15 files changed, 516 insertions(+), 184 deletions(-) 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 f01d06f941..1f50c86c9f 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 @@ -19,17 +19,11 @@ package org.apache.texera.service import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.typesafe.scalalogging.LazyLogging -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.common.config.StorageConfig -import org.apache.texera.auth.{ - JwtAuthFilter, - RequestLoggingFilter, - SessionUser, - UnauthorizedExceptionMapper -} +import org.apache.texera.auth.{AuthFeatures, RequestLoggingFilter, RoleAnnotationEnforcer} import org.apache.texera.dao.SqlServer import org.apache.texera.service.activity.UserActivityEventListener import org.apache.texera.service.resource.{ @@ -39,7 +33,6 @@ 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 { @@ -76,17 +69,7 @@ class AccessControlService extends Application[AccessControlServiceConfiguration environment.jersey.register(classOf[LiteLLMProxyResource]) environment.jersey.register(classOf[LiteLLMModelsResource]) - // Register JWT authentication filter - environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) - environment.jersey.register(classOf[UnauthorizedExceptionMapper]) - - // Enable @Auth annotation for injecting SessionUser - environment.jersey.register( - new io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser]) - ) - - // Required for @RolesAllowed on resources to be enforced. - environment.jersey.register(classOf[RolesAllowedDynamicFeature]) + AuthFeatures.register(environment) // Record USER_LAST_ACTIVE_TIME on every matched, completed request. // Lives only in this service because authenticated client sessions @@ -94,6 +77,8 @@ class AccessControlService extends Application[AccessControlServiceConfiguration // with high recall. environment.jersey.register(new UserActivityEventListener()) + RoleAnnotationEnforcer.enforce(environment.jersey.getResourceConfig, "AccessControlService") + // Route request logs through SLF4J, controlled by TEXERA_SERVICE_LOG_LEVEL RequestLoggingFilter.register(environment.getApplicationContext) } 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 2460d18b45..04443ab9c6 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 @@ -20,11 +20,18 @@ package org.apache.texera.service import io.dropwizard.core.setup.Environment +import io.dropwizard.jersey.DropwizardResourceConfig import io.dropwizard.jersey.setup.JerseyEnvironment import io.dropwizard.jetty.MutableServletContextHandler import io.dropwizard.jetty.setup.ServletEnvironment -import org.apache.texera.auth.UnauthorizedExceptionMapper +import org.apache.texera.auth.{RoleAnnotationEnforcer, UnauthorizedExceptionMapper} import org.apache.texera.service.activity.UserActivityEventListener +import org.apache.texera.service.resource.{ + AccessControlResource, + HealthCheckResource, + LiteLLMModelsResource, + LiteLLMProxyResource +} import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature import org.mockito.ArgumentMatchers.isA import org.mockito.Mockito.{mock, verify, when} @@ -41,6 +48,7 @@ class AccessControlServiceRunSpec extends AnyFlatSpec with Matchers { when(env.jersey).thenReturn(jersey) when(env.servlets).thenReturn(servlets) when(env.getApplicationContext).thenReturn(context) + when(jersey.getResourceConfig).thenReturn(DropwizardResourceConfig.forTesting()) val service = new AccessControlService service.run(mock(classOf[AccessControlServiceConfiguration]), env) @@ -51,4 +59,16 @@ class AccessControlServiceRunSpec extends AnyFlatSpec with Matchers { verify(jersey).register(classOf[RolesAllowedDynamicFeature]) verify(jersey).setUrlPattern("/api/*") } + + // Every endpoint this service registers declares @RolesAllowed/@PermitAll/@DenyAll. + "AccessControlService's registered resources" should "all declare access control" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq( + classOf[AccessControlResource], + classOf[LiteLLMProxyResource], + classOf[LiteLLMModelsResource], + classOf[HealthCheckResource] + ) + ) shouldBe empty + } } diff --git a/common/auth/build.sbt b/common/auth/build.sbt index 742cf95eea..4f325bf77e 100644 --- a/common/auth/build.sbt +++ b/common/auth/build.sbt @@ -60,5 +60,9 @@ libraryDependencies ++= Seq( "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 + "org.glassfish.jersey.core" % "jersey-server" % "3.0.12" % "provided", // for RoleAnnotationEnforcer's ResourceConfig overload and AuthFeatures' RolesAllowedDynamicFeature + "io.dropwizard" % "dropwizard-core" % "4.0.7" % "provided", // for AuthFeatures' Environment + "io.dropwizard" % "dropwizard-auth" % "4.0.7" % "provided", // for AuthFeatures' AuthDynamicFeature/AuthValueFactoryProvider + "org.scalatest" %% "scalatest" % "3.2.17" % Test, + "org.mockito" % "mockito-core" % "5.4.0" % Test // for mocking the Jersey environment in AuthFeaturesSpec ) \ No newline at end of file diff --git a/common/auth/src/main/scala/org/apache/texera/auth/AuthFeatures.scala b/common/auth/src/main/scala/org/apache/texera/auth/AuthFeatures.scala new file mode 100644 index 0000000000..ab5d8b7cde --- /dev/null +++ b/common/auth/src/main/scala/org/apache/texera/auth/AuthFeatures.scala @@ -0,0 +1,46 @@ +/* + * 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 io.dropwizard.auth.{AuthDynamicFeature, AuthValueFactoryProvider} +import io.dropwizard.core.setup.Environment +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature + +/** Registers the standard Texera auth stack on a Dropwizard service: JWT + * authentication, `@Auth` SessionUser injection, and `@RolesAllowed` + * enforcement. Shared by every service so the registrations don't drift apart. + */ +object AuthFeatures { + + /** Register JWT auth, the `@Auth` value factory, and the `@RolesAllowed` + * dynamic feature on `environment`'s Jersey config. + */ + def register(environment: Environment): Unit = { + // Register JWT authentication filter + environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) + environment.jersey.register(classOf[UnauthorizedExceptionMapper]) + + // Enable @Auth annotation for injecting SessionUser + environment.jersey.register(new AuthValueFactoryProvider.Binder(classOf[SessionUser])) + + // Enforce @RolesAllowed annotations on resource methods + environment.jersey.register(classOf[RolesAllowedDynamicFeature]) + } +} diff --git a/common/auth/src/main/scala/org/apache/texera/auth/RoleAnnotationEnforcer.scala b/common/auth/src/main/scala/org/apache/texera/auth/RoleAnnotationEnforcer.scala new file mode 100644 index 0000000000..d99a03ee12 --- /dev/null +++ b/common/auth/src/main/scala/org/apache/texera/auth/RoleAnnotationEnforcer.scala @@ -0,0 +1,87 @@ +/* + * 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 com.typesafe.scalalogging.LazyLogging +import jakarta.annotation.security.{DenyAll, PermitAll, RolesAllowed} +import jakarta.ws.rs.HttpMethod +import org.glassfish.jersey.server.ResourceConfig + +import java.lang.reflect.Method +import scala.jdk.CollectionConverters._ + +/** Scans Jersey resource classes and fails if any HTTP-mapped method lacks an + * @RolesAllowed/@PermitAll/@DenyAll annotation at the method or class level. + */ +object RoleAnnotationEnforcer extends LazyLogging { + + private val securityAnnotations: Seq[Class[_ <: java.lang.annotation.Annotation]] = + Seq(classOf[RolesAllowed], classOf[PermitAll], classOf[DenyAll]) + + /** Enforce over every resource registered on `resourceConfig`, both + * `getClasses` and singleton `getInstances`. + */ + def enforce(resourceConfig: ResourceConfig, serviceName: String): Unit = + enforce( + resourceConfig.getClasses.asScala.toSet ++ + resourceConfig.getInstances.asScala.map(_.getClass), + serviceName + ) + + /** Scans `resourceClasses` and throws if any HTTP-mapped method is missing an + * access-control annotation, after logging the offending methods. + */ + def enforce(resourceClasses: Iterable[Class[_]], serviceName: String): Unit = { + val violations = findUnannotatedEndpoints(resourceClasses) + if (violations.nonEmpty) { + val message = + s"$serviceName has HTTP endpoint(s) without an @RolesAllowed/@PermitAll/@DenyAll " + + s"annotation; every endpoint must declare its access control explicitly:\n " + + violations.mkString("\n ") + logger.error(message) + throw new IllegalStateException(message) + } + } + + /** Returns `Class#method` identifiers for every HTTP-mapped method that lacks + * a security annotation at either the method or its declaring resource class. + */ + def findUnannotatedEndpoints(resourceClasses: Iterable[Class[_]]): Seq[String] = + resourceClasses.toSeq.flatMap { resourceClass => + val classSecured = hasSecurityAnnotation(resourceClass) + resourceClass.getMethods.toSeq + .filter(isHttpMethod) + .filterNot(method => classSecured || hasSecurityAnnotation(method)) + .map(method => s"${resourceClass.getName}#${method.getName}") + }.distinct + + /** A method is HTTP-mapped if one of its annotations is itself meta-annotated + * with `@HttpMethod` (covers `@GET`/`@POST`/`@PUT`/`@DELETE`/`@PATCH`/ + * `@HEAD`/`@OPTIONS` and any custom verb). + */ + private def isHttpMethod(method: Method): Boolean = + method.getAnnotations.exists(_.annotationType.isAnnotationPresent(classOf[HttpMethod])) + + private def hasSecurityAnnotation(method: Method): Boolean = + securityAnnotations.exists(method.isAnnotationPresent) + + private def hasSecurityAnnotation(clazz: Class[_]): Boolean = + securityAnnotations.exists(clazz.isAnnotationPresent) +} diff --git a/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala b/common/auth/src/test/scala/org/apache/texera/auth/AuthFeaturesSpec.scala similarity index 74% copy from config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala copy to common/auth/src/test/scala/org/apache/texera/auth/AuthFeaturesSpec.scala index 388a48136a..a5c3c11a44 100644 --- a/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala +++ b/common/auth/src/test/scala/org/apache/texera/auth/AuthFeaturesSpec.scala @@ -17,28 +17,28 @@ * under the License. */ -package org.apache.texera.service +package org.apache.texera.auth import io.dropwizard.auth.{AuthDynamicFeature, AuthValueFactoryProvider} import io.dropwizard.core.setup.Environment import io.dropwizard.jersey.setup.JerseyEnvironment -import org.apache.texera.auth.UnauthorizedExceptionMapper 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 { +class AuthFeaturesSpec 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 { + // Enforcing @RolesAllowed on resource methods requires RolesAllowedDynamicFeature, + // AuthDynamicFeature, the @Auth value factory, and the UnauthorizedExceptionMapper + // to be registered on the Jersey environment. Every service shares this helper, so + // the registrations are verified once here rather than per service. + "AuthFeatures.register" 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) + AuthFeatures.register(env) verify(jersey).register(classOf[RolesAllowedDynamicFeature]) verify(jersey).register(classOf[UnauthorizedExceptionMapper]) diff --git a/common/auth/src/test/scala/org/apache/texera/auth/RoleAnnotationEnforcerSpec.scala b/common/auth/src/test/scala/org/apache/texera/auth/RoleAnnotationEnforcerSpec.scala new file mode 100644 index 0000000000..7513646fe3 --- /dev/null +++ b/common/auth/src/test/scala/org/apache/texera/auth/RoleAnnotationEnforcerSpec.scala @@ -0,0 +1,251 @@ +/* + * 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 ch.qos.logback.classic.{Level, Logger => LogbackLogger} +import jakarta.annotation.security.{DenyAll, PermitAll, RolesAllowed} +import jakarta.ws.rs.{DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT} +import org.glassfish.jersey.server.ResourceConfig +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.slf4j.LoggerFactory + +class RoleAnnotationEnforcerSpec extends AnyFlatSpec with Matchers { + + "findUnannotatedEndpoints" should "return nothing when every HTTP method is annotated" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.FullyAnnotatedResource]) + ) shouldBe empty + } + + it should "flag an HTTP method with no security annotation" in { + val violations = + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.PartiallyAnnotatedResource]) + ) + violations should have size 1 + violations.head should endWith("#openEndpoint") + } + + it should "treat a class-level annotation as covering every method" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.ClassLevelResource]) + ) shouldBe empty + } + + it should "accept @PermitAll and @DenyAll, not only @RolesAllowed" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.PermitAndDenyResource]) + ) shouldBe empty + } + + it should "ignore methods that are not HTTP-mapped" in { + // helper has no @RolesAllowed but is not a JAX-RS endpoint, so it is not a hole + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.NonEndpointMethodResource]) + ) shouldBe empty + } + + it should "return nothing when given no resources" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints(Seq.empty) shouldBe empty + } + + it should "report every hole across multiple resources as fully-qualified Class#method" in { + val violations = RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq( + classOf[RoleAnnotationEnforcerSpec.PartiallyAnnotatedResource], + classOf[RoleAnnotationEnforcerSpec.MultiHoleResource] + ) + ) + violations should contain allOf ( + s"${classOf[RoleAnnotationEnforcerSpec.PartiallyAnnotatedResource].getName}#openEndpoint", + s"${classOf[RoleAnnotationEnforcerSpec.MultiHoleResource].getName}#put", + s"${classOf[RoleAnnotationEnforcerSpec.MultiHoleResource].getName}#patch" + ) + } + + it should "detect verbs beyond GET/POST/DELETE via the @HttpMethod meta-annotation" in { + val violations = RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.AllVerbsUnannotatedResource]) + ) + violations.map(_.split("#").last) should contain theSameElementsAs + Seq("put", "patch", "head", "options") + } + + it should "treat a security annotation inherited from a superclass method as covering it" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.InheritsAnnotatedEndpoint]) + ) shouldBe empty + } + + it should "let a subclass class-level annotation cover an inherited unannotated endpoint" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.SecuredSubclass]) + ) shouldBe empty + } + + it should "flag an inherited unannotated endpoint against the scanned subclass" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[RoleAnnotationEnforcerSpec.UnsecuredSubclass]) + ) should contain( + s"${classOf[RoleAnnotationEnforcerSpec.UnsecuredSubclass].getName}#inheritedWrite" + ) + } + + it should "deduplicate when the same resource is scanned more than once" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq.fill(3)(classOf[RoleAnnotationEnforcerSpec.PartiallyAnnotatedResource]) + ) should have size 1 + } + + "enforce" should "throw when an endpoint is unannotated" in { + val ex = intercept[IllegalStateException] { + RoleAnnotationEnforcer.enforce( + Seq(classOf[RoleAnnotationEnforcerSpec.PartiallyAnnotatedResource]), + "TestService" + ) + } + ex.getMessage should include("TestService") + ex.getMessage should include("openEndpoint") + } + + "enforce" should "not throw when every endpoint is annotated" in { + noException should be thrownBy RoleAnnotationEnforcer.enforce( + Seq(classOf[RoleAnnotationEnforcerSpec.FullyAnnotatedResource]), + "TestService" + ) + } + + it should "list every offending endpoint in the thrown message" in { + val ex = intercept[IllegalStateException] { + RoleAnnotationEnforcer.enforce( + Seq(classOf[RoleAnnotationEnforcerSpec.MultiHoleResource]), + "TestService" + ) + } + ex.getMessage should include("#put") + ex.getMessage should include("#patch") + } + + it should "not throw when given no resources" in { + noException should be thrownBy RoleAnnotationEnforcer.enforce(Seq.empty, "TestService") + } + + "enforce(ResourceConfig)" should "pass when every registered resource is annotated" in { + val resourceConfig = new ResourceConfig() + resourceConfig.register(classOf[RoleAnnotationEnforcerSpec.FullyAnnotatedResource]) + noException should be thrownBy RoleAnnotationEnforcer.enforce(resourceConfig, "TestService") + } + + it should "throw when a registered resource class has an unannotated endpoint" in { + val resourceConfig = new ResourceConfig() + resourceConfig.register(classOf[RoleAnnotationEnforcerSpec.PartiallyAnnotatedResource]) + val ex = intercept[IllegalStateException] { + RoleAnnotationEnforcer.enforce(resourceConfig, "TestService") + } + ex.getMessage should include("TestService") + ex.getMessage should include("openEndpoint") + } + + it should "throw when a resource registered as an instance has an unannotated endpoint" in { + val resourceConfig = new ResourceConfig() + resourceConfig.register(new RoleAnnotationEnforcerSpec.PartiallyAnnotatedResource) + an[IllegalStateException] should be thrownBy + RoleAnnotationEnforcer.enforce(resourceConfig, "TestService") + } + + it should "still fail closed when error logging is disabled" in { + // enforce logs the violation at error level before throwing. Even with error + // logging suppressed, enforcement must still throw rather than silently pass. + val backendLogger = LoggerFactory + .getLogger(RoleAnnotationEnforcer.getClass.getName) + .asInstanceOf[LogbackLogger] + val previousLevel = backendLogger.getLevel + backendLogger.setLevel(Level.OFF) + try { + an[IllegalStateException] should be thrownBy + RoleAnnotationEnforcer.enforce( + Seq(classOf[RoleAnnotationEnforcerSpec.PartiallyAnnotatedResource]), + "TestService" + ) + } finally { + backendLogger.setLevel(previousLevel) + } + } +} + +object RoleAnnotationEnforcerSpec { + + class FullyAnnotatedResource { + @GET @RolesAllowed(Array("REGULAR")) def read: String = "" + @POST @PermitAll def create: String = "" + } + + class PartiallyAnnotatedResource { + @GET @RolesAllowed(Array("ADMIN")) def securedEndpoint: String = "" + @POST def openEndpoint: String = "" + } + + @RolesAllowed(Array("ADMIN")) + class ClassLevelResource { + @GET def read: String = "" + @DELETE def remove: String = "" + } + + class PermitAndDenyResource { + @PermitAll @GET def open: String = "" + @DenyAll @POST def closed: String = "" + } + + class NonEndpointMethodResource { + @GET @RolesAllowed(Array("REGULAR")) def read: String = "" + def helper: String = "" + } + + // One secured endpoint plus two holes on distinct verbs. + class MultiHoleResource { + @GET @RolesAllowed(Array("ADMIN")) def get: String = "" + @PUT def put: String = "" + @PATCH def patch: String = "" + } + + // Every method maps to a verb that is not GET/POST/DELETE; all are holes. + class AllVerbsUnannotatedResource { + @PUT def put: String = "" + @PATCH def patch: String = "" + @HEAD def head: String = "" + @OPTIONS def options: String = "" + } + + class AnnotatedBaseResource { + @GET @PermitAll def inheritedOpen: String = "" + } + // Inherits an endpoint whose annotation lives on the superclass method. + class InheritsAnnotatedEndpoint extends AnnotatedBaseResource + + class UnannotatedBaseResource { + @PUT def inheritedWrite: String = "" + } + // Class-level annotation on the subclass covers the inherited unannotated endpoint. + @RolesAllowed(Array("ADMIN")) + class SecuredSubclass extends UnannotatedBaseResource + // No annotation anywhere: the inherited endpoint is a hole. + class UnsecuredSubclass extends UnannotatedBaseResource +} 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 0650990264..db63bbf2eb 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 @@ -20,24 +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.common.config.StorageConfig -import org.apache.texera.auth.{ - JwtAuthFilter, - RequestLoggingFilter, - SessionUser, - UnauthorizedExceptionMapper -} +import org.apache.texera.auth.{AuthFeatures, RequestLoggingFilter, RoleAnnotationEnforcer} import org.apache.texera.dao.SqlServer import org.apache.texera.service.resource.{ ComputingUnitAccessResource, ComputingUnitManagingResource, HealthCheckResource } -import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature import java.nio.file.Path class ComputingUnitManagingService extends Application[ComputingUnitManagingServiceConfiguration] { @@ -63,7 +56,7 @@ class ComputingUnitManagingService extends Application[ComputingUnitManagingServ environment.jersey.setUrlPattern("/api/*") environment.jersey.register(classOf[HealthCheckResource]) - ComputingUnitManagingService.registerAuthFeatures(environment) + AuthFeatures.register(environment) SqlServer.initConnection( StorageConfig.jdbcUrl, @@ -74,27 +67,17 @@ class ComputingUnitManagingService extends Application[ComputingUnitManagingServ environment.jersey().register(new ComputingUnitManagingResource) environment.jersey().register(new ComputingUnitAccessResource) + RoleAnnotationEnforcer.enforce( + environment.jersey.getResourceConfig, + "ComputingUnitManagingService" + ) + // Route request logs through SLF4J, controlled by TEXERA_SERVICE_LOG_LEVEL RequestLoggingFilter.register(environment.getApplicationContext) } } 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])) - environment.jersey.register(classOf[UnauthorizedExceptionMapper]) - - // 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/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 index b189cf3480..d2162d48c7 100644 --- 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 @@ -19,32 +19,25 @@ 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.apache.texera.auth.UnauthorizedExceptionMapper -import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature -import org.mockito.Mockito.{mock, verify, when} +import org.apache.texera.auth.RoleAnnotationEnforcer +import org.apache.texera.service.resource.{ + ComputingUnitAccessResource, + ComputingUnitManagingResource, + HealthCheckResource +} 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(classOf[UnauthorizedExceptionMapper]) - verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature])) - verify(jersey).register( - org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]]) - ) + // Every endpoint this service registers declares @RolesAllowed/@PermitAll/@DenyAll. + "ComputingUnitManagingService's registered resources" should "all declare access control" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq( + classOf[ComputingUnitManagingResource], + classOf[ComputingUnitAccessResource], + classOf[HealthCheckResource] + ) + ) shouldBe empty } } 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 e4736cf251..b45c6ce62b 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 @@ -21,21 +21,14 @@ package org.apache.texera.service import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.typesafe.scalalogging.LazyLogging -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.auth.{ - JwtAuthFilter, - RequestLoggingFilter, - SessionUser, - UnauthorizedExceptionMapper -} +import org.apache.texera.auth.{AuthFeatures, RequestLoggingFilter, RoleAnnotationEnforcer} import org.apache.texera.common.config.{DefaultsConfig, StorageConfig} 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 @@ -68,10 +61,12 @@ class ConfigService extends Application[ConfigServiceConfiguration] with LazyLog environment.jersey.register(classOf[HealthCheckResource]) - ConfigService.registerAuthFeatures(environment) + AuthFeatures.register(environment) environment.jersey.register(new ConfigResource) + RoleAnnotationEnforcer.enforce(environment.jersey.getResourceConfig, "ConfigService") + // Preload default.conf into site_setting tables try { val ctx = SqlServer.getInstance().createDSLContext() @@ -108,24 +103,6 @@ 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])) - environment.jersey.register(classOf[UnauthorizedExceptionMapper]) - - // 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/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala b/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala index 388a48136a..1481b311e6 100644 --- a/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala +++ b/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala @@ -19,32 +19,17 @@ 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.apache.texera.auth.UnauthorizedExceptionMapper -import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature -import org.mockito.Mockito.{mock, verify, when} +import org.apache.texera.auth.RoleAnnotationEnforcer +import org.apache.texera.service.resource.{ConfigResource, HealthCheckResource} 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(classOf[UnauthorizedExceptionMapper]) - verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature])) - verify(jersey).register( - org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]]) - ) + // Every endpoint this service registers declares @RolesAllowed/@PermitAll/@DenyAll. + "ConfigService's registered resources" should "all declare access control" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[ConfigResource], classOf[HealthCheckResource]) + ) shouldBe empty } } 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 9a2688212d..fe3c3b8741 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 @@ -22,18 +22,12 @@ package org.apache.texera.service import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.typesafe.scalalogging.LazyLogging -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.common.config.StorageConfig import org.apache.texera.amber.core.storage.util.LakeFSStorageClient -import org.apache.texera.auth.{ - JwtAuthFilter, - RequestLoggingFilter, - SessionUser, - UnauthorizedExceptionMapper -} +import org.apache.texera.auth.{AuthFeatures, RequestLoggingFilter, RoleAnnotationEnforcer} import org.apache.texera.dao.SqlServer import org.apache.texera.service.`type`.DatasetFileNode import org.apache.texera.service.`type`.serde.DatasetFileNodeSerializer @@ -45,7 +39,6 @@ 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 { @@ -91,21 +84,13 @@ class FileService extends Application[FileServiceConfiguration] with LazyLogging environment.jersey.register(classOf[HealthCheckResource]) - // Register JWT authentication filter - environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) - environment.jersey.register(classOf[UnauthorizedExceptionMapper]) - - // 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]) + AuthFeatures.register(environment) environment.jersey.register(classOf[DatasetResource]) environment.jersey.register(classOf[DatasetAccessResource]) + RoleAnnotationEnforcer.enforce(environment.jersey.getResourceConfig, "FileService") + // Route request logs through SLF4J, controlled by TEXERA_SERVICE_LOG_LEVEL RequestLoggingFilter.register(environment.getApplicationContext) } diff --git a/file-service/src/test/scala/org/apache/texera/service/FileServiceRunSpec.scala b/file-service/src/test/scala/org/apache/texera/service/FileServiceRunSpec.scala new file mode 100644 index 0000000000..82b5169bd7 --- /dev/null +++ b/file-service/src/test/scala/org/apache/texera/service/FileServiceRunSpec.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 + +import org.apache.texera.auth.RoleAnnotationEnforcer +import org.apache.texera.service.resource.{ + DatasetAccessResource, + DatasetResource, + HealthCheckResource +} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class FileServiceRunSpec extends AnyFlatSpec with Matchers { + + // Every endpoint this service registers declares @RolesAllowed/@PermitAll/@DenyAll. + "FileService's registered resources" should "all declare access control" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq( + classOf[DatasetResource], + classOf[DatasetAccessResource], + classOf[HealthCheckResource] + ) + ) shouldBe empty + } +} 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 c278b21b4d..a69ef54524 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,17 +20,15 @@ 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.common.config.StorageConfig import org.apache.texera.amber.util.ObjectMapperUtils -import org.apache.texera.auth.{JwtAuthFilter, SessionUser, UnauthorizedExceptionMapper} +import org.apache.texera.auth.{AuthFeatures, RoleAnnotationEnforcer} 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 @@ -58,7 +56,7 @@ class WorkflowCompilingService extends Application[WorkflowCompilingServiceConfi environment.jersey.register(classOf[HealthCheckResource]) - WorkflowCompilingService.registerAuthFeatures(environment) + AuthFeatures.register(environment) SqlServer.initConnection( StorageConfig.jdbcUrl, @@ -69,6 +67,11 @@ class WorkflowCompilingService extends Application[WorkflowCompilingServiceConfi // register the compilation endpoint environment.jersey.register(classOf[WorkflowCompilationResource]) + RoleAnnotationEnforcer.enforce( + environment.jersey.getResourceConfig, + "WorkflowCompilingService" + ) + // Route request logs through SLF4J, controlled by TEXERA_SERVICE_LOG_LEVEL val requestLogger = org.slf4j.LoggerFactory.getLogger("org.eclipse.jetty.server.RequestLog") environment.getApplicationContext.addFilter( @@ -95,21 +98,6 @@ 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])) - environment.jersey.register(classOf[UnauthorizedExceptionMapper]) - - // 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 index cecc09a4d3..898d32a9b4 100644 --- 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 @@ -19,32 +19,17 @@ 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.apache.texera.auth.UnauthorizedExceptionMapper -import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature -import org.mockito.Mockito.{mock, verify, when} +import org.apache.texera.auth.RoleAnnotationEnforcer +import org.apache.texera.service.resource.{HealthCheckResource, WorkflowCompilationResource} 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(classOf[UnauthorizedExceptionMapper]) - verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature])) - verify(jersey).register( - org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]]) - ) + // Every endpoint this service registers declares @RolesAllowed/@PermitAll/@DenyAll. + "WorkflowCompilingService's registered resources" should "all declare access control" in { + RoleAnnotationEnforcer.findUnannotatedEndpoints( + Seq(classOf[WorkflowCompilationResource], classOf[HealthCheckResource]) + ) shouldBe empty } }
