This is an automated email from the ASF dual-hosted git repository.

aicam pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new dbeb0a084e feat(access-control-service): AccessControlService added as 
a new micro service (#3767)
dbeb0a084e is described below

commit dbeb0a084e0b5acec9151ec25fb896a06dfc7da4
Author: ali risheh <[email protected]>
AuthorDate: Fri Sep 26 08:43:59 2025 -0700

    feat(access-control-service): AccessControlService added as a new micro 
service (#3767)
    
    ## Overview
    This PR is fixing the first part of
    [#3634](https://github.com/apache/texera/issues/3634). The full
    [PR](https://github.com/apache/texera/pull/3598) had so many changes so
    we broke it down into two PRs. This PR is the first one that introduce
    Access Control Service only without its usage in any part of the system.
    
    ## Test cases
    - Empty authorization token
    - Wrong CUID format
    - Request access for a user without access to the computing unit
    - Request access for a user with access to the computing unit
    
    ## Changes
    Currently, the folder named `access-control-service` is added with its
    dependencies to `auth` folder. Currently, it has test cases to make sure
    its functionality is working correctly.
    
    <img width="1382" height="554" alt="Screenshot from 2025-09-25 15-46-55"
    
src="https://github.com/user-attachments/assets/25da27b4-5018-4a47-bccc-6ba2adacbfb4";
    />
    
    
    | Component/Flow | File | Description |
    | :--- | :--- | :--- |
    | **New `AccessControl` Service** | `core/access-control-service/...` |
    A new Dropwizard-based microservice responsible for authorizing user
    requests to computing units. This includes its build configuration,
    application setup (`AccessControlService.scala`), configuration model
    (`AccessControlServiceConfiguration.scala`), authorization logic, REST
    endpoint (`AccessControlResource.scala`), and unit tests
    (`AccessControlResourceSpec.scala`). |
    | **Database Access Logic** |
    
`core/auth/src/main/scala/edu/uci/ics/texera/auth/util/ComputingUnitAccess.scala`
    | Implements the logic to query the PostgreSQL database and determine a
    user's access privilege (`READ`, `WRITE`, `NONE`) for a given Computing
    Unit. |
    | |
    `core/auth/src/main/scala/edu/uci/ics/texera/auth/util/HeaderField.scala`
    | Defines constants for the custom HTTP headers (`x-user-cu-access`,
    `x-user-id`, etc.) that are injected by the Access Control Service. |
---
 core/access-control-service/build.sbt              |  81 ++++++++
 .../project/build.properties                       |  18 ++
 .../access-control-service-web-config.yaml         |  33 ++++
 .../src/main/resources/logback.xml                 |  55 ++++++
 .../ics/texera/service/AccessControlService.scala  |  78 ++++++++
 .../AccessControlServiceConfiguration.scala        |  22 +++
 .../service/resource/AccessControlResource.scala   | 132 +++++++++++++
 .../service/resource/HealthCheckResource.scala     |  30 +++
 .../uci/ics/texera/AccessControlResourceSpec.scala | 220 +++++++++++++++++++++
 .../ics/texera/auth/util/ComputingUnitAccess.scala |  55 ++++++
 .../edu/uci/ics/texera/auth/util/HeaderField.scala |  27 +++
 core/build.sbt                                     |  10 +
 .../scala/edu/uci/ics/amber/util/PathUtils.scala   |   2 +
 13 files changed, 763 insertions(+)

diff --git a/core/access-control-service/build.sbt 
b/core/access-control-service/build.sbt
new file mode 100644
index 0000000000..052dad4c13
--- /dev/null
+++ b/core/access-control-service/build.sbt
@@ -0,0 +1,81 @@
+// 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.
+
+import scala.collection.Seq
+
+name := "access-control-service"
+organization := "edu.uci.ics"
+version := "1.0.0"
+
+scalaVersion := "2.13.12"
+
+enablePlugins(JavaAppPackaging)
+
+// Enable semanticdb for Scalafix
+ThisBuild / semanticdbEnabled := true
+ThisBuild / semanticdbVersion := scalafixSemanticdb.revision
+
+// Manage dependency conflicts by always using the latest revision
+ThisBuild / conflictManager := ConflictManager.latestRevision
+
+// Restrict parallel execution of tests to avoid conflicts
+Global / concurrentRestrictions += Tags.limit(Tags.Test, 1)
+
+/////////////////////////////////////////////////////////////////////////////
+// Compiler Options
+/////////////////////////////////////////////////////////////////////////////
+
+// Scala compiler options
+Compile / scalacOptions ++= Seq(
+  "-Xelide-below", "WARNING",       // Turn on optimizations with "WARNING" as 
the threshold
+  "-feature",                       // Check feature warnings
+  "-deprecation",                   // Check deprecation warnings
+  "-Ywarn-unused:imports"           // Check for unused imports
+)
+
+/////////////////////////////////////////////////////////////////////////////
+// Version Variables
+/////////////////////////////////////////////////////////////////////////////
+
+val dropwizardVersion = "4.0.7"
+val mockitoVersion = "5.4.0"
+val assertjVersion = "3.24.2"
+
+/////////////////////////////////////////////////////////////////////////////
+// Test-related Dependencies
+/////////////////////////////////////////////////////////////////////////////
+
+libraryDependencies ++= Seq(
+  "org.scalamock" %% "scalamock" % "5.2.0" % Test,                   // 
ScalaMock
+  "org.scalatest" %% "scalatest" % "3.2.17" % Test,                  // 
ScalaTest
+  "io.dropwizard" % "dropwizard-testing" % dropwizardVersion % Test, // 
Dropwizard Testing
+  "org.mockito" % "mockito-core" % mockitoVersion % Test,            // 
Mockito for mocking
+  "org.assertj" % "assertj-core" % assertjVersion % Test,            // 
AssertJ for assertions
+  "com.novocode" % "junit-interface" % "0.11" % Test                // SBT 
interface for JUnit
+)
+
+/////////////////////////////////////////////////////////////////////////////
+// Dependencies
+/////////////////////////////////////////////////////////////////////////////
+
+// Core Dependencies
+libraryDependencies ++= Seq(
+  "io.dropwizard" % "dropwizard-core" % dropwizardVersion,
+  "io.dropwizard" % "dropwizard-auth" % dropwizardVersion, // Dropwizard 
Authentication module
+  "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.15.2",
+  "org.playframework" %% "play-json" % "3.1.0-M1",
+)
\ No newline at end of file
diff --git a/core/access-control-service/project/build.properties 
b/core/access-control-service/project/build.properties
new file mode 100644
index 0000000000..5a15dd8541
--- /dev/null
+++ b/core/access-control-service/project/build.properties
@@ -0,0 +1,18 @@
+# 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.
+
+sbt.version = 1.9.9
\ No newline at end of file
diff --git 
a/core/access-control-service/src/main/resources/access-control-service-web-config.yaml
 
b/core/access-control-service/src/main/resources/access-control-service-web-config.yaml
new file mode 100644
index 0000000000..e8d17cec28
--- /dev/null
+++ 
b/core/access-control-service/src/main/resources/access-control-service-web-config.yaml
@@ -0,0 +1,33 @@
+# 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.
+
+server:
+  applicationConnectors:
+    - type: http
+      port: 9096
+  adminConnectors: []
+
+logging:
+  level: INFO
+  appenders:
+    - type: console
+      threshold: INFO
+    - type: file
+      currentLogFilename: logs/access-control-service.log
+      archive: true
+      archivedLogFilenamePattern: logs/access-control-service-%d.log.gz
+      archivedFileCount: 5
\ No newline at end of file
diff --git a/core/access-control-service/src/main/resources/logback.xml 
b/core/access-control-service/src/main/resources/logback.xml
new file mode 100644
index 0000000000..4763107b50
--- /dev/null
+++ b/core/access-control-service/src/main/resources/logback.xml
@@ -0,0 +1,55 @@
+<!--
+ 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.
+-->
+
+<configuration>
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <!-- encoders are assigned the type 
ch.qos.logback.classic.encoder.PatternLayoutEncoder
+            by default -->
+        <encoder>
+            <pattern>[%date{ISO8601}] [%level] [%logger] [%thread] - %msg %n
+            </pattern>
+        </encoder>
+    </appender>
+
+
+    <appender name="FILE" 
class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>../log/access-control-service.log</file>
+        <immediateFlush>true</immediateFlush>
+        <rollingPolicy 
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            
<fileNamePattern>../log/access-control-service-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+        </rollingPolicy>
+        <encoder>
+            <pattern>[%date{ISO8601}] [%level] [%logger] [%thread] - %msg 
%n</pattern>
+        </encoder>
+    </appender>
+
+    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
+        <queueSize>8192</queueSize>
+        <neverBlock>true</neverBlock>
+        <appender-ref ref="FILE"/>
+    </appender>
+
+    <root level="INFO">
+        <appender-ref ref="ASYNC"/>
+        <appender-ref ref="STDOUT"/>
+    </root>
+    <logger name="org.apache" level="WARN"/>
+    <logger name="httpclient" level="WARN"/>
+    <logger name="io.grpc.netty" level="WARN"/>
+</configuration>
\ No newline at end of file
diff --git 
a/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlService.scala
 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlService.scala
new file mode 100644
index 0000000000..02278fd97a
--- /dev/null
+++ 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlService.scala
@@ -0,0 +1,78 @@
+// 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 edu.uci.ics.texera.service
+
+import io.dropwizard.core.Application
+import io.dropwizard.core.setup.{Bootstrap, Environment}
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import com.typesafe.scalalogging.LazyLogging
+import edu.uci.ics.amber.config.StorageConfig
+import edu.uci.ics.amber.util.PathUtils.{configServicePath, 
accessControlServicePath}
+import edu.uci.ics.texera.auth.{JwtAuthFilter, SessionUser}
+import edu.uci.ics.texera.dao.SqlServer
+import edu.uci.ics.texera.service.resource.{HealthCheckResource, 
AccessControlResource}
+import io.dropwizard.auth.AuthDynamicFeature
+import org.eclipse.jetty.server.session.SessionHandler
+import org.jooq.impl.DSL
+
+
+class AccessControlService extends 
Application[AccessControlServiceConfiguration] with LazyLogging {
+  override def initialize(bootstrap: 
Bootstrap[AccessControlServiceConfiguration]): Unit = {
+    // Register Scala module to Dropwizard default object mapper
+    bootstrap.getObjectMapper.registerModule(DefaultScalaModule)
+
+    SqlServer.initConnection(
+      StorageConfig.jdbcUrl,
+      StorageConfig.jdbcUsername,
+      StorageConfig.jdbcPassword
+    )
+  }
+
+  override def run(configuration: AccessControlServiceConfiguration, 
environment: Environment): Unit = {
+    // Serve backend at /api
+    environment.jersey.setUrlPattern("/api/*")
+
+    environment.jersey.register(classOf[SessionHandler])
+    environment.servlets.setSessionHandler(new SessionHandler)
+
+    environment.jersey.register(classOf[HealthCheckResource])
+    environment.jersey.register(classOf[AccessControlResource])
+
+    // Register JWT authentication filter
+    environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter]))
+
+    // Enable @Auth annotation for injecting SessionUser
+    environment.jersey.register(
+      new 
io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser])
+    )
+  }
+}
+object AccessControlService {
+  def main(args: Array[String]): Unit = {
+    val accessControlPath = accessControlServicePath
+      .resolve("src")
+      .resolve("main")
+      .resolve("resources")
+      .resolve("access-control-service-web-config.yaml")
+      .toAbsolutePath
+      .toString
+
+    // Start the Dropwizard application
+    new AccessControlService().run("server", accessControlPath)
+  }
+}
diff --git 
a/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlServiceConfiguration.scala
 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlServiceConfiguration.scala
