Skip to content

Commit

Permalink
Add switch orga to legacy routes (#8257)
Browse files Browse the repository at this point in the history
* add switch orga to legacy routes

* fix pattern matching for annotation in orga switching mechanism

* add assertion that directoryName and organizationId must be defined together

* simplify assertion in backend

* undo changes except for making AsyncRedirect a functional component

* include successful disambiguation request even in case orga is not correct

* add changelog entry

* fix backend formatting

* move code checking if ds is accessible via orga switching to separate service

* fix imports

* rename to AccessibleBySwitchingService

* remove unused import

---------

Co-authored-by: Michael Büßemeyer <[email protected]>
  • Loading branch information
MichaelBuessemeyer and Michael Büßemeyer authored Dec 11, 2024
1 parent f909097 commit 002c915
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 159 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
### Fixed
- Fixed that listing datasets with the `api/datasets` route without compression failed due to missing permissions regarding public datasets. [#8249](https://github.com/scalableminds/webknossos/pull/8249)
- Fixed a bug that uploading a zarr dataset with an already existing `datasource-properties.json` file failed. [#8268](https://github.com/scalableminds/webknossos/pull/8268)
- Fixed the organization switching feature for datasets opened via old links. [#8257](https://github.com/scalableminds/webknossos/pull/8257)
- Fixed that the frontend did not ensure a minium length for annotation layer names. Moreover, names starting with a `.` are also disallowed now. [#8244](https://github.com/scalableminds/webknossos/pull/8244)
- Fixed a bug where in the add remote dataset view the dataset name setting was not in sync with the datasource setting of the advanced tab making the form not submittable. [#8245](https://github.com/scalableminds/webknossos/pull/8245)
- Fix read and update dataset route for versions 8 and lower. [#8263](https://github.com/scalableminds/webknossos/pull/8263)
Expand Down
128 changes: 15 additions & 113 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
@@ -1,41 +1,29 @@
package controllers

import org.apache.pekko.actor.ActorSystem
import play.silhouette.api.actions.SecuredRequest
import play.silhouette.api.exceptions.ProviderException
import play.silhouette.api.services.AuthenticatorResult
import play.silhouette.api.util.{Credentials, PasswordInfo}
import play.silhouette.api.{LoginInfo, Silhouette}
import play.silhouette.impl.providers.CredentialsProvider
import com.scalableminds.util.accesscontext.{AuthorizedAccessContext, DBAccessContext, GlobalAccessContext}
import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContext}
import com.scalableminds.util.objectid.ObjectId
import com.scalableminds.util.tools.{Fox, FoxImplicits, TextUtils}
import mail.{DefaultMails, MailchimpClient, MailchimpTag, Send}
import models.analytics.{AnalyticsService, InviteEvent, JoinOrganizationEvent, SignupEvent}
import models.annotation.AnnotationState.Cancelled
import models.annotation.{AnnotationDAO, AnnotationIdentifier, AnnotationInformationProvider}
import models.dataset.DatasetDAO
import models.organization.{Organization, OrganizationDAO, OrganizationService}
import models.user._
import models.voxelytics.VoxelyticsDAO
import net.liftweb.common.{Box, Empty, Failure, Full}
import org.apache.commons.codec.binary.Base64
import org.apache.commons.codec.digest.{HmacAlgorithms, HmacUtils}
import org.apache.pekko.actor.ActorSystem
import play.api.data.Form
import play.api.data.Forms._
import play.api.data.validation.Constraints._
import play.api.i18n.{Messages, MessagesProvider}
import play.api.libs.json._
import play.api.mvc.{Action, AnyContent, Cookie, PlayBodyParsers, Request, Result}
import security.{
CombinedAuthenticator,
OpenIdConnectClient,
OpenIdConnectUserInfo,
PasswordHasher,
TokenType,
WkEnv,
WkSilhouetteEnvironment
}
import play.api.mvc._
import play.silhouette.api.actions.SecuredRequest
import play.silhouette.api.exceptions.ProviderException
import play.silhouette.api.services.AuthenticatorResult
import play.silhouette.api.util.{Credentials, PasswordInfo}
import play.silhouette.api.{LoginInfo, Silhouette}
import play.silhouette.impl.providers.CredentialsProvider
import security._
import utils.WkConf

import java.net.URLEncoder
Expand All @@ -49,20 +37,17 @@ class AuthenticationController @Inject()(
credentialsProvider: CredentialsProvider,
passwordHasher: PasswordHasher,
userService: UserService,
annotationProvider: AnnotationInformationProvider,
authenticationService: AccessibleBySwitchingService,
organizationService: OrganizationService,
inviteService: InviteService,
inviteDAO: InviteDAO,
mailchimpClient: MailchimpClient,
organizationDAO: OrganizationDAO,
analyticsService: AnalyticsService,
userDAO: UserDAO,
datasetDAO: DatasetDAO,
multiUserDAO: MultiUserDAO,
defaultMails: DefaultMails,
conf: WkConf,
annotationDAO: AnnotationDAO,
voxelyticsDAO: VoxelyticsDAO,
wkSilhouetteEnvironment: WkSilhouetteEnvironment,
openIdConnectClient: OpenIdConnectClient,
initialDataService: InitialDataService,
Expand Down Expand Up @@ -228,103 +213,20 @@ class AuthenticationController @Inject()(
result <- combinedAuthenticatorService.embed(cookie, Redirect("/dashboard")) //to login the new user
} yield result

/*
superadmin - can definitely switch, find organization via global access context
not superadmin - fetch all identities, construct access context, try until one works
*/

def accessibleBySwitching(datasetId: Option[String],
annotationId: Option[String],
workflowHash: Option[String]): Action[AnyContent] = sil.SecuredAction.async {
implicit request =>
for {
datasetIdValidated <- Fox.runOptional(datasetId)(ObjectId.fromString(_))
isSuperUser <- multiUserDAO.findOne(request.identity._multiUser).map(_.isSuperUser)
selectedOrganization <- if (isSuperUser)
accessibleBySwitchingForSuperUser(datasetIdValidated, annotationId, workflowHash)
else
accessibleBySwitchingForMultiUser(request.identity._multiUser, datasetIdValidated, annotationId, workflowHash)
_ <- bool2Fox(selectedOrganization._id != request.identity._organization) // User is already in correct orga, but still could not see dataset. Assume this had a reason.
selectedOrganization <- authenticationService.getOrganizationToSwitchTo(request.identity,
datasetIdValidated,
annotationId,
workflowHash)
selectedOrganizationJs <- organizationService.publicWrites(selectedOrganization)
} yield Ok(selectedOrganizationJs)
}

private def accessibleBySwitchingForSuperUser(datasetIdOpt: Option[ObjectId],
annotationIdOpt: Option[String],
workflowHashOpt: Option[String]): Fox[Organization] = {
implicit val ctx: DBAccessContext = GlobalAccessContext
(datasetIdOpt, annotationIdOpt, workflowHashOpt) match {
case (Some(datasetId), None, None) =>
for {
dataset <- datasetDAO.findOne(datasetId)
organization <- organizationDAO.findOne(dataset._organization)
} yield organization
case (None, Some(annotationId), None) =>
for {
annotationObjectId <- ObjectId.fromString(annotationId)
annotation <- annotationDAO.findOne(annotationObjectId) // Note: this does not work for compound annotations.
user <- userDAO.findOne(annotation._user)
organization <- organizationDAO.findOne(user._organization)
} yield organization
case (None, None, Some(workflowHash)) =>
for {
workflow <- voxelyticsDAO.findWorkflowByHash(workflowHash)
organization <- organizationDAO.findOne(workflow._organization)
} yield organization
case _ => Fox.failure("Can either test access for dataset or annotation or workflow, not a combination")
}
}

private def accessibleBySwitchingForMultiUser(multiUserId: ObjectId,
datasetIdOpt: Option[ObjectId],
annotationIdOpt: Option[String],
workflowHashOpt: Option[String]): Fox[Organization] =
for {
identities <- userDAO.findAllByMultiUser(multiUserId)
selectedIdentity <- Fox.find(identities)(identity =>
canAccessDatasetOrAnnotationOrWorkflow(identity, datasetIdOpt, annotationIdOpt, workflowHashOpt))
selectedOrganization <- organizationDAO.findOne(selectedIdentity._organization)(GlobalAccessContext)
} yield selectedOrganization

private def canAccessDatasetOrAnnotationOrWorkflow(user: User,
datasetIdOpt: Option[ObjectId],
annotationIdOpt: Option[String],
workflowHashOpt: Option[String]): Fox[Boolean] = {
val ctx = AuthorizedAccessContext(user)
(datasetIdOpt, annotationIdOpt, workflowHashOpt) match {
case (Some(datasetId), None, None) =>
canAccessDataset(ctx, datasetId)
case (None, Some(annotationId), None) =>
canAccessAnnotation(user, ctx, annotationId)
case (None, None, Some(workflowHash)) =>
canAccessWorkflow(user, workflowHash)
case _ => Fox.failure("Can either test access for dataset or annotation or workflow, not a combination")
}
}

private def canAccessDataset(ctx: DBAccessContext, datasetId: ObjectId): Fox[Boolean] = {
val foundFox = datasetDAO.findOne(datasetId)(ctx)
foundFox.futureBox.map(_.isDefined)
}

private def canAccessAnnotation(user: User, ctx: DBAccessContext, annotationId: String): Fox[Boolean] = {
val foundFox = for {
annotationIdParsed <- ObjectId.fromString(annotationId)
annotation <- annotationDAO.findOne(annotationIdParsed)(GlobalAccessContext)
_ <- bool2Fox(annotation.state != Cancelled)
restrictions <- annotationProvider.restrictionsFor(AnnotationIdentifier(annotation.typ, annotationIdParsed))(ctx)
_ <- restrictions.allowAccess(user)
} yield ()
foundFox.futureBox.map(_.isDefined)
}

private def canAccessWorkflow(user: User, workflowHash: String): Fox[Boolean] = {
val foundFox = for {
_ <- voxelyticsDAO.findWorkflowByHashAndOrganization(user._organization, workflowHash)
} yield ()
foundFox.futureBox.map(_.isDefined)
}

def joinOrganization(inviteToken: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
invite <- inviteDAO.findOneByTokenValue(inviteToken) ?~> "invite.invalidToken"
Expand Down
39 changes: 28 additions & 11 deletions app/controllers/DatasetController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ import models.folder.FolderService
import models.organization.OrganizationDAO
import models.team.{TeamDAO, TeamService}
import models.user.{User, UserDAO, UserService}
import net.liftweb.common.{Failure, Full}
import net.liftweb.common.{Empty, Failure, Full}
import play.api.i18n.{Messages, MessagesProvider}
import play.api.libs.functional.syntax._
import play.api.libs.json._
import play.api.mvc.{Action, AnyContent, PlayBodyParsers}
import play.silhouette.api.Silhouette
import security.{URLSharing, WkEnv}
import security.{AccessibleBySwitchingService, URLSharing, WkEnv}
import utils.{MetadataAssertions, WkConf}

import javax.inject.Inject
Expand Down Expand Up @@ -85,6 +85,7 @@ class DatasetController @Inject()(userService: UserService,
thumbnailService: ThumbnailService,
thumbnailCachingService: ThumbnailCachingService,
conf: WkConf,
authenticationService: AccessibleBySwitchingService,
analyticsService: AnalyticsService,
mailchimpClient: MailchimpClient,
wkExploreRemoteLayerService: WKExploreRemoteLayerService,
Expand Down Expand Up @@ -317,7 +318,7 @@ class DatasetController @Inject()(userService: UserService,
def update(datasetId: String): Action[JsValue] =
sil.SecuredAction.async(parse.json) { implicit request =>
withJsonBodyUsing(datasetPublicReads) {
case (description, datasetName, legacyDatasetDisplayName, sortingKey, isPublic, tags, metadata, folderId) => {
case (description, datasetName, legacyDatasetDisplayName, sortingKey, isPublic, tags, metadata, folderId) =>
val name = if (legacyDatasetDisplayName.isDefined) legacyDatasetDisplayName else datasetName
for {
datasetIdValidated <- ObjectId.fromString(datasetId)
Expand All @@ -339,7 +340,6 @@ class DatasetController @Inject()(userService: UserService,
_ = analyticsService.track(ChangeDatasetSettingsEvent(request.identity, updated))
js <- datasetService.publicWrites(updated, Some(request.identity))
} yield Ok(Json.toJson(js))
}
}
}

Expand Down Expand Up @@ -406,13 +406,30 @@ class DatasetController @Inject()(userService: UserService,
def getDatasetIdFromNameAndOrganization(datasetName: String, organizationId: String): Action[AnyContent] =
sil.UserAwareAction.async { implicit request =>
for {
dataset <- datasetDAO.findOneByNameAndOrganization(datasetName, organizationId) ?~> notFoundMessage(datasetName) ~> NOT_FOUND
} yield
Ok(
Json.obj("id" -> dataset._id,
"name" -> dataset.name,
"organization" -> dataset._organization,
"directoryName" -> dataset.directoryName))
datasetBox <- datasetDAO.findOneByNameAndOrganization(datasetName, organizationId).futureBox
result <- (datasetBox match {
case Full(dataset) =>
Fox.successful(
Ok(
Json.obj("id" -> dataset._id,
"name" -> dataset.name,
"organization" -> dataset._organization,
"directoryName" -> dataset.directoryName)))
case Empty =>
for {
user <- request.identity.toFox ~> Unauthorized
dataset <- datasetDAO.findOneByNameAndOrganization(datasetName, organizationId)(GlobalAccessContext)
// Just checking if the user can switch to an organization to access the dataset.
_ <- authenticationService.getOrganizationToSwitchTo(user, Some(dataset._id), None, None)
} yield
Ok(
Json.obj("id" -> dataset._id,
"name" -> dataset.name,
"organization" -> dataset._organization,
"directoryName" -> dataset.directoryName))
case _ => Fox.failure(notFoundMessage(datasetName))
}) ?~> notFoundMessage(datasetName) ~> NOT_FOUND
} yield result
}

private def notFoundMessage(datasetName: String)(implicit ctx: DBAccessContext, m: MessagesProvider): String =
Expand Down
9 changes: 4 additions & 5 deletions app/models/dataset/Dataset.scala
Original file line number Diff line number Diff line change
Expand Up @@ -430,16 +430,15 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA
exists <- r.headOption
} yield exists

// Datasets are looked up by name and directoryName, as datasets from before dataset renaming was possible
// should have their directory name equal to their name during the time the link was created. This heuristic should
// have the best expected outcome as it expect to find the dataset by directoryName and it to be the oldest. In case
// someone renamed a dataset and created the link with a tool that uses the outdated dataset identification, the dataset should still be found.
// Legacy links to Datasets used their name and organizationId as identifier. In #8075 name was changed to directoryName.
// Thus, interpreting the name as the directory name should work, as changing the directory name is not possible.
// This way of looking up datasets should only be used for backwards compatibility.
def findOneByNameAndOrganization(name: String, organizationId: String)(implicit ctx: DBAccessContext): Fox[Dataset] =
for {
accessQuery <- readAccessQuery
r <- run(q"""SELECT $columns
FROM $existingCollectionName
WHERE (directoryName = $name OR name = $name)
WHERE (directoryName = $name)
AND _organization = $organizationId
AND $accessQuery
ORDER BY created ASC
Expand Down
Loading

0 comments on commit 002c915

Please sign in to comment.