diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 42b34c92b86..c815d8a3844 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -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) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 1ab3014c26d..c186e6203ab 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -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 @@ -49,7 +37,7 @@ class AuthenticationController @Inject()( credentialsProvider: CredentialsProvider, passwordHasher: PasswordHasher, userService: UserService, - annotationProvider: AnnotationInformationProvider, + authenticationService: AccessibleBySwitchingService, organizationService: OrganizationService, inviteService: InviteService, inviteDAO: InviteDAO, @@ -57,12 +45,9 @@ class AuthenticationController @Inject()( organizationDAO: OrganizationDAO, analyticsService: AnalyticsService, userDAO: UserDAO, - datasetDAO: DatasetDAO, multiUserDAO: MultiUserDAO, defaultMails: DefaultMails, conf: WkConf, - annotationDAO: AnnotationDAO, - voxelyticsDAO: VoxelyticsDAO, wkSilhouetteEnvironment: WkSilhouetteEnvironment, openIdConnectClient: OpenIdConnectClient, initialDataService: InitialDataService, @@ -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" diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index 8a88dcf4b1e..846b95b0d1e 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -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 @@ -85,6 +85,7 @@ class DatasetController @Inject()(userService: UserService, thumbnailService: ThumbnailService, thumbnailCachingService: ThumbnailCachingService, conf: WkConf, + authenticationService: AccessibleBySwitchingService, analyticsService: AnalyticsService, mailchimpClient: MailchimpClient, wkExploreRemoteLayerService: WKExploreRemoteLayerService, @@ -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) @@ -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)) - } } } @@ -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 = diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index ebfa63387a1..b149a48446c 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -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 diff --git a/app/security/AccessibleBySwitchingService.scala b/app/security/AccessibleBySwitchingService.scala new file mode 100644 index 00000000000..3e5210ed470 --- /dev/null +++ b/app/security/AccessibleBySwitchingService.scala @@ -0,0 +1,121 @@ +package security + +import com.scalableminds.util.accesscontext.{AuthorizedAccessContext, DBAccessContext, GlobalAccessContext} +import com.scalableminds.util.objectid.ObjectId +import com.scalableminds.util.tools.Fox +import com.scalableminds.util.tools.Fox.bool2Fox +import models.annotation.AnnotationState.Cancelled +import models.annotation.{AnnotationDAO, AnnotationIdentifier, AnnotationInformationProvider} +import models.dataset.DatasetDAO +import models.organization.{Organization, OrganizationDAO} +import models.user.{MultiUserDAO, User, UserDAO} +import models.voxelytics.VoxelyticsDAO + +import javax.inject.Inject +import scala.concurrent.ExecutionContext + +class AccessibleBySwitchingService @Inject()( + userDAO: UserDAO, + multiUserDAO: MultiUserDAO, + annotationDAO: AnnotationDAO, + organizationDAO: OrganizationDAO, + datasetDAO: DatasetDAO, + annotationProvider: AnnotationInformationProvider, + voxelyticsDAO: VoxelyticsDAO, +)(implicit ec: ExecutionContext) { + + /* + superadmin - can definitely switch, find organization via global access context + not superadmin - fetch all identities, construct access context, try until one works + */ + + def getOrganizationToSwitchTo(user: User, + datasetId: Option[ObjectId], + annotationId: Option[String], + workflowHash: Option[String])(implicit ctx: DBAccessContext): Fox[Organization] = + for { + isSuperUser <- multiUserDAO.findOne(user._multiUser).map(_.isSuperUser) + selectedOrganization <- if (isSuperUser) + accessibleBySwitchingForSuperUser(datasetId, annotationId, workflowHash) + else + accessibleBySwitchingForMultiUser(user._multiUser, datasetId, annotationId, workflowHash) + _ <- bool2Fox(selectedOrganization._id != user._organization) // User is already in correct orga, but still could not see dataset. Assume this had a reason. + } yield selectedOrganization + + 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) + } + +} diff --git a/frontend/javascripts/components/redirect.tsx b/frontend/javascripts/components/redirect.tsx index 82898e6cab7..68bfb3ea29e 100644 --- a/frontend/javascripts/components/redirect.tsx +++ b/frontend/javascripts/components/redirect.tsx @@ -1,6 +1,7 @@ import type { RouteComponentProps } from "react-router-dom"; import { withRouter } from "react-router-dom"; -import React from "react"; +import type React from "react"; +import { useEffectOnlyOnce } from "libs/react_hooks"; type Props = { redirectTo: () => Promise; @@ -8,39 +9,33 @@ type Props = { pushToHistory?: boolean; }; -class AsyncRedirect extends React.PureComponent { - static defaultProps = { - pushToHistory: true, - }; - - componentDidMount() { - this.redirect(); - } - - async redirect() { - const newPath = await this.props.redirectTo(); +const AsyncRedirect: React.FC = ({ redirectTo, history, pushToHistory = true }) => { + useEffectOnlyOnce(() => { + const redirect = async () => { + const newPath = await redirectTo(); + + if (newPath.startsWith(location.origin)) { + // The link is absolute which react-router does not support + // apparently. See https://stackoverflow.com/questions/42914666/react-router-external-link + if (pushToHistory) { + location.assign(newPath); + } else { + location.replace(newPath); + } + return; + } - if (newPath.startsWith(location.origin)) { - // The link is absolute which react-router does not support - // apparently. See https://stackoverflow.com/questions/42914666/react-router-external-link - if (this.props.pushToHistory) { - location.assign(newPath); + if (pushToHistory) { + history.push(newPath); } else { - location.replace(newPath); + history.replace(newPath); } - return; - } + }; - if (this.props.pushToHistory) { - this.props.history.push(newPath); - } else { - this.props.history.replace(newPath); - } - } + redirect(); + }); - render() { - return null; - } -} + return null; +}; export default withRouter(AsyncRedirect);