new file mode 100644
index 0000000000..1f388d8f9a
--- /dev/null
+++ 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlServiceConfiguration.scala
@@ -0,0 +1,22 @@
+// 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 edu.uci.ics.texera.service
+
+import io.dropwizard.core.Configuration
+
+class AccessControlServiceConfiguration extends Configuration {}
diff --git 
a/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/AccessControlResource.scala
 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/AccessControlResource.scala
new file mode 100644
index 0000000000..8bca493850
--- /dev/null
+++ 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/AccessControlResource.scala
@@ -0,0 +1,132 @@
+// 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 edu.uci.ics.texera.service.resource
+
+import com.typesafe.scalalogging.LazyLogging
+import edu.uci.ics.texera.auth.JwtParser.parseToken
+import edu.uci.ics.texera.auth.SessionUser
+import edu.uci.ics.texera.auth.util.{ComputingUnitAccess, HeaderField}
+import edu.uci.ics.texera.dao.jooq.generated.enums.PrivilegeEnum
+import jakarta.ws.rs.core._
+import jakarta.ws.rs.{GET, POST, Path, Produces}
+
+import java.util.Optional
+import scala.jdk.CollectionConverters.{CollectionHasAsScala, MapHasAsScala}
+import scala.util.matching.Regex
+
+object AccessControlResource extends LazyLogging {
+
+  // Regex for the paths that require authorization
+  private val wsapiWorkflowWebsocket: Regex = 
""".*/wsapi/workflow-websocket.*""".r
+  private val apiExecutionsStats: Regex = 
""".*/api/executions/[0-9]+/stats/[0-9]+.*""".r
+  private val apiExecutionsResultExport: Regex = 
""".*/api/executions/result/export.*""".r
+
+  /**
+    * Authorize the request based on the path and headers.
+   * @param uriInfo URI sent by Envoy or API Gateway
+   * @param headers HTTP headers sent by Envoy or API Gateway which include
+   *                headers sent by the client (browser)
+   * @return HTTP Response with appropriate status code and headers
+   */
+  def authorize(uriInfo: UriInfo, headers: HttpHeaders): Response = {
+    val path = uriInfo.getPath
+    logger.info(s"Authorizing request for path: $path")
+
+    path match {
+      case wsapiWorkflowWebsocket() | apiExecutionsStats() | 
apiExecutionsResultExport() =>
+        checkComputingUnitAccess(uriInfo, headers)
+      case _ =>
+        logger.warn(s"No authorization logic for path: $path. Denying access.")
+        Response.status(Response.Status.FORBIDDEN).build()
+    }
+  }
+
+  private def checkComputingUnitAccess(uriInfo: UriInfo, headers: 
HttpHeaders): Response = {
+    val queryParams: Map[String, String] = uriInfo
+      .getQueryParameters()
+      .asScala
+      .view
+      .mapValues(values => values.asScala.headOption.getOrElse(""))
+      .toMap
+
+    logger.info(s"Request URI: ${uriInfo.getRequestUri} and headers: 
${headers.getRequestHeaders.asScala} and queryParams: $queryParams")
+
+    val token = queryParams.getOrElse(
+      "access-token",
+      headers
+        .getRequestHeader("Authorization")
+        .asScala
+        .headOption
+        .getOrElse("")
+        .replace("Bearer ", "")
+    )
+    val cuid = queryParams.getOrElse("cuid", "")
+    val cuidInt = try {
+      cuid.toInt
+    } catch {
+      case _: NumberFormatException =>
+        return Response.status(Response.Status.FORBIDDEN).build()
+    }
+
+    var cuAccess: PrivilegeEnum = PrivilegeEnum.NONE
+    var userSession: Optional[SessionUser] = Optional.empty()
+    try {
+      userSession = parseToken(token)
+      if (userSession.isEmpty)
+        return Response.status(Response.Status.FORBIDDEN).build()
+
+      val uid = userSession.get().getUid
+      cuAccess = ComputingUnitAccess.getComputingUnitAccess(cuidInt, uid)
+      if (cuAccess == PrivilegeEnum.NONE)
+        return Response.status(Response.Status.FORBIDDEN).build()
+    } catch {
+      case e: Exception =>
+        return Response.status(Response.Status.FORBIDDEN).build()
+    }
+
+    Response
+      .ok()
+      .header(HeaderField.UserComputingUnitAccess, cuAccess.toString)
+      .header(HeaderField.UserId, userSession.get().getUid.toString)
+      .header(HeaderField.UserName, userSession.get().getName)
+      .header(HeaderField.UserEmail, userSession.get().getEmail)
+      .build()
+  }
+}
+@Produces(Array(MediaType.APPLICATION_JSON))
+@Path("/auth")
+class AccessControlResource extends LazyLogging {
+
+  @GET
+  @Path("/{path:.*}")
+  def authorizeGet(
+                    @Context uriInfo: UriInfo,
+                    @Context headers: HttpHeaders
+                  ): Response = {
+    AccessControlResource.authorize(uriInfo, headers)
+  }
+
+  @POST
+  @Path("/{path:.*}")
+  def authorizePost(
+                     @Context uriInfo: UriInfo,
+                     @Context headers: HttpHeaders
+                   ): Response = {
+    AccessControlResource.authorize(uriInfo, headers)
+  }
+}
diff --git 
a/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/HealthCheckResource.scala
 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/HealthCheckResource.scala
new file mode 100644
index 0000000000..895f6a400a
--- /dev/null
+++ 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/HealthCheckResource.scala
@@ -0,0 +1,30 @@
+/*
+ * 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 edu.uci.ics.texera.service.resource
+
+import jakarta.ws.rs.core.MediaType
+import jakarta.ws.rs.{GET, Path, Produces}
+
+@Path("/healthcheck")
+@Produces(Array(MediaType.APPLICATION_JSON))
+class HealthCheckResource {
+  @GET
+  def healthCheck: Map[String, String] = Map("status" -> "ok")
+}
diff --git 
a/core/access-control-service/src/test/scala/edu/uci/ics/texera/AccessControlResourceSpec.scala
 
b/core/access-control-service/src/test/scala/edu/uci/ics/texera/AccessControlResourceSpec.scala
new file mode 100644
index 0000000000..349ec334aa
--- /dev/null
+++ 
b/core/access-control-service/src/test/scala/edu/uci/ics/texera/AccessControlResourceSpec.scala
@@ -0,0 +1,220 @@
+// 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 edu.uci.ics.texera
+
+import edu.uci.ics.texera.auth.JwtAuth
+import edu.uci.ics.texera.auth.util.HeaderField
+import edu.uci.ics.texera.dao.MockTexeraDB
+import edu.uci.ics.texera.dao.jooq.generated.enums.{PrivilegeEnum, 
UserRoleEnum, WorkflowComputingUnitTypeEnum}
+import 
edu.uci.ics.texera.dao.jooq.generated.tables.daos.{ComputingUnitUserAccessDao, 
UserDao, WorkflowComputingUnitDao}
+import 
edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{ComputingUnitUserAccess, 
User, WorkflowComputingUnit}
+import edu.uci.ics.texera.service.resource.AccessControlResource
+import jakarta.ws.rs.core.{HttpHeaders, MultivaluedHashMap, Response, UriInfo}
+import org.mockito.Mockito._
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import java.net.URI
+import java.util
+
+class AccessControlResourceSpec extends AnyFlatSpec
+  with Matchers
+  with BeforeAndAfterAll
+  with BeforeAndAfterEach
+  with MockTexeraDB {
+
+  private val testURI: String = "http://localhost:8080/";
+  private val testPath: String = "/api/executions/1/stats/1"
+
+  private val testUser1: User = {
+    val user = new User()
+    user.setUid(1)
+    user.setName("testuser")
+    user.setEmail("[email protected]")
+    user.setRole(UserRoleEnum.REGULAR)
+    user.setPassword("password")
+    user
+  }
+
+  private val testUser2: User = {
+    val user = new User()
+    user.setUid(2)
+    user.setName("testuser2")
+    user.setEmail("[email protected]")
+    user.setRole(UserRoleEnum.REGULAR)
+    user.setPassword("password")
+    user
+  }
+
+  private val testCU: WorkflowComputingUnit = {
+    val cu = new WorkflowComputingUnit()
+    cu.setUid(2)
+    cu.setType(WorkflowComputingUnitTypeEnum.kubernetes)
+    cu.setCuid(2)
+    cu.setName("test-cu")
+    cu
+  }
+
+  private var token: String = _
+
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+    val userDao = new UserDao(getDSLContext.configuration())
+    val computingUnitDao = new 
WorkflowComputingUnitDao(getDSLContext.configuration())
+    val computingUnitOfUserDao = new 
ComputingUnitUserAccessDao(getDSLContext.configuration())
+
+    // insert user, computing unit, and access privilege into the mock database
+    userDao.insert(testUser1)
+    userDao.insert(testUser2)
+    computingUnitDao.insert(testCU)
+
+    val cuAccess = new ComputingUnitUserAccess()
+    cuAccess.setUid(testUser1.getUid)
+    cuAccess.setCuid(testCU.getCuid)
+    cuAccess.setPrivilege(PrivilegeEnum.WRITE)
+    computingUnitOfUserDao.insert(cuAccess)
+
+    val claims = JwtAuth.jwtClaims(testUser1, 1)
+    token = JwtAuth.jwtToken(claims)
+  }
+
+  override protected def afterAll(): Unit = {
+    shutdownDB()
+  }
+
+  "AccessControlResource" should "return FORBIDDEN for a GET request without a 
token" in {
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", "1")
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(new 
util.ArrayList[String]())
+
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizeGet(mockUriInfo, 
mockHttpHeaders)
+
+    response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+  }
+
+  it should "return FORBIDDEN for a GET request with a non-integer cuid" in {
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", "abc")
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+    requestHeaders.add("Authorization", "Bearer dummy-token")
+
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    
when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(util.Arrays.asList("Bearer
 dummy-token"))
+
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizeGet(mockUriInfo, 
mockHttpHeaders)
+
+    response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+  }
+
+  it should "return FORBIDDEN for a POST request without a token" in {
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", "1")
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(new 
util.ArrayList[String]())
+
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizePost(mockUriInfo, 
mockHttpHeaders)
+
+    response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+  }
+
+  "AccessControlResource" should "return FORBIDDEN when user does not have 
access to the computing unit" in {
+    // Mock the request context
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+
+    // Prepare query parameters with a computing unit ID (cuid)
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", "1") // Assuming user 1 does not have access to 
cuid 1
+
+    // Prepare request headers with the generated JWT
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+    requestHeaders.add("Authorization", "Bearer " + token)
+
+    // Stub the mock objects to return the prepared data
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    
when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(util.Arrays.asList("Bearer
 " + token))
+
+    // Instantiate the resource and call the method under test
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizeGet(mockUriInfo, 
mockHttpHeaders)
+
+    // Assert that the response status is FORBIDDEN
+    response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+  }
+
+  it should "return OK and correct headers when user has access" in {
+    // Mock the request context
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+
+    // Prepare query parameters with a computing unit ID the user HAS access to
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", testCU.getCuid.toString)
+
+    // Prepare request headers with the generated JWT
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+    requestHeaders.add("Authorization", "Bearer " + token)
+
+    // Stub the mock objects to return the prepared data
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    
when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(util.Arrays.asList("Bearer
 " + token))
+
+    // Instantiate the resource and call the method under test
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizeGet(mockUriInfo, 
mockHttpHeaders)
+
+    // Assert that the response status is OK and headers are correct
+    response.getStatus shouldBe Response.Status.OK.getStatusCode
+    response.getHeaderString(
+      HeaderField.UserComputingUnitAccess
+    ) shouldBe PrivilegeEnum.WRITE.toString
+    response.getHeaderString(HeaderField.UserId) shouldBe 
testUser1.getUid.toString
+    response.getHeaderString(HeaderField.UserName) shouldBe testUser1.getName
+    response.getHeaderString(HeaderField.UserEmail) shouldBe testUser1.getEmail
+  }
+}
\ No newline at end of file
diff --git 
a/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/ComputingUnitAccess.scala
 
b/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/ComputingUnitAccess.scala
new file mode 100644
index 0000000000..c529edf8ec
--- /dev/null
+++ 
b/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/ComputingUnitAccess.scala
@@ -0,0 +1,55 @@
+// 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 edu.uci.ics.texera.auth.util
+
+import edu.uci.ics.texera.dao.SqlServer
+import edu.uci.ics.texera.dao.jooq.generated.enums.PrivilegeEnum
+import edu.uci.ics.texera.dao.jooq.generated.tables.daos.{
+  ComputingUnitUserAccessDao,
+  WorkflowComputingUnitDao
+}
+import ComputingUnitAccess._
+import org.jooq.DSLContext
+
+import scala.jdk.CollectionConverters._
+
+object ComputingUnitAccess {
+  private lazy val context: DSLContext = SqlServer
+    .getInstance()
+    .createDSLContext()
+
+  def getComputingUnitAccess(cuid: Integer, uid: Integer): PrivilegeEnum = {
+    val workflowComputingUnitDao = new 
WorkflowComputingUnitDao(context.configuration())
+    val unit = workflowComputingUnitDao.fetchOneByCuid(cuid)
+
+    if (unit.getUid.equals(uid)) {
+      return PrivilegeEnum.WRITE // owner has write access
+    }
+
+    val computingUnitUserAccessDao = new 
ComputingUnitUserAccessDao(context.configuration())
+    val accessOpt = computingUnitUserAccessDao
+      .fetchByUid(uid)
+      .asScala
+      .find(_.getCuid.equals(cuid))
+
+    accessOpt match {
+      case Some(access) => access.getPrivilege
+      case None         => PrivilegeEnum.NONE
+    }
+  }
+}
diff --git 
a/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/HeaderField.scala 
b/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/HeaderField.scala
new file mode 100644
index 0000000000..2b98989737
--- /dev/null
+++ b/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/HeaderField.scala
@@ -0,0 +1,27 @@
+/*
+ * 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 edu.uci.ics.texera.auth.util
+
+object HeaderField {
+  val UserComputingUnitAccess = "x-user-computing-unit-access"
+  val UserId = "x-user-id"
+  val UserName = "x-user-name"
+  val UserEmail = "x-user-email"
+}
diff --git a/core/build.sbt b/core/build.sbt
index 658f755f33..dc4fa0e823 100644
--- a/core/build.sbt
+++ b/core/build.sbt
@@ -27,6 +27,16 @@ lazy val ConfigService = (project in file("config-service"))
       "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.17.0"
     )
   )
+lazy val AccessControlService = (project in file("access-control-service"))
+  .dependsOn(Auth, Config, DAO)
+  .settings(
+    dependencyOverrides ++= Seq(
+      // override it as io.dropwizard 4 require 2.16.1 or higher
+      "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.17.0"
+    )
+  )
+  .configs(Test)
+  .dependsOn(DAO % "test->test", Auth % "test->test")
 lazy val WorkflowCore = (project in file("workflow-core"))
   .dependsOn(DAO, Config)
   .configs(Test)
diff --git a/core/config/src/main/scala/edu/uci/ics/amber/util/PathUtils.scala 
b/core/config/src/main/scala/edu/uci/ics/amber/util/PathUtils.scala
index a4d82a9030..827bd5e289 100644
--- a/core/config/src/main/scala/edu/uci/ics/amber/util/PathUtils.scala
+++ b/core/config/src/main/scala/edu/uci/ics/amber/util/PathUtils.scala
@@ -63,6 +63,8 @@ object PathUtils {
 
   lazy val configServicePath: Path = corePath.resolve("config-service")
 
+  lazy val accessControlServicePath: Path = 
corePath.resolve("access-control-service")
+
   private lazy val datasetsRootPath =
     corePath.resolve("amber").resolve("user-resources").resolve("datasets")
 


Reply via email to