You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/06/24 21:40:34 UTC
(texera) branch gh-readonly-queue/main/pr-5743-4a93b70f0d8af0220557e42db8e4c19ce2b21e7e created (now 71fce70c3d)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-5743-4a93b70f0d8af0220557e42db8e4c19ce2b21e7e
in repository https://gitbox.apache.org/repos/asf/texera.git
at 71fce70c3d feat(auth): enforce role annotation coverage at service startup (#5743)
This branch includes the following new commits:
new 71fce70c3d feat(auth): enforce role annotation coverage at service startup (#5743)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: feat(auth): enforce role annotation coverage at service startup (#5743)
Posted by gi...@apache.org.
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. <mg...@uci.edu>
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. <mg...@uci.edu>
Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
---
.../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
}
}