From 962260eda4b73018ad1195c374325d220fc2fec5 Mon Sep 17 00:00:00 2001 From: ali Date: Tue, 8 Sep 2026 15:10:08 -0700 Subject: [PATCH] feat(dataset-mount): authorize and perform a repository mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a computing unit have a versioned LakeFS repository mounted into it, on the infrastructure merged in #6866. The per-node mounter authorizes nothing — it performs what it is told, which is why it admits exactly one caller, verified with TokenReview against an audience-bound service-account token that only access-control-service holds. Every decision therefore has to be made here, and this endpoint makes four before anything reaches the mounter: the request has the shape a mount path can be built from; the caller holds write access to the computing unit, mounting being a change to it; the caller may read the repository, matched by name across datasets and models and refused unless exactly one matches; and the commit belongs to that repository. It then resolves which node the unit's pod is on — itself, rather than taking one from the caller, or anything reaching it could aim requests at any node's privileged mounter — and forwards. file-service still re-checks read access on every byte it serves, but as the last line rather than the only one: without the check here a caller could have a mount created for a repository they cannot read, learning it exists and spending a node's resources on it. The rules themselves move to common/resource so both services decide from one definition. Mounts are released when the pod is deleted, so there is no unmount path. No new configuration: the mounter's port and file-service's root come from the environment the chart already sets. Everything is behind mounter.enabled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fy9tJ1AB4trv6ZYGwfm9pR --- .../texera/service/AccessControlService.scala | 2 + .../resource/ComputingUnitMountResource.scala | 221 ++++++++++++ .../util/ComputingUnitNodeLocator.scala | 114 +++++++ .../texera/service/util/MounterClient.scala | 149 ++++++++ .../ComputingUnitMountResourceSpec.scala | 322 ++++++++++++++++++ .../util/ComputingUnitNodeLocatorSpec.scala | 66 ++++ .../service/util/MounterClientSpec.scala | 143 ++++++++ .../access-control-service-deployment.yaml | 12 + ...ccess-control-service-service-account.yaml | 50 ++- build.sbt | 2 +- .../service/resource/ResourceAccess.scala | 0 .../service/resource/ResourceTables.scala | 0 12 files changed, 1063 insertions(+), 18 deletions(-) create mode 100644 access-control-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitMountResource.scala create mode 100644 access-control-service/src/main/scala/org/apache/texera/service/util/ComputingUnitNodeLocator.scala create mode 100644 access-control-service/src/main/scala/org/apache/texera/service/util/MounterClient.scala create mode 100644 access-control-service/src/test/scala/org/apache/texera/service/resource/ComputingUnitMountResourceSpec.scala create mode 100644 access-control-service/src/test/scala/org/apache/texera/service/util/ComputingUnitNodeLocatorSpec.scala create mode 100644 access-control-service/src/test/scala/org/apache/texera/service/util/MounterClientSpec.scala rename {file-service => common/resource}/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala (100%) rename {file-service => common/resource}/src/main/scala/org/apache/texera/service/resource/ResourceTables.scala (100%) 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 1f50c86c9f5..ca2c797ab98 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 @@ -28,6 +28,7 @@ import org.apache.texera.dao.SqlServer import org.apache.texera.service.activity.UserActivityEventListener import org.apache.texera.service.resource.{ AccessControlResource, + ComputingUnitMountResource, HealthCheckResource, LiteLLMModelsResource, LiteLLMProxyResource @@ -68,6 +69,7 @@ class AccessControlService extends Application[AccessControlServiceConfiguration environment.jersey.register(classOf[AccessControlResource]) environment.jersey.register(classOf[LiteLLMProxyResource]) environment.jersey.register(classOf[LiteLLMModelsResource]) + environment.jersey.register(classOf[ComputingUnitMountResource]) AuthFeatures.register(environment) diff --git a/access-control-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitMountResource.scala b/access-control-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitMountResource.scala new file mode 100644 index 00000000000..4122e631533 --- /dev/null +++ b/access-control-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitMountResource.scala @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import com.typesafe.scalalogging.LazyLogging +import io.dropwizard.auth.Auth +import jakarta.annotation.security.RolesAllowed +import jakarta.ws.rs._ +import jakarta.ws.rs.core.MediaType +import org.apache.texera.auth.JwtAuth.{jwtClaims, jwtToken} +import org.apache.texera.auth.SessionUser +import org.apache.texera.auth.util.ComputingUnitAccess +import org.apache.texera.common.config.{EnvironmentalVariable, KubernetesConfig} +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.SqlServer.withTransaction +import org.apache.texera.dao.jooq.generated.Tables.{DATASET_VERSION, MODEL_VERSION} +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, ModelDao} +import org.apache.texera.service.resource.ComputingUnitMountResource._ +import org.apache.texera.service.util.{ + ComputingUnitNodeLocator, + MountRequestValidation, + MounterClient +} + +import scala.jdk.CollectionConverters._ + +/** + * The mount authority: this service decides whether a user may act on a computing unit, so + * it is where a mount request is authorized before being forwarded to that unit's node. + * + * Read access to the data is not decided here. file-service's S3 proxy authorizes every + * read, so a mount of a repository the user cannot read reads nothing. + * + * Not routed at the gateway — these endpoints are reached in-cluster, by the engine. + */ +@Path("/mounts") +@RolesAllowed(Array("REGULAR", "ADMIN")) +@Produces(Array(MediaType.APPLICATION_JSON)) +class ComputingUnitMountResource( + mounterEnabled: Boolean, + mounterPort: Option[Int], + fileServiceBaseUrl: Option[String], + nodeLocator: ComputingUnitNodeLocator, + mounter: MounterClient +) extends LazyLogging { + + // No-arg constructor for Jersey reflection. Tests use the param-ful form. + def this() = + this( + KubernetesConfig.mounterEnabled, + EnvironmentalVariable.get(MounterPortVariable).map(_.trim.toInt), + EnvironmentalVariable.get(FileServiceUrlVariable), + ComputingUnitNodeLocator, + MounterClient + ) + + @POST + @Path("/{cuid}") + @Consumes(Array(MediaType.APPLICATION_JSON)) + def mount( + @PathParam("cuid") cuid: Int, + request: MountRequest, + @Auth user: SessionUser + ): MountInfo = { + val (port, fileService) = requireMountConfiguration() + try MountRequestValidation.validate(cuid.toString, request.repositoryName, request.commitHash) + catch { case e: IllegalArgumentException => throw new BadRequestException(e.getMessage) } + requireComputingUnitAccess(cuid, user) + requireRepositoryReadAccess(request.repositoryName, request.commitHash, user.getUid) + val nodeIp = requireNodeIp(cuid) + + // A token minted here, after the access check: GeeseFS keeps presenting it for the life + // of the mount, so it must be one this service vouched for. + val mountPath = + try { + mounter.mount( + nodeIp, + port, + cuid.toString, + request.repositoryName, + request.commitHash, + jwtToken(jwtClaims(user.getUser)), + fileService + ) + } catch { + case e: IllegalArgumentException => + throw new BadRequestException(e.getMessage) + case e: MounterClient.MounterRequestException => + logger.warn(s"node mounter at $nodeIp refused a mount for computing unit $cuid", e) + throw new BadRequestException(e.getMessage) + } + + logger.info( + s"user ${user.getUid} mounted ${request.repositoryName}:${request.commitHash} " + + s"onto computing unit $cuid at $mountPath" + ) + MountInfo(request.repositoryName, request.commitHash, mountPath) + } + + /** + * What a mount request needs beyond the request itself, both passed by the chart. Missing + * with mounting off is said plainly, because the alternative is a connection timeout to a + * node port nothing is listening on; missing with it on is a misconfiguration, and naming + * the variable beats letting a half-formed request reach the mounter. + */ + private def requireMountConfiguration(): (Int, String) = { + if (!mounterEnabled) { + throw new ServiceUnavailableException( + "Repository mounting is not enabled on this deployment." + ) + } + def required[T](value: Option[T], variable: String): T = + value.getOrElse( + throw new InternalServerErrorException( + s"Repository mounting is enabled but $variable is unset." + ) + ) + ( + required(mounterPort, MounterPortVariable), + required(fileServiceBaseUrl.filter(_.nonEmpty), FileServiceUrlVariable) + ) + } + + /** + * Mounting puts data into someone's computing unit, so it takes the same privilege as any + * other change to one: ownership, or an explicit WRITE grant. A read-only sharee may use + * the unit, not alter what it can see. + */ + private def requireComputingUnitAccess(cuid: Int, user: SessionUser): Unit = + if (ComputingUnitAccess.getComputingUnitAccess(cuid, user.getUid) != PrivilegeEnum.WRITE) { + logger.warn(s"user ${user.getUid} denied mount access to computing unit $cuid") + throw new ForbiddenException("No write access to this computing unit.") + } + + /** + * The repository must be one the user may read, at a commit that belongs to it. + * + * The mounter performs what it is told and authorizes nothing, so this is where a mount is + * refused. file-service re-checks read access on every byte served through its proxy, but + * that is the last line rather than this one: without the check here a caller could have a + * mount created for a repository they cannot read, learning it exists and spending a node's + * resources on it. + * + * A repository is matched by name rather than parsed, because `sql/updates/15.sql` + * backfilled the column from the dataset's plain name; both resource kinds are searched, + * and anything other than exactly one match is refused rather than resolved arbitrarily. + */ + private def requireRepositoryReadAccess( + repositoryName: String, + commitHash: String, + uid: Integer + ): Unit = + withTransaction(SqlServer.getInstance().createDSLContext()) { ctx => + val datasets = new DatasetDao(ctx.configuration()).fetchByRepositoryName(repositoryName) + val models = new ModelDao(ctx.configuration()).fetchByRepositoryName(repositoryName) + + val readable = (datasets.asScala.toList, models.asScala.toList) match { + case (dataset :: Nil, Nil) => + ResourceAccess.userHasReadAccess(ctx, ResourceTables.Dataset, dataset.getDid, uid) && + ctx.fetchExists( + DATASET_VERSION, + DATASET_VERSION.DID + .eq(dataset.getDid) + .and(DATASET_VERSION.VERSION_HASH.eq(commitHash)) + ) + case (Nil, model :: Nil) => + ResourceAccess.userHasReadAccess(ctx, ResourceTables.Model, model.getMid, uid) && + ctx.fetchExists( + MODEL_VERSION, + MODEL_VERSION.MID.eq(model.getMid).and(MODEL_VERSION.VERSION_HASH.eq(commitHash)) + ) + case _ => false + } + + if (!readable) { + logger.warn(s"user $uid denied a mount of '$repositoryName' at '$commitHash'") + throw new ForbiddenException("No read access to the requested repository version.") + } + } + + private def requireNodeIp(cuid: Int): String = + nodeLocator + .nodeIpOf(cuid) + .getOrElse( + throw new BadRequestException( + s"Computing unit $cuid is not running on a node yet; cannot manage its mounts." + ) + ) +} + +object ComputingUnitMountResource { + + /** Set by the chart from the same value it gives the mounter DaemonSet's hostPort. */ + private val MounterPortVariable = "KUBERNETES_MOUNTER_PORT" + + // file-service's root, which is what GeeseFS is pointed at: the S3 proxy is served at the + // servlet root, so this is scheme and authority with no path. + private val FileServiceUrlVariable = "FILE_SERVICE_URL" + + case class MountRequest(repositoryName: String, commitHash: String) + + case class MountInfo(repositoryName: String, commitHash: String, mountPath: String) +} diff --git a/access-control-service/src/main/scala/org/apache/texera/service/util/ComputingUnitNodeLocator.scala b/access-control-service/src/main/scala/org/apache/texera/service/util/ComputingUnitNodeLocator.scala new file mode 100644 index 00000000000..237d6cb2c0a --- /dev/null +++ b/access-control-service/src/main/scala/org/apache/texera/service/util/ComputingUnitNodeLocator.scala @@ -0,0 +1,114 @@ +/* + * 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.util + +import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} +import com.typesafe.scalalogging.LazyLogging +import org.apache.texera.common.config.KubernetesConfig + +import java.io.FileInputStream +import java.net.URI +import java.net.http.{HttpClient, HttpRequest, HttpResponse} +import java.nio.file.{Files, Paths} +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.time.Duration +import javax.net.ssl.{SSLContext, TrustManagerFactory} +import scala.jdk.CollectionConverters._ + +/** + * Finds the node a computing unit's pod runs on, so a mount can be sent to that node's + * mounter. + * + * Resolved here rather than supplied by the caller: letting a caller name the node would + * hand anything that can reach this service the ability to aim requests at any node's + * privileged mounter. + */ +class ComputingUnitNodeLocator(fetchPod: String => Option[JsonNode]) extends LazyLogging { + + def nodeIpOf(cuid: Int): Option[String] = { + val podName = s"${KubernetesConfig.computeUnitPodNamePrefix}-$cuid" + fetchPod(podName).map(_.at("/status/hostIP").asText("")).filter(_.nonEmpty) + } +} + +object ComputingUnitNodeLocator extends ComputingUnitNodeLocator(InClusterKubernetesApi.getPod) + +// One read of one field, so the API is called directly rather than through a Kubernetes +// client library and its transitive dependencies. +private[util] object InClusterKubernetesApi extends LazyLogging { + + private val serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount" + private val mapper = new ObjectMapper() + + private lazy val client: HttpClient = + HttpClient + .newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .sslContext(clusterSslContext) + .build() + + /** Trusts only the cluster CA, so this talks to the API server and nothing else. */ + private def clusterSslContext: SSLContext = { + val certificates = { + val stream = new FileInputStream(s"$serviceAccountDir/ca.crt") + try CertificateFactory.getInstance("X.509").generateCertificates(stream).asScala.toList + finally stream.close() + } + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType) + keyStore.load(null, null) + certificates.zipWithIndex.foreach { + case (certificate, index) => keyStore.setCertificateEntry(s"cluster-ca-$index", certificate) + } + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + trustManagerFactory.init(keyStore) + val context = SSLContext.getInstance("TLS") + context.init(null, trustManagerFactory.getTrustManagers, null) + context + } + + def getPod(podName: String): Option[JsonNode] = { + val host = sys.env.getOrElse("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc") + val port = sys.env.getOrElse("KUBERNETES_SERVICE_PORT", "443") + val namespace = KubernetesConfig.computeUnitPoolNamespace + val token = Files.readString(Paths.get(s"$serviceAccountDir/token")).trim + + val request = HttpRequest + .newBuilder() + .uri(URI.create(s"https://$host:$port/api/v1/namespaces/$namespace/pods/$podName")) + .header("Authorization", s"Bearer $token") + .timeout(Duration.ofSeconds(10)) + .GET() + .build() + + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + response.statusCode() match { + case 200 => Some(mapper.readTree(response.body())) + case 404 => None + case other => + // Distinguished from a missing pod: a missing RBAC rule or an unreachable API + // server must not read as "the computing unit is not running". + throw new IllegalStateException( + s"cannot read pod $podName in namespace $namespace: HTTP $other ${response.body()}" + ) + } + } +} diff --git a/access-control-service/src/main/scala/org/apache/texera/service/util/MounterClient.scala b/access-control-service/src/main/scala/org/apache/texera/service/util/MounterClient.scala new file mode 100644 index 00000000000..da73ecb72b7 --- /dev/null +++ b/access-control-service/src/main/scala/org/apache/texera/service/util/MounterClient.scala @@ -0,0 +1,149 @@ +/* + * 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.util + +import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} +import com.fasterxml.jackson.module.scala.DefaultScalaModule + +import java.net.{HttpURLConnection, URI} +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Paths} +import scala.util.Using + +/** + * HTTP client for the per-node `texera-mounter`. + * + * The mounter is privileged and listens on a hostPort, so it admits exactly one caller: it + * requires a service-account token minted for its own audience and checks it with the + * Kubernetes TokenReview API (see `authenticate_caller` in `bin/mounter/mounter.py`). That + * caller is this service, which is why the client lives here. + */ +class MounterClient(tokenPath: String = MounterDefaults.ProjectedTokenPath) { + + import MounterClient._ + + private val mapper: ObjectMapper = new ObjectMapper().registerModule(DefaultScalaModule) + + private val connectTimeoutMs = 10000 + private val readTimeoutMs = 35000 + + private def baseUrl(nodeIp: String, port: Int): String = s"http://$nodeIp:$port" + + // Read per call, not cached: the kubelet rewrites the projected token in place. + private def mounterToken(): String = + try Files.readString(Paths.get(tokenPath)).trim + catch { + case e: Exception => + throw new IllegalStateException( + s"cannot read the mounter service-account token at $tokenPath; without it this " + + s"service cannot authenticate to the node mounter: ${e.getMessage}" + ) + } + + def mount( + nodeIp: String, + port: Int, + cuid: String, + repositoryName: String, + commitHash: String, + jwt: String, + fileServiceBase: String + ): String = { + MountRequestValidation.validate(cuid, repositoryName, commitHash) + + val body = mapper.createObjectNode() + body.put("cuid", cuid) + body.put("repositoryName", repositoryName) + body.put("commitHash", commitHash) + body.put("jwt", jwt) + body.put("fileServiceBase", fileServiceBase) + + val response = send("POST", s"${baseUrl(nodeIp, port)}/mount", Some(body.toString)) + Option(response.get("mountPath")).map(_.asText()).getOrElse("") + } + + private def send(method: String, url: String, body: Option[String]): JsonNode = { + val connection = URI.create(url).toURL.openConnection().asInstanceOf[HttpURLConnection] + connection.setRequestMethod(method) + connection.setRequestProperty("Authorization", s"Bearer ${mounterToken()}") + connection.setConnectTimeout(connectTimeoutMs) + connection.setReadTimeout(readTimeoutMs) + body.foreach { _ => + connection.setRequestProperty("Content-Type", "application/json") + connection.setDoOutput(true) + } + try { + body.foreach(payload => + Using(connection.getOutputStream)(_.write(payload.getBytes(StandardCharsets.UTF_8))) + ) + val code = connection.getResponseCode + val stream = + if (code >= 200 && code < 300) connection.getInputStream else connection.getErrorStream + val responseBody = Option(stream) + .map(s => new String(s.readAllBytes(), StandardCharsets.UTF_8)) + .getOrElse("") + if (code < 200 || code >= 300) { + throw new MounterRequestException(code, s"mounter $method failed: HTTP $code $responseBody") + } + if (responseBody.isEmpty) mapper.createObjectNode() else mapper.readTree(responseBody) + } finally { + connection.disconnect() + } + } +} + +object MounterClient extends MounterClient(MounterDefaults.ProjectedTokenPath) { + + class MounterRequestException(val status: Int, message: String) extends RuntimeException(message) +} + +private object MounterDefaults { + + /** Where the chart projects the token. Not configurable: the same chart fixes both ends. */ + val ProjectedTokenPath = "/var/run/secrets/texera/mounter/token" +} + +/** + * The shape a mount request has to have before anything acts on it. + * + * The mounter joins these into a path and creates the directory, so each has to be a single + * safe segment: no separator, no "..", and a leading alphanumeric so a value cannot be read + * as a geesefs flag. The mounter enforces this itself, being privileged; this is the same + * rule applied earlier, so a malformed request is refused by name rather than by whatever + * it fails next. + */ +private[service] object MountRequestValidation { + + private val cuidPattern = "^[0-9]+$".r + private val segmentPattern = "^[A-Za-z0-9][A-Za-z0-9._-]*$".r + + def validate(cuid: String, repositoryName: String, commitHash: String): Unit = { + if (cuid == null || cuidPattern.findFirstIn(cuid).isEmpty) { + throw new IllegalArgumentException(s"cuid must be a non-negative integer, got '$cuid'") + } + requireSegment(repositoryName, "repositoryName") + requireSegment(commitHash, "commitHash") + } + + private def requireSegment(value: String, field: String): Unit = + if (value == null || segmentPattern.findFirstIn(value).isEmpty) { + throw new IllegalArgumentException(s"$field must be a single path segment, got '$value'") + } +} diff --git a/access-control-service/src/test/scala/org/apache/texera/service/resource/ComputingUnitMountResourceSpec.scala b/access-control-service/src/test/scala/org/apache/texera/service/resource/ComputingUnitMountResourceSpec.scala new file mode 100644 index 00000000000..926568fc831 --- /dev/null +++ b/access-control-service/src/test/scala/org/apache/texera/service/resource/ComputingUnitMountResourceSpec.scala @@ -0,0 +1,322 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import jakarta.ws.rs.{ + BadRequestException, + ForbiddenException, + InternalServerErrorException, + ServiceUnavailableException +} +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.enums.{ + PrivilegeEnum, + UserRoleEnum, + WorkflowComputingUnitTypeEnum +} +import org.apache.texera.dao.jooq.generated.tables.daos.{ + ComputingUnitUserAccessDao, + DatasetDao, + DatasetVersionDao, + UserDao, + WorkflowComputingUnitDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + ComputingUnitUserAccess, + Dataset, + DatasetVersion, + User, + WorkflowComputingUnit +} +import org.apache.texera.service.resource.ComputingUnitMountResource.MountRequest +import org.apache.texera.service.util.{ComputingUnitNodeLocator, MounterClient} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.collection.mutable + +class ComputingUnitMountResourceSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with MockTexeraDB { + + private val owner: User = { + val user = new User + user.setUid(1) + user.setName("owner") + user.setEmail("owner@test.com") + user.setRole(UserRoleEnum.REGULAR) + user + } + + private val reader: User = { + val user = new User + user.setUid(2) + user.setName("reader") + user.setEmail("reader@test.com") + user.setRole(UserRoleEnum.REGULAR) + user + } + + private val stranger: User = { + val user = new User + user.setUid(3) + user.setName("stranger") + user.setEmail("stranger@test.com") + user.setRole(UserRoleEnum.REGULAR) + user + } + + private val ownedDataset: Dataset = { + val dataset = new Dataset + dataset.setDid(1) + dataset.setName("owned") + dataset.setDescription("") + dataset.setRepositoryName("dataset-1") + dataset.setOwnerUid(owner.getUid) + dataset.setIsPublic(false) + dataset + } + + private val ownedVersion: DatasetVersion = { + val version = new DatasetVersion + version.setDvid(1) + version.setDid(ownedDataset.getDid) + version.setName("v1") + version.setCreatorUid(owner.getUid) + version.setVersionHash("abc123") + version + } + + // Another user's private dataset: the only fixture whose refusal comes from read access + // rather than from the repository not existing. + private val strangersDataset: Dataset = { + val dataset = new Dataset + dataset.setDid(2) + dataset.setName("private") + dataset.setDescription("") + dataset.setRepositoryName("dataset-2") + dataset.setOwnerUid(stranger.getUid) + dataset.setIsPublic(false) + dataset + } + + private val strangersVersion: DatasetVersion = { + val version = new DatasetVersion + version.setDvid(2) + version.setDid(strangersDataset.getDid) + version.setName("v1") + version.setCreatorUid(stranger.getUid) + version.setVersionHash("def456") + version + } + + private val computingUnit: WorkflowComputingUnit = { + val unit = new WorkflowComputingUnit + unit.setCuid(7) + unit.setUid(owner.getUid) + unit.setName("test-cu") + unit.setType(WorkflowComputingUnitTypeEnum.kubernetes) + unit + } + + /** Records what the resource asked the node mounter to do, without any HTTP. */ + /** Records what the resource asked the node mounter to do, without any HTTP. */ + private class RecordingMounter extends MounterClient("/nonexistent-token") { + val mounts: mutable.Buffer[(String, Int, String, String, String, String, String)] = + mutable.Buffer() + var failWith: Option[Throwable] = None + + override def mount( + nodeIp: String, + port: Int, + cuid: String, + repositoryName: String, + commitHash: String, + jwt: String, + fileServiceBase: String + ): String = { + failWith.foreach(throw _) + mounts += ((nodeIp, port, cuid, repositoryName, commitHash, jwt, fileServiceBase)) + s"/var/lib/texera-mounts/$cuid/$repositoryName/$commitHash" + } + + } + + private val scheduledOnNode = new ComputingUnitNodeLocator(_ => None) { + override def nodeIpOf(cuid: Int): Option[String] = Some("10.0.0.4") + } + + private val notScheduled = new ComputingUnitNodeLocator(_ => None) { + override def nodeIpOf(cuid: Int): Option[String] = None + } + + private def resource( + mounter: MounterClient, + mounterEnabled: Boolean = true, + nodeLocator: ComputingUnitNodeLocator = scheduledOnNode, + mounterPort: Option[Int] = Some(8100), + fileServiceUrl: Option[String] = Some("http://file-service-svc:9092") + ) = + new ComputingUnitMountResource( + mounterEnabled, + mounterPort, + fileServiceUrl, + nodeLocator, + mounter + ) + + private def sessionOf(user: User) = new SessionUser(user) + + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + val userDao = new UserDao(getDSLContext.configuration()) + userDao.insert(owner) + userDao.insert(reader) + userDao.insert(stranger) + new WorkflowComputingUnitDao(getDSLContext.configuration()).insert(computingUnit) + val datasetDao = new DatasetDao(getDSLContext.configuration()) + datasetDao.insert(ownedDataset) + datasetDao.insert(strangersDataset) + val versionDao = new DatasetVersionDao(getDSLContext.configuration()) + versionDao.insert(ownedVersion) + versionDao.insert(strangersVersion) + val access = new ComputingUnitUserAccess() + access.setCuid(computingUnit.getCuid) + access.setUid(reader.getUid) + access.setPrivilege(PrivilegeEnum.READ) + new ComputingUnitUserAccessDao(getDSLContext.configuration()).insert(access) + } + + override protected def afterAll(): Unit = closeConnectionPool() + + "mount" should "forward the request to the mounter on the unit's own node" in { + val mounter = new RecordingMounter + val info = resource(mounter).mount(7, MountRequest("dataset-1", "abc123"), sessionOf(owner)) + + info.repositoryName shouldBe "dataset-1" + info.commitHash shouldBe "abc123" + info.mountPath shouldBe "/var/lib/texera-mounts/7/dataset-1/abc123" + + val (nodeIp, port, cuid, repository, commit, jwt, fileServiceBase) = mounter.mounts.head + nodeIp shouldBe "10.0.0.4" + port shouldBe 8100 + cuid shouldBe "7" + repository shouldBe "dataset-1" + commit shouldBe "abc123" + fileServiceBase shouldBe "http://file-service-svc:9092" + jwt should not be empty + } + + // The only fixture with READ and not WRITE: tells "any access is enough" apart from + // "write access is required". + it should "refuse a read-only sharee, who may use the unit but not change what it sees" in { + val mounter = new RecordingMounter + a[ForbiddenException] should be thrownBy + resource(mounter).mount(7, MountRequest("dataset-1", "abc123"), sessionOf(reader)) + mounter.mounts shouldBe empty + } + + it should "refuse a repository the user cannot read" in { + val mounter = new RecordingMounter + a[ForbiddenException] should be thrownBy + resource(mounter).mount(7, MountRequest("dataset-2", "def456"), sessionOf(owner)) + // The mounter authorizes nothing, so a refusal has to happen before it is asked. + mounter.mounts shouldBe empty + } + + it should "refuse a repository that does not exist" in { + val mounter = new RecordingMounter + a[ForbiddenException] should be thrownBy + resource(mounter).mount(7, MountRequest("dataset-404", "abc123"), sessionOf(owner)) + mounter.mounts shouldBe empty + } + + it should "refuse a commit that belongs to another repository" in { + val mounter = new RecordingMounter + a[ForbiddenException] should be thrownBy + resource(mounter).mount(7, MountRequest("dataset-1", "def456"), sessionOf(owner)) + mounter.mounts shouldBe empty + } + + it should "refuse a user with no access to the computing unit" in { + val mounter = new RecordingMounter + a[ForbiddenException] should be thrownBy + resource(mounter).mount(7, MountRequest("dataset-1", "abc123"), sessionOf(stranger)) + mounter.mounts shouldBe empty + } + + it should "refuse a computing unit that does not exist" in { + val mounter = new RecordingMounter + a[ForbiddenException] should be thrownBy + resource(mounter).mount(999, MountRequest("dataset-1", "abc123"), sessionOf(owner)) + mounter.mounts shouldBe empty + } + + it should "report a misconfigured port rather than dialling a wrong one" in { + val mounter = new RecordingMounter + an[InternalServerErrorException] should be thrownBy + resource(mounter, mounterPort = None) + .mount(7, MountRequest("dataset-1", "abc123"), sessionOf(owner)) + mounter.mounts shouldBe empty + } + + it should "report a missing file-service address rather than mounting against nothing" in { + val mounter = new RecordingMounter + an[InternalServerErrorException] should be thrownBy + resource(mounter, fileServiceUrl = None) + .mount(7, MountRequest("dataset-1", "abc123"), sessionOf(owner)) + mounter.mounts shouldBe empty + } + + it should "answer plainly when the deployment did not enable mounting" in { + val mounter = new RecordingMounter + a[ServiceUnavailableException] should be thrownBy + resource(mounter, mounterEnabled = false) + .mount(7, MountRequest("dataset-1", "abc123"), sessionOf(owner)) + mounter.mounts shouldBe empty + } + + it should "refuse while the unit's pod is not on a node yet" in { + val mounter = new RecordingMounter + a[BadRequestException] should be thrownBy + resource(mounter, nodeLocator = notScheduled) + .mount(7, MountRequest("dataset-1", "abc123"), sessionOf(owner)) + mounter.mounts shouldBe empty + } + + it should "report a rejected path as a bad request rather than a server error" in { + val mounter = new RecordingMounter + mounter.failWith = Some(new IllegalArgumentException("repositoryName must be a single segment")) + val failure = the[BadRequestException] thrownBy + resource(mounter).mount(7, MountRequest("../evil", "abc123"), sessionOf(owner)) + failure.getMessage should include("repositoryName") + } + + it should "relay a refusal from the mounter as a bad request" in { + val mounter = new RecordingMounter + mounter.failWith = Some(new MounterClient.MounterRequestException(400, "mounter said no")) + a[BadRequestException] should be thrownBy + resource(mounter).mount(7, MountRequest("dataset-1", "abc123"), sessionOf(owner)) + } +} diff --git a/access-control-service/src/test/scala/org/apache/texera/service/util/ComputingUnitNodeLocatorSpec.scala b/access-control-service/src/test/scala/org/apache/texera/service/util/ComputingUnitNodeLocatorSpec.scala new file mode 100644 index 00000000000..33f3a6a0347 --- /dev/null +++ b/access-control-service/src/test/scala/org/apache/texera/service/util/ComputingUnitNodeLocatorSpec.scala @@ -0,0 +1,66 @@ +/* + * 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.util + +import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} +import org.apache.texera.common.config.KubernetesConfig +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.collection.mutable + +class ComputingUnitNodeLocatorSpec extends AnyFlatSpec with Matchers { + + private val mapper = new ObjectMapper() + private def pod(json: String): JsonNode = mapper.readTree(json) + + private def locator( + pods: Map[String, JsonNode], + asked: mutable.Buffer[String] = mutable.Buffer() + ): ComputingUnitNodeLocator = + new ComputingUnitNodeLocator(name => { asked += name; pods.get(name) }) + + private val podName = s"${KubernetesConfig.computeUnitPodNamePrefix}-7" + + "nodeIpOf" should "return the host IP of the computing unit's pod" in { + locator(Map(podName -> pod("""{"status":{"hostIP":"10.0.0.4"}}"""))).nodeIpOf(7) shouldBe + Some("10.0.0.4") + } + + it should "ask for the pod named by the configured prefix and the cuid" in { + val asked = mutable.Buffer[String]() + locator(Map.empty, asked).nodeIpOf(7) + asked should contain only podName + } + + it should "return None when the computing unit has no pod" in { + locator(Map.empty).nodeIpOf(7) shouldBe None + } + + it should "return None while the pod is not scheduled yet" in { + locator(Map(podName -> pod("""{"status":{"phase":"Pending"}}"""))).nodeIpOf(7) shouldBe None + locator(Map(podName -> pod("""{"status":{"hostIP":""}}"""))).nodeIpOf(7) shouldBe None + } + + it should "propagate a lookup failure rather than reporting the unit as unscheduled" in { + val failing = new ComputingUnitNodeLocator(_ => throw new IllegalStateException("forbidden")) + an[IllegalStateException] should be thrownBy failing.nodeIpOf(7) + } +} diff --git a/access-control-service/src/test/scala/org/apache/texera/service/util/MounterClientSpec.scala b/access-control-service/src/test/scala/org/apache/texera/service/util/MounterClientSpec.scala new file mode 100644 index 00000000000..cee10dd9dd9 --- /dev/null +++ b/access-control-service/src/test/scala/org/apache/texera/service/util/MounterClientSpec.scala @@ -0,0 +1,143 @@ +/* + * 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.util + +import com.sun.net.httpserver.{HttpExchange, HttpServer} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import scala.collection.mutable + +class MounterClientSpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll { + + private var server: HttpServer = _ + private var port: Int = _ + private var client: MounterClient = _ + + private val received = mutable.Map[String, (String, String, String)]() + private val authorization = mutable.Map[String, String]() + private var refuseWith: Option[Int] = None + + private def bodyOf(exchange: HttpExchange): String = + new String(exchange.getRequestBody.readAllBytes(), StandardCharsets.UTF_8) + + private def reply(exchange: HttpExchange, status: Int, body: String): Unit = { + val bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.getResponseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(status, bytes.length.toLong) + exchange.getResponseBody.write(bytes) + exchange.close() + } + + private def record(exchange: HttpExchange, path: String): Unit = { + authorization(path) = Option(exchange.getRequestHeaders.getFirst("Authorization")).getOrElse("") + received(path) = ( + exchange.getRequestMethod, + Option(exchange.getRequestURI.getQuery).getOrElse(""), + bodyOf(exchange) + ) + } + + override def beforeAll(): Unit = { + val tokenFile = Files.createTempFile("mounter-token", "") + Files.writeString(tokenFile, "the-service-account-token\n") + tokenFile.toFile.deleteOnExit() + client = new MounterClient(tokenFile.toString) + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0) + server.createContext( + "/mount", + (exchange: HttpExchange) => { + record(exchange, "/mount") + refuseWith match { + case Some(status) => reply(exchange, status, """{"error":"nope"}""") + case None => + reply(exchange, 200, """{"mountPath":"/var/lib/texera-mounts/7/dataset-1/abc123"}""") + } + } + ) + server.start() + port = server.getAddress.getPort + } + + override def afterAll(): Unit = if (server != null) server.stop(0) + + private val nodeIp = "127.0.0.1" + + "MounterClient.mount" should "post the mount request and return the mounter's path" in { + val path = client.mount(nodeIp, port, "7", "dataset-1", "abc123", "user-jwt", "http://fs:9092") + + path shouldBe "/var/lib/texera-mounts/7/dataset-1/abc123" + val (method, _, requestBody) = received("/mount") + method shouldBe "POST" + requestBody should include(""""cuid":"7"""") + requestBody should include(""""repositoryName":"dataset-1"""") + requestBody should include(""""commitHash":"abc123"""") + requestBody should include(""""jwt":"user-jwt"""") + requestBody should include(""""fileServiceBase":"http://fs:9092"""") + } + + it should "identify itself with the projected service-account token" in { + client.mount(nodeIp, port, "7", "dataset-1", "abc123", "user-jwt", "http://fs:9092") + authorization("/mount") shouldBe "Bearer the-service-account-token" + } + + it should "carry the mounter's status back on a refusal" in { + refuseWith = Some(400) + try { + val failure = the[MounterClient.MounterRequestException] thrownBy + client.mount(nodeIp, port, "7", "dataset-1", "abc123", "user-jwt", "http://fs:9092") + failure.status shouldBe 400 + } finally refuseWith = None + } + + // The escapes reported on the infrastructure PR: each would otherwise be joined into the + // mount path, and the directory created before the mounter's own validation could matter. + it should "refuse a cuid that is not a single numeric segment, without calling the mounter" in { + received.remove("/mount") + Seq("5/../8", "../..", "/absolute", "", "7x").foreach { cuid => + an[IllegalArgumentException] should be thrownBy + client.mount(nodeIp, port, cuid, "dataset-1", "abc123", "jwt", "http://fs:9092") + } + received should not contain key("/mount") + } + + it should "refuse a repository or commit that is not a single safe segment" in { + received.remove("/mount") + Seq("../evil", "a/b", "-o", "", ".hidden/../x").foreach { bad => + an[IllegalArgumentException] should be thrownBy + client.mount(nodeIp, port, "7", bad, "abc123", "jwt", "http://fs:9092") + an[IllegalArgumentException] should be thrownBy + client.mount(nodeIp, port, "7", "dataset-1", bad, "jwt", "http://fs:9092") + } + received should not contain key("/mount") + } + + it should "fail loudly when the service-account token is missing" in { + val withoutToken = new MounterClient("/nonexistent/mounter/token") + val failure = the[IllegalStateException] thrownBy + withoutToken.mount(nodeIp, port, "7", "dataset-1", "abc123", "jwt", "http://fs:9092") + failure.getMessage should include("/nonexistent/mounter/token") + } +} diff --git a/bin/k8s/templates/base/access-control-service/access-control-service-deployment.yaml b/bin/k8s/templates/base/access-control-service/access-control-service-deployment.yaml index 85fbbdd5f76..a686e174180 100644 --- a/bin/k8s/templates/base/access-control-service/access-control-service-deployment.yaml +++ b/bin/k8s/templates/base/access-control-service/access-control-service-deployment.yaml @@ -55,6 +55,18 @@ spec: value: {{ .Values.workflowComputingUnitPool.name }} - name: KUBERNETES_COMPUTE_UNIT_POOL_NAMESPACE value: {{ .Values.workflowComputingUnitPool.namespace }} + {{- if .Values.mounter.enabled }} + # Passed from the chart rather than left to the kubernetes.conf defaults, so that + # changing a value here reaches the service that reads it. + - name: KUBERNETES_MOUNTER_ENABLED + value: "{{ .Values.mounter.enabled }}" + - name: KUBERNETES_COMPUTE_UNIT_POD_NAME_PREFIX + value: {{ .Values.workflowComputingUnitPool.podNamePrefix }} + - name: KUBERNETES_MOUNTER_PORT + value: "{{ .Values.mounter.port }}" + - name: FILE_SERVICE_URL + value: http://{{ .Values.fileService.name }}-svc:{{ .Values.fileService.service.port }} + {{- end }} {{- if .Values.litellm.enabled }} # LLM gateway used to serve /api/chat and /api/models to the agent service. - name: LITELLM_BASE_URL diff --git a/bin/k8s/templates/base/access-control-service/access-control-service-service-account.yaml b/bin/k8s/templates/base/access-control-service/access-control-service-service-account.yaml index 44cd6bbf07e..c4e603e7997 100644 --- a/bin/k8s/templates/base/access-control-service/access-control-service-service-account.yaml +++ b/bin/k8s/templates/base/access-control-service/access-control-service-service-account.yaml @@ -18,16 +18,13 @@ # Dedicated identity for the access-control-service. # -# The access-control-service is intended to become the only component allowed to ask the -# per-node mounter to mount a dataset: it is already the JWT and computing-unit-access -# authorization proxy, so it is the natural place for the decision "may this user mount -# onto this CU?". Giving it its own identity now is what makes that switch a config -# change later, rather than a redesign -- running as the namespace's `default` -# ServiceAccount (shared with every pod that does not name one) would make the mounter -# unable to tell this service apart from anything else. +# The access-control-service is the only component allowed to ask the per-node mounter to +# mount a repository: it is already the JWT and computing-unit-access authorization proxy, +# so it is the natural place for the decision "may this user mount onto this CU?" -- +# running as the namespace's `default` ServiceAccount (shared with every pod that does not +# name one) would make the mounter unable to tell this service apart from anything else. # -# The enforcement mechanism already exists and is live in this PR; only the identity in -# the allow-list is still provisional: +# How that is enforced: # # 1. The calling pod mounts a projected `serviceAccountToken` volume bound to the # audience `texera-mounter` and sends that token as a Bearer header on each mounter @@ -44,22 +41,41 @@ # 3. The mounter's own ServiceAccount is bound to the built-in `system:auth-delegator` # ClusterRole, which is what grants it permission to create TokenReviews. # -# TODO(dataset-mount): today `mounter.allowedCallers` defaults to the computing-unit -# manager, because that is the service which actually calls the mounter. Point it at this -# account -- and move the mount endpoints behind this service -- once access-control-service -# takes over as the mount authority. Nothing else has to change. -# -# Either way, computing-unit pods are never an accepted caller even though they can reach +# Computing-unit pods are never an accepted caller even though they can reach # the mounter's hostPort: they hold no token for this audience, so a mount request forged # from user code fails the TokenReview regardless of what it puts in the request body. And # because authenticating the caller only establishes who is asking, the mounter still # validates every path component of the request itself. # -# This account needs no RBAC rules: it is an identity to authenticate as, not a client -# of the Kubernetes API. +# The Role below lets this service read which node a computing unit's pod runs on, so it can +# forward a mount to that node's mounter. apiVersion: v1 kind: ServiceAccount metadata: name: {{ .Values.accessControlService.serviceAccountName }} namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Release.Name }}-access-control-service-pod-reader + namespace: {{ .Values.workflowComputingUnitPool.namespace }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-access-control-service-pod-reader-binding + namespace: {{ .Values.workflowComputingUnitPool.namespace }} +subjects: + - kind: ServiceAccount + name: {{ .Values.accessControlService.serviceAccountName }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: Role + name: {{ .Release.Name }}-access-control-service-pod-reader + apiGroup: rbac.authorization.k8s.io {{- end }} diff --git a/build.sbt b/build.sbt index 035edbb68cc..a62644864d2 100644 --- a/build.sbt +++ b/build.sbt @@ -122,7 +122,7 @@ ThisBuild / excludeDependencies += ExclusionRule("log4j", "log4j") lazy val Util = (project in file("common/util")).settings(commonModuleSettings) lazy val DAO = (project in file("common/dao")).settings(commonModuleSettings) lazy val Config = (project in file("common/config")).settings(commonModuleSettings) -lazy val Resource = (project in file("common/resource")).settings(commonModuleSettings) +lazy val Resource = (project in file("common/resource")).settings(commonModuleSettings).dependsOn(DAO) lazy val Auth = (project in file("common/auth")) .settings(commonModuleSettings) .configs(Test) diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala b/common/resource/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala similarity index 100% rename from file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala rename to common/resource/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceTables.scala b/common/resource/src/main/scala/org/apache/texera/service/resource/ResourceTables.scala similarity index 100% rename from file-service/src/main/scala/org/apache/texera/service/resource/ResourceTables.scala rename to common/resource/src/main/scala/org/apache/texera/service/resource/ResourceTables.scala