This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] 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 dcec7f3601 feat(LakeFS-mount): authorize and perform a repository
mount (#6896)
dcec7f3601 is described below
commit dcec7f3601e61436857a619365f7a2c5963c7589
Author: ali risheh <[email protected]>
AuthorDate: Tue Sep 15 02:48:54 2026 +0000
feat(LakeFS-mount): authorize and perform a repository mount (#6896)
### What changes were proposed in this PR?
Let a computing unit have a versioned LakeFS repository mounted into it,
on the infrastructure merged in #6866. This is the authorization step of
#6606: the endpoint that decides whether a mount may happen, and
performs it.
The per-node mounter is the one privileged component — root on every
node, listening on a hostPort — so it admits exactly one caller,
verified with `TokenReview` against a token bound to the
`texera-mounter` audience. Only access-control-service holds one, which
is also why it is the right place: it is already where the deployment
decides whether a user may act on a computing unit.
**The mounter authorizes nothing.** It performs what it is told, so
every decision has to be made before it is asked. The endpoint makes
four:
1. **The request has a shape a mount path can be built from** — each
component a single safe segment, so a separator, a `..` or a leading `-`
is refused by name rather than by whatever it fails next. The mounter
checks this too, being privileged; here it is checked first, before any
database work.
2. **The caller holds write access to the computing unit.** Mounting
puts data into someone's unit, so it takes the same privilege as any
other change to one; a read-only sharee may use the unit, not alter what
it can see.
3. **The caller may read the repository.** It is matched by name across
datasets and models — by name rather than by parsing an id out of it,
because an earlier migration backfilled that column from the dataset's
plain name — and anything other than exactly one match is refused rather
than resolved arbitrarily.
4. **The commit belongs to that repository**, so a well-formed but
unrelated digest cannot be mounted.
Only then does it resolve which node the unit's pod is on — itself,
rather than accepting one from the caller, or anything that can reach it
could aim requests at any node's privileged mounter — and forward.
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
that it exists and spending a node's resources on it. The read and
ownership rules move to a module both services share, so they decide
from one definition instead of two that can drift.
Mounts are released when the computing unit's pod is deleted, by the
mounter's pod watcher, so there is no unmount path. No new
configuration: the mounter's port and file-service's root are read from
the environment the chart already sets, so each value is written once,
in the chart. The whole feature stays behind `mounter.enabled`; with it
off the chart renders no new object and no new environment variable.
Nothing here is used only by the follow-up. Resolving a logical dataset
or model path to a repository and commit, and the engine client that
calls this endpoint, land in #6895 where they have callers.
### Any related issues, documentation, discussions?
Closes #6863 · part of #6606 · builds on #6866.
### How was this PR tested?
- New unit tests cover each refusal — no access to the unit, read-only
access to it, a repository the caller cannot read, one that does not
exist, a commit belonging to another repository, a malformed component,
a unit not yet scheduled, and mounting disabled or misconfigured — each
asserting the mounter was never asked. Plus the node lookup and the
mounter client's own request shape, service-account token and error
handling. Compile and tests green across the touched modules.
- Validated end to end on a single-node minikube with
`mounter.enabled=true`, deployed from this chart rather than by hand so
the wiring itself is what ran. Every call was made from inside an
unprivileged computing-unit pod: authorized mount `200`, the same mount
again `200`, unauthenticated `401`, a computing unit the caller may not
use `403`, a repository that does not exist `403`, a commit from another
repository `403`, a malformed name `400`, and calling the node mounter
directly `401` while its health probe stays open at `200`.
- The mount itself: `fuse.geesefs` at the expected path inside the pod,
file byte-exact, read-only, in a pod whose effective capabilities are
empty. The mounter's log confirms GeeseFS was pointed at file-service's
root.
- Terminating the computing unit took GeeseFS mounts from one to zero
and emptied the mount root, which is why there is no unmount endpoint.
- With the feature off, the service account, the pod-reader role, the
mounter objects, the projected token and every new environment variable
render zero times.
- The cluster run caught a real bug: file-service was being addressed
through an endpoint whose path had to be stripped back off, when what
GeeseFS needs is the root the S3 proxy is served at. Fixed.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../texera/service/AccessControlService.scala | 2 +
.../resource/ComputingUnitMountResource.scala | 216 ++++++++++++++
.../service/util/ComputingUnitNodeLocator.scala | 114 ++++++++
.../apache/texera/service/util/MounterClient.scala | 157 ++++++++++
.../resource/ComputingUnitMountResourceSpec.scala | 322 +++++++++++++++++++++
.../util/ComputingUnitNodeLocatorSpec.scala | 66 +++++
.../texera/service/util/MounterClientSpec.scala | 153 ++++++++++
.../access-control-service-deployment.yaml | 12 +
.../access-control-service-service-account.yaml | 50 ++--
build.sbt | 2 +-
.../texera/service/resource/ResourceAccess.scala | 0
.../texera/service/resource/ResourceTables.scala | 0
12 files changed, 1076 insertions(+), 18 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 1f50c86c9f..ca2c797ab9 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 0000000000..b521bdf821
--- /dev/null
+++
b/access-control-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitMountResource.scala
@@ -0,0 +1,216 @@
+/*
+ * 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.
+ */
+@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 0000000000..237d6cb2c0
--- /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 0000000000..1cb15f0213
--- /dev/null
+++
b/access-control-service/src/main/scala/org/apache/texera/service/util/MounterClient.scala
@@ -0,0 +1,157 @@
+/*
+ * 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)
+
+ send("POST", s"${baseUrl(nodeIp, port)}/mount", Some(body.toString))
+ }
+
+ private def send(method: String, url: String, body: Option[String]): String
= {
+ 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")
+ }
+ val response: JsonNode =
+ if (responseBody.isEmpty) mapper.createObjectNode() else
mapper.readTree(responseBody)
+ Option(response.get("mountPath"))
+ .filter(node => node.isTextual && node.asText().nonEmpty)
+ .map(_.asText())
+ .getOrElse(
+ throw new IllegalStateException(
+ s"mounter $method reported success without a mount path:
$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 0000000000..926568fc83
--- /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("[email protected]")
+ user.setRole(UserRoleEnum.REGULAR)
+ user
+ }
+
+ private val reader: User = {
+ val user = new User
+ user.setUid(2)
+ user.setName("reader")
+ user.setEmail("[email protected]")
+ user.setRole(UserRoleEnum.REGULAR)
+ user
+ }
+
+ private val stranger: User = {
+ val user = new User
+ user.setUid(3)
+ user.setName("stranger")
+ user.setEmail("[email protected]")
+ 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 0000000000..33f3a6a034
--- /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 0000000000..beb14509ca
--- /dev/null
+++
b/access-control-service/src/test/scala/org/apache/texera/service/util/MounterClientSpec.scala
@@ -0,0 +1,153 @@
+/*
+ * 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 replyWith: Option[(Int, String)] = 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")
+ replyWith match {
+ case Some((status, body)) => reply(exchange, status, body)
+ 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 {
+ replyWith = Some((400, """{"error":"nope"}"""))
+ 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 replyWith = None
+ }
+
+ it should "fail rather than report a mount when the mounter's success names
no path" in {
+ Seq("{}", """{"mountPath":null}""", """{"mountPath":""}""").foreach { body
=>
+ replyWith = Some((200, body))
+ try {
+ an[IllegalStateException] should be thrownBy
+ client.mount(nodeIp, port, "7", "dataset-1", "abc123", "user-jwt",
"http://fs:9092")
+ } finally replyWith = 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 62bb8f3b21..7d63720365 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
@@ -60,6 +60,18 @@ spec:
- name: KUBERNETES_JUPYTER_BASE_URL
value: {{ include "texera.jupyter.basePath" . }}
{{- end }}
+ {{- 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 44cd6bbf07..c4e603e799 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 3853666c68..cb16bda952 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