diff --git a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala index 0cd52f49194..d305bf8eb61 100644 --- a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala +++ b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala @@ -22,7 +22,7 @@ import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.typesafe.scalalogging.LazyLogging import jakarta.ws.rs.client.{Client, ClientBuilder, Entity} import jakarta.ws.rs.core._ -import jakarta.ws.rs.{Consumes, GET, POST, Path, Produces} +import jakarta.ws.rs.{Consumes, DELETE, GET, POST, Path, Produces} import org.apache.texera.auth.JwtParser.parseToken import org.apache.texera.auth.SessionUser import org.apache.texera.auth.util.{ComputingUnitAccess, HeaderField} @@ -43,6 +43,11 @@ object AccessControlResource extends LazyLogging { private val wsapiWorkflowWebsocket: Regex = """.*/wsapi/workflow-websocket.*""".r private val apiExecutionsStats: Regex = """.*/api/executions/[0-9]+/stats/[0-9]+.*""".r private val apiExecutionsResultExport: Regex = """.*/api/executions/result/export.*""".r + private val pveRoute: Regex = """^/?(?:auth/)?(?:api/|wsapi/)?pve(?:/.*)?$""".r + // Path patterns whose cuid lives in the URL path rather than the query string. + private val pvePvesCuidPath: Regex = """^/?(?:auth/)?(?:api/|wsapi/)?pve/pves/([0-9]+)$""".r + private val pvePackagesCuidPath: Regex = + """^/?(?:auth/)?(?:api/|wsapi/)?pve/([0-9]+)/[^/]+/packages/.+$""".r /** * Authorize the request based on the path and headers. @@ -60,7 +65,8 @@ object AccessControlResource extends LazyLogging { logger.info(s"Authorizing request for path: $path") path match { - case wsapiWorkflowWebsocket() | apiExecutionsStats() | apiExecutionsResultExport() => + case wsapiWorkflowWebsocket() | apiExecutionsStats() | apiExecutionsResultExport() | + pveRoute() => checkComputingUnitAccess(uriInfo, headers, bodyOpt) case _ => logger.warn(s"No authorization logic for path: $path. Denying access.") @@ -95,7 +101,14 @@ object AccessControlResource extends LazyLogging { qToken.orElse(hToken).orElse(bToken).getOrElse("") } logger.info(s"token extracted from request $token") - val cuid = queryParams.getOrElse("cuid", "") + + val cuid = queryParams.get("cuid").filter(_.nonEmpty).getOrElse { + uriInfo.getPath match { + case pvePvesCuidPath(c) => c + case pvePackagesCuidPath(c) => c + case _ => "" + } + } val cuidInt = try { cuid.toInt @@ -213,6 +226,15 @@ class AccessControlResource extends LazyLogging { logger.info("Request body: " + body) AccessControlResource.authorize(uriInfo, headers, Option(body).map(_.trim).filter(_.nonEmpty)) } + + @DELETE + @Path("/{path:.*}") + def authorizeDelete( + @Context uriInfo: UriInfo, + @Context headers: HttpHeaders + ): Response = { + AccessControlResource.authorize(uriInfo, headers) + } } @Path("/chat") diff --git a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala index 751b51022f4..75f3bacb107 100644 --- a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala +++ b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala @@ -233,4 +233,62 @@ class AccessControlResourceSpec response.getHeaderString(HeaderField.UserName) shouldBe testUser1.getName response.getHeaderString(HeaderField.UserEmail) shouldBe testUser1.getEmail } + + private def mockRequest( + path: String, + cuidQueryParam: Option[String] + ): (UriInfo, HttpHeaders) = { + val mockUriInfo = mock(classOf[UriInfo]) + val mockHttpHeaders = mock(classOf[HttpHeaders]) + + val queryParams = new MultivaluedHashMap[String, String]() + cuidQueryParam.foreach(queryParams.add("cuid", _)) + + val requestHeaders = new MultivaluedHashMap[String, String]() + requestHeaders.add("Authorization", "Bearer " + token) + + when(mockUriInfo.getQueryParameters).thenReturn(queryParams) + when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI)) + when(mockUriInfo.getPath).thenReturn(path) + when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders) + when(mockHttpHeaders.getRequestHeader("Authorization")) + .thenReturn(util.Arrays.asList("Bearer " + token)) + + (mockUriInfo, mockHttpHeaders) + } + + it should "return OK for /pve/system with cuid as query parameter" in { + val (uri, headers) = mockRequest("/pve/system", Some(testCU.getCuid.toString)) + val response = new AccessControlResource().authorizeGet(uri, headers) + + response.getStatus shouldBe Response.Status.OK.getStatusCode + } + + it should "return OK for /pve/pves/{cuid} (cuid extracted from path)" in { + val (uri, headers) = mockRequest(s"/pve/pves/${testCU.getCuid}", None) + val response = new AccessControlResource().authorizeDelete(uri, headers) + + response.getStatus shouldBe Response.Status.OK.getStatusCode + } + + it should "return OK for /pve/{cuid}/{pveName}/packages/{packageName} (cuid extracted from path)" in { + val (uri, headers) = mockRequest(s"/pve/${testCU.getCuid}/myenv/packages/numpy", None) + val response = new AccessControlResource().authorizeDelete(uri, headers) + + response.getStatus shouldBe Response.Status.OK.getStatusCode + } + + it should "return FORBIDDEN for a PVE path with no cuid in query or path" in { + val (uri, headers) = mockRequest("/pve/no-cuid-anywhere", None) + val response = new AccessControlResource().authorizeGet(uri, headers) + + response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode + } + + it should "return FORBIDDEN for a non-PVE / non-whitelisted path" in { + val (uri, headers) = mockRequest("/random/garbage", Some(testCU.getCuid.toString)) + val response = new AccessControlResource().authorizeGet(uri, headers) + + response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode + } } diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala index 80a4f686452..ac07616d509 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala @@ -19,6 +19,8 @@ package org.apache.texera.web.resource.pythonvirtualenvironment +import org.apache.texera.config.KubernetesConfig + import javax.ws.rs._ import javax.ws.rs.core.MediaType import scala.jdk.CollectionConverters._ @@ -37,11 +39,8 @@ class PveResource { @Path("/system") @Produces(Array(MediaType.APPLICATION_JSON)) def getSystemPackages: util.Map[String, util.List[String]] = { + val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled try { - - // TODO: Support Kubernetes environment handling - val isLocal = true - val systemPkgs = PveManager.getSystemPackages(isLocal).toList.asJava @@ -103,9 +102,9 @@ class PveResource { def deletePackage( @PathParam("cuid") cuid: Int, @PathParam("pveName") pveName: String, - @PathParam("packageName") packageName: String, - @QueryParam("isLocal") isLocal: Boolean + @PathParam("packageName") packageName: String ): Response = { + val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled val messages = PveManager.deletePackages( cuid, packageName, diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala index e21f91fada3..efaa266caac 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala @@ -19,6 +19,8 @@ package org.apache.texera.web.resource.pythonvirtualenvironment +import org.apache.texera.config.KubernetesConfig + import javax.websocket._ import javax.websocket.server.ServerEndpoint import java.util.concurrent.LinkedBlockingQueue @@ -41,7 +43,7 @@ class PveWebsocketResource { val cuid = params.get("cuid").get(0).toInt val pveName = params.get("pveName").get(0) - val isLocal = params.get("isLocal").get(0).toBoolean + val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled val action = params.getOrDefault("action", java.util.List.of("create")).get(0) val queue = new LinkedBlockingQueue[String]() diff --git a/bin/computing-unit-master.dockerfile b/bin/computing-unit-master.dockerfile index 0d9d60b79f5..aece464438d 100644 --- a/bin/computing-unit-master.dockerfile +++ b/bin/computing-unit-master.dockerfile @@ -88,11 +88,13 @@ WORKDIR /texera/amber COPY --from=build /texera/amber/requirements.txt /tmp/requirements.txt COPY --from=build /texera/amber/operator-requirements.txt /tmp/operator-requirements.txt +COPY --from=build /texera/amber/system-requirements-lock.txt /tmp/system-requirements-lock.txt # Install Python runtime dependencies RUN apt-get update && apt-get install -y \ python3-pip \ python3-dev \ + python3-venv \ libpq-dev \ && apt-get clean diff --git a/bin/k8s/templates/gateway-routes.yaml b/bin/k8s/templates/gateway-routes.yaml index 55dc40f581a..ab53c184e83 100644 --- a/bin/k8s/templates/gateway-routes.yaml +++ b/bin/k8s/templates/gateway-routes.yaml @@ -118,6 +118,20 @@ spec: - group: gateway.envoyproxy.io kind: Backend name: texera-dynamic-backend + - matches: + - path: + type: PathPrefix + value: /pve + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /api/pve + backendRefs: + - group: gateway.envoyproxy.io + kind: Backend + name: texera-dynamic-backend --- # MinIO Route {{- if .Values.minio.gateway.enabled }} diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts index a2843a51bf3..b9a41c5b5f6 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts @@ -782,7 +782,6 @@ export class ComputingUnitSelectionComponent implements OnInit { getPVEs(): void { const cuId = this.selectedComputingUnit!.computingUnit.cuid; - const isLocal = this.selectedComputingUnit?.computingUnit.type === "local"; this.workflowPveService .fetchPVEs(cuId) @@ -802,7 +801,7 @@ export class ComputingUnitSelectionComponent implements OnInit { })); this.workflowPveService - .getSystemPackages(isLocal) + .getSystemPackages(cuId) .pipe(untilDestroyed(this)) .subscribe({ next: installedResp => { @@ -880,11 +879,10 @@ export class ComputingUnitSelectionComponent implements OnInit { const cuId = this.selectedComputingUnit!.computingUnit.cuid; const env = this.pves[index]; const trimmedName = env.name.trim(); - const isLocal = this.selectedComputingUnit?.computingUnit.type === "local"; env.socket?.close(); - const websocketUrl = this.workflowPveService.getPveWebSocketUrl(cuId, trimmedName, isLocal, action, packages); + const websocketUrl = this.workflowPveService.getPveWebSocketUrl(cuId, trimmedName, action, packages); const socket = new WebSocket(websocketUrl); @@ -967,7 +965,6 @@ export class ComputingUnitSelectionComponent implements OnInit { createVirtualEnvironment(index: number): void { const env = this.pves[index]; const trimmedName = env.name.trim(); - const isLocal = this.selectedComputingUnit?.computingUnit.type === "local"; if (!/^[a-zA-Z0-9]+$/.test(trimmedName)) { this.notificationService.error("Environment name must contain only letters and numbers."); @@ -1066,7 +1063,6 @@ export class ComputingUnitSelectionComponent implements OnInit { private deleteUserPackages(index: number, onDone?: () => void): void { const cuId = this.selectedComputingUnit!.computingUnit.cuid; - const isLocal = this.selectedComputingUnit?.computingUnit.type === "local"; const pveName = this.pves[index].name.trim(); const packagesToDelete = [...this.pves[index].deletingPackages]; @@ -1094,7 +1090,7 @@ export class ComputingUnitSelectionComponent implements OnInit { const pkg = packagesToDelete[deleteIndex]; this.workflowPveService - .deletePackage(cuId, pveName, pkg.name, isLocal) + .deletePackage(cuId, pveName, pkg.name) .pipe(untilDestroyed(this)) .subscribe({ next: messages => { diff --git a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts index 7788cba2701..d3108e47564 100644 --- a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts +++ b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts @@ -49,8 +49,8 @@ export class WorkflowPveService { return params; } - getSystemPackages(isLocal: boolean): Observable { - const params = this.buildBaseParams(); + getSystemPackages(cuid: number): Observable { + const params = this.buildBaseParams().set("cuid", cuid.toString()); return this.http.get("/pve/system", { params }); } @@ -67,8 +67,8 @@ export class WorkflowPveService { return this.http.delete(`/pve/pves/${cuid}`); } - deletePackage(cuid: number, pveName: string, packageName: string, isLocal: boolean) { - const params = this.buildBaseParams().set("isLocal", isLocal.toString()); + deletePackage(cuid: number, pveName: string, packageName: string) { + const params = this.buildBaseParams(); return this.http.delete( `/pve/${cuid}/${encodeURIComponent(pveName)}/packages/${encodeURIComponent(packageName)}`, @@ -76,7 +76,7 @@ export class WorkflowPveService { ); } - getPveWebSocketUrl(cuid: number, pveName: string, isLocal: boolean, action: string, packages: string[] = []): string { + getPveWebSocketUrl(cuid: number, pveName: string, action: string, packages: string[] = []): string { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const query = encodeURIComponent(JSON.stringify(packages)); @@ -88,7 +88,6 @@ export class WorkflowPveService { `?packages=${query}` + `&cuid=${cuid}` + `&pveName=${encodeURIComponent(pveName)}` + - `&isLocal=${isLocal}` + `&action=${action}` + tokenParam );