Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,25 +84,24 @@ abstract class VersionedResourceSearchQueryBuilder[Rec <: Record, P](

/**
* `uid` is null for anonymous callers. Visibility: public only when `uid` is null;
* explicitly-granted only when `includePublic` is false; both when it is true.
* owned or explicitly granted only when `includePublic` is false; both when it is true.
*/
override protected def constructFromClause(
uid: Integer,
params: DashboardResource.SearchQueryParams,
includePublic: Boolean = false
): TableLike[_] = {
val baseJoin = tables.joinWithAccessAndOwner(
Some(if (uid == null) DSL.falseCondition() else tables.access.uidColumn.eq(uid))
)
val baseJoin = tables.joinWithViewerAccessAndOwner(uid)

val condition: Condition =
if (uid == null) {
tables.isPublicColumn.eq(true)
} else {
val ownedOrGranted = tables.access.uidColumn.isNotNull.or(tables.ownerUidColumn.eq(uid))
if (includePublic) {
tables.isPublicColumn.eq(true).or(tables.access.uidColumn.isNotNull)
tables.isPublicColumn.eq(true).or(ownedOrGranted)
} else {
tables.access.uidColumn.isNotNull
ownedOrGranted
}
}
baseJoin.where(condition)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package org.apache.texera.web.resource.dashboard
import com.typesafe.scalalogging.LazyLogging
import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
import org.apache.texera.dao.jooq.generated.Tables.{DATASET, MODEL}
import org.apache.texera.auth.util.OwnerAccess
import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
import org.apache.texera.dao.jooq.generated.tables.User.USER
import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, Model, User}
Expand All @@ -34,6 +35,7 @@ import org.apache.texera.web.resource.dashboard.hub.EntityTables.{AccessTable, B
import org.apache.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
import org.apache.texera.web.resource.dashboard.user.model.ModelResource.DashboardModel
import org.jooq._
import org.jooq.impl.DSL

import java.sql.Timestamp

Expand Down Expand Up @@ -84,19 +86,25 @@ trait VersionedResourceTables[Rec <: Record, P] extends BaseEntityTable with Laz
.on(USER.UID.eq(ownerUidColumn))
}

/** The access join narrowed to `uid`'s own grant, or to none for an anonymous viewer. */
final def joinWithViewerAccessAndOwner(uid: Integer): Table[Record] =
joinWithAccessAndOwner(
Some(if (uid == null) DSL.falseCondition() else access.uidColumn.eq(uid))
)

/** `None` when LakeFS cannot size the resource; callers drop it and report a mismatch. */
final def hydrate(
record: Record,
uid: Integer
): Option[(Integer, DashboardClickableFileEntry)] = {
val resource = pojo(record)
val isOwner = ownerUidOf(resource) == uid
repositorySize(resource).map { size =>
idOf(resource) -> entry(
resource,
record.into(USER).into(classOf[User]).getEmail,
Option(record.get(access.privilegeColumn, classOf[PrivilegeEnum]))
.getOrElse(PrivilegeEnum.NONE),
ownerUidOf(resource) == uid,
OwnerAccess.privilege(isOwner, record.get(access.privilegeColumn, classOf[PrivilegeEnum])),
isOwner,
size
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,18 @@ object HubResource {
mapWorkflowEntries(records, uid)
}

/**
* Hydrates ids of one LakeFS-backed resource into hub entries. Unsizable resources are
* dropped; ids are de-duplicated because the access join can match one twice.
*/
/** The access join is narrowed to `uid`, so each id yields one row carrying only the viewer's grant. */
private[hub] def versionedResourcesByIdsQuery(
tables: VersionedResourceTables[_ <: Record, _],
ids: Seq[Integer],
uid: Integer
) =
context
.select()
.from(tables.joinWithViewerAccessAndOwner(uid))
.where(tables.idColumn.in(ids: _*))

/** Hydrates ids of one LakeFS-backed resource into hub entries; unsizable resources are dropped. */
def fetchDashboardVersionedResourcesByIds(
tables: VersionedResourceTables[_ <: Record, _],
ids: Seq[Integer],
Expand All @@ -289,27 +297,12 @@ object HubResource {
return List.empty[DashboardClickableFileEntry]
}

val records = context
.select()
.from(tables.joinWithAccessAndOwner(None))
.where(tables.idColumn.in(ids: _*))
.groupBy(
tables.idColumn,
tables.nameColumn,
tables.descriptionColumn,
tables.ownerUidColumn,
USER.NAME,
tables.access.idColumn,
tables.access.uidColumn,
USER.UID
)
versionedResourcesByIdsQuery(tables, ids, uid)
.fetch()

records.asScala
.asScala
.flatMap(record => tables.hydrate(record, uid))
.toList
.distinctBy(_._1)
.map(_._2)
.toList
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package org.apache.texera.web.resource.dashboard.user.workflow

import io.dropwizard.auth.Auth
import org.apache.texera.auth.SessionUser
import org.apache.texera.auth.util.OwnerAccess
import org.apache.texera.dao.SqlServer
import org.apache.texera.dao.jooq.generated.Tables._
import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
Expand Down Expand Up @@ -199,7 +200,8 @@ class WorkflowAccessResource() {
.where(WORKFLOW_OF_USER.WID.eq(wid))
.fetchOneInto(classOf[Integer])
if (userUid == workflowOwnerUid) {
throw new ForbiddenException("You cannot modify the owner's permissions!")
// amber is on javax JAX-RS, so it raises its own ForbiddenException with the shared message.
throw new ForbiddenException(OwnerAccess.ownerChangeRefused)
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,18 @@ class DatasetSearchQueryBuilderSpec
} finally shutdownDB()
}

/** Runs `body` with the owner's own grant row replaced by `privilege`, or removed, then restores it. */
private def withOwnerRow(privilege: Option[PrivilegeEnum])(body: => Unit): Unit = {
val dao = new DatasetUserAccessDao(getDSLContext.configuration())
dao.delete(grant(sizedDid, ownerUid, PrivilegeEnum.WRITE))
privilege.foreach(p => dao.insert(grant(sizedDid, ownerUid, p)))
try body
finally {
privilege.foreach(p => dao.delete(grant(sizedDid, ownerUid, p)))
dao.insert(grant(sizedDid, ownerUid, PrivilegeEnum.WRITE))
}
}

private def params(keywords: String*): SearchQueryParams =
SearchQueryParams(keywords = keywords.toList.asJava)

Expand Down Expand Up @@ -391,6 +403,16 @@ class DatasetSearchQueryBuilderSpec
sql should not include "is_public = true"
}

it should "still return the caller's own dataset when they have no grant row" in {
withOwnerRow(None) {
val p = SearchQueryParams(datasetIds = List(sizedDid).asJava)
DatasetSearchQueryBuilder
.constructQuery(ownerUid, p, includePublic = false)
.fetch()
.size() shouldBe 1
}
}

it should "add public datasets to the granted ones when they are included" in {
val sql = sqlFor(uid, includePublic = true)

Expand Down Expand Up @@ -651,6 +673,14 @@ class DatasetSearchQueryBuilderSpec
entryFor(otherUid, sizedDid).dataset.value.accessPrivilege shouldBe PrivilegeEnum.NONE
}

it should "report WRITE for the owner even when their own grant row says READ" in {
requireStub()

withOwnerRow(Some(PrivilegeEnum.READ)) {
entryFor(ownerUid, sizedDid).dataset.value.accessPrivilege shouldBe PrivilegeEnum.WRITE
}
}

it should "tag the entry as a dataset and fill the dataset payload slot" in {
// `entry.workflow shouldBe None` used to sit here and was vacuous:
// `DashboardClickableFileEntry` declares it `= None` and this file does not pass it, so no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ class VersionedResourceTablesSpec
sql should include("dataset_user_access.uid = 42")
}

"joinWithViewerAccessAndOwner" should "narrow the access rows to the viewer, or to none when anonymous" in {
val forViewer =
rendered(
getDSLContext.renderInlined(tables.joinWithViewerAccessAndOwner(Integer.valueOf(42)))
)
forViewer should include("dataset_user_access.uid = 42")

val forAnonymous =
rendered(getDSLContext.renderInlined(tables.joinWithViewerAccessAndOwner(null)))
forAnonymous should not include "dataset_user_access.uid ="
forAnonymous should include("false")
}

"ModelTables" should "name every column the shared query logic reads" in {
modelTables.table.getName shouldBe "model"
modelTables.idColumn.getName shouldBe "mid"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,31 @@ class HubResourceSpec
byEntity shouldBe Map((Wf, 810805) -> 1, (Ds, 820802) -> 2)
}

"versionedResourcesByIdsQuery" should "carry only the viewer's own grant, never another user's" in {
val did = seedDataset(820860, "ds_two_grants")
for ((uid, privilege) <- Seq(ownerUid -> PrivilegeEnum.WRITE, likerUid -> PrivilegeEnum.READ)) {
getDSLContext
.insertInto(DATASET_USER_ACCESS)
.set(DATASET_USER_ACCESS.DID, did)
.set(DATASET_USER_ACCESS.UID, Integer.valueOf(uid))
.set(DATASET_USER_ACCESS.PRIVILEGE, privilege)
.execute()
}
val tables = VersionedResourceTables.DatasetTables
def grantsSeenBy(uid: Integer): Seq[PrivilegeEnum] =
HubResource
.versionedResourcesByIdsQuery(tables, Seq(did), uid)
.fetch()
.asScala
.map(_.get(DATASET_USER_ACCESS.PRIVILEGE))
.toSeq

grantsSeenBy(Integer.valueOf(likerUid)) shouldBe Seq(PrivilegeEnum.READ)
// A user with no grant, and an anonymous viewer, see one row with no grant at all.
grantsSeenBy(Integer.valueOf(thirdUid)) shouldBe Seq(null)
grantsSeenBy(null) shouldBe Seq(null)
}

"getTops" should "list the liked public entity under the 'like' key" in {
recordLikeAction(req, Integer.valueOf(likerUid), UserRequest(wid, Wf), isLike = true)

Expand Down
5 changes: 4 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@
// on Config to read OTEL_* settings from observability.conf.
lazy val Observability =
(project in file("common/observability")).settings(commonModuleSettings).dependsOn(Config)
lazy val Resource = (project in file("common/resource")).settings(commonModuleSettings).dependsOn(DAO)
lazy val Resource = (project in file("common/resource"))
.settings(commonModuleSettings)
.dependsOn(DAO, Auth)
.dependsOn(DAO % "test->test") // reuse MockTexeraDB embedded Postgres in tests
lazy val Auth = (project in file("common/auth"))
.settings(commonModuleSettings)
.configs(Test)
Expand Down Expand Up @@ -231,10 +234,10 @@
// so every suite gets its own forked JVM; sbt runs forked groups one at a
// time by default (Tags.ForkedTestGroup limit), keeping the stacks serial.
Test / fork := true,
Test / forkOptions := (Test / forkOptions).value

Check warning on line 237 in build.sbt

View workflow job for this annotation

GitHub Actions / Bench

The evaluation of `/` inside an anonymous function is prohibited.
.withWorkingDirectory((ThisBuild / baseDirectory).value),
Test / testGrouping := (Test / definedTests).value.map { suite =>
Tests.Group(suite.name, Seq(suite), Tests.SubProcess((Test / forkOptions).value))

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / Bench

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber (ubuntu-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber-integration (ubuntu-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (file-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber-integration (macos-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (access-control-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (workflow-compiling-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (computing-unit-managing-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (notebook-migration-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (workflow-compiling-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (computing-unit-managing-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (access-control-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (file-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (notebook-migration-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (config-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 240 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (config-service)

The evaluation of `/` inside an anonymous function is prohibited.
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,8 @@ object ComputingUnitAccess {

if (record == null) {
PrivilegeEnum.NONE // no such unit
} else if (record.value1().equals(uid)) {
PrivilegeEnum.WRITE // owner
} else {
Option(record.value2()).getOrElse(PrivilegeEnum.NONE)
OwnerAccess.privilege(record.value1().equals(uid), record.value2())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package org.apache.texera.auth.util

import jakarta.ws.rs.ForbiddenException
import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum

/** The rule every shared resource applies to its owner. */
object OwnerAccess {

/** The owner always has WRITE; anyone else has what they were granted, or NONE.
* `granted` is by-name, so a lookup passed in is skipped for the owner.
*/
def privilege(isOwner: Boolean, granted: => PrivilegeEnum): PrivilegeEnum =
if (isOwner) PrivilegeEnum.WRITE else Option(granted).getOrElse(PrivilegeEnum.NONE)

val ownerChangeRefused = "You cannot modify the owner's permissions!"

/** Refuses any change to the owner's own access. */
def requireNotOwner(isOwner: Boolean): Unit =
if (isOwner) throw new ForbiddenException(ownerChangeRefused)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package org.apache.texera.auth.util

import jakarta.ws.rs.ForbiddenException
import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class OwnerAccessSpec extends AnyFlatSpec with Matchers {

"privilege" should "give the owner WRITE whatever they were granted" in {
OwnerAccess.privilege(isOwner = true, PrivilegeEnum.READ) shouldBe PrivilegeEnum.WRITE
OwnerAccess.privilege(isOwner = true, null) shouldBe PrivilegeEnum.WRITE
}

it should "give anyone else their grant, or NONE without one" in {
OwnerAccess.privilege(isOwner = false, PrivilegeEnum.READ) shouldBe PrivilegeEnum.READ
OwnerAccess.privilege(isOwner = false, null) shouldBe PrivilegeEnum.NONE
}

it should "not look up the grant for the owner" in {
var lookups = 0
def lookup(): PrivilegeEnum = { lookups += 1; PrivilegeEnum.READ }

OwnerAccess.privilege(isOwner = true, lookup())
lookups shouldBe 0
OwnerAccess.privilege(isOwner = false, lookup())
lookups shouldBe 1
}

"requireNotOwner" should "refuse a change to the owner's access with a 403" in {
val ex = intercept[ForbiddenException](OwnerAccess.requireNotOwner(isOwner = true))
ex.getResponse.getStatus shouldBe 403
ex.getMessage shouldBe OwnerAccess.ownerChangeRefused
}

it should "allow a change to anyone else's access" in {
noException should be thrownBy OwnerAccess.requireNotOwner(isOwner = false)
}
}
3 changes: 2 additions & 1 deletion common/resource/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ Compile / scalacOptions ++= Seq(
libraryDependencies ++= Seq(
"jakarta.ws.rs" % "jakarta.ws.rs-api" % "3.0.0", // JAX-RS annotations for shared REST resources
"jakarta.annotation" % "jakarta.annotation-api" % "2.1.1", // @PermitAll on shared REST resources
"org.scalatest" %% "scalatest" % "3.2.15" % Test // ScalaTest (for unit tests)
"org.scalatest" %% "scalatest" % "3.2.15" % Test, // ScalaTest (for unit tests)
"org.glassfish.jersey.core" % "jersey-common" % "3.0.12" % Test // JAX-RS runtime, so tests can build a ForbiddenException
)
Loading
Loading