backend/app/services/users/Neo4jUserManagement.scala (388 lines of code) (raw):

package services.users import commands.{CreateCollection, CreateIngestion} import model.CreateIngestionRequest import model.frontend.user.PartialUser import model.manifest.{Collection, Ingestion} import model.user.{BCryptPassword, DBUser, UserPermission, UserPermissions} import org.neo4j.driver.v1.Values.parameters import org.neo4j.driver.v1.exceptions.ClientException import org.neo4j.driver.v1.{Driver, Record, StatementResult} import services.Neo4jQueryLoggingConfig import services.annotations.Annotations import services.index.{Index, Pages} import services.manifest.Manifest import utils._ import utils.attempt.{Attempt, ClientFailure, Failure, IllegalStateFailure, Neo4JFailure, NotFoundFailure, UnknownFailure, UserDoesNotExistFailure} import utils.auth.totp.Secret import scala.jdk.CollectionConverters._ import scala.concurrent.ExecutionContext import scala.util.control.NonFatal object Neo4jUserManagement { def apply(driver: Driver, executionContext: ExecutionContext, queryLoggingConfig: Neo4jQueryLoggingConfig, manifest: Manifest, index: Index, pages: Pages, annotations: Annotations): UserManagement = { val neo4jUserManagement = new Neo4jUserManagement(driver, executionContext, queryLoggingConfig, manifest, index, pages, annotations) neo4jUserManagement.setup() match { case Left(err) => throw err.toThrowable case Right(_) => neo4jUserManagement } } } class Neo4jUserManagement(neo4jDriver: Driver, executionContext: ExecutionContext, queryLoggingConfig: Neo4jQueryLoggingConfig, manifest: Manifest, index: Index, pages: Pages, annotations: Annotations) extends Neo4jHelper(neo4jDriver, executionContext, queryLoggingConfig) with UserManagement { import Neo4jHelper._ implicit val ec = executionContext def setup(): Either[Failure, Unit] = transaction { tx => tx.run("CREATE CONSTRAINT ON (user :User) ASSERT user.username IS UNIQUE") Right(()) } override def listUsers(): Attempt[List[(DBUser, List[Collection])]] = attemptTransaction { tx => val attemptedResult = tx.run( """ |MATCH (user :User) |OPTIONAL MATCH (user)-[:CAN_SEE]->(c: Collection) |WITH user, collect(c) as collections |RETURN user, collections """.stripMargin ) for { result <- attemptedResult userList <- Attempt.traverse(result.list.asScala.toList) { record => record.hasKeyOrFailure("user", IllegalStateFailure("Failed to get user details")).map { result => (DBUser.fromNeo4jValue(result.get("user")), result.get("collections").asList(v => Collection.fromNeo4jValue(v)).asScala.toList) } } } yield userList } override def listUsersWithPermission(permission: UserPermission): Attempt[List[DBUser]] = attemptTransaction { tx => val attemptedResult = tx.run( """ |MATCH (user :User)-[:HAS_PERMISSION]->(permission: Permission { name: {permissionName} }) |RETURN user """.stripMargin, parameters( "permissionName", permission.entryName ) ) for { result <- attemptedResult userList <- Attempt.traverse(result.list.asScala.toList) { record => record.hasKeyOrFailure("user", IllegalStateFailure("Failed to get user details")).map { result => DBUser.fromNeo4jValue(result.get("user")) } } } yield userList } override def getPermissions(username: String): Attempt[UserPermissions] = attemptTransaction { tx => tx.run( """ |MATCH (user: User { username: {username} }) |OPTIONAL MATCH (permission: Permission)<-[:HAS_PERMISSION]-(user) |RETURN permission """.stripMargin, parameters( "username", username ) ).flatMap(userPermissions) } override def createUser(user: DBUser, permissions: UserPermissions): Attempt[DBUser] = attemptTransaction { tx => val attemptedResult = tx.run( s""" |CREATE | (user :User { | username: {username}, | displayName: {displayName}, | password: {password}, | invalidationTime: {invalidationTime}, | registered: {registered}, | totpSecret: {totpSecret} | }) | | ${permissionQuery(permissions)} | |RETURN user """.stripMargin, parameters( "username", user.username, "displayName", user.displayName.orNull, "password", user.password.map(_.hash).orNull, "invalidationTime", user.invalidationTime.map(_.asInstanceOf[java.lang.Long]).orNull, "registered", Boolean.box(user.registered), "granted", permissions.granted.map(_.toString).toArray, "totpSecret", user.totpSecret.map(_.toBase32).orNull ) ) val result = for { result <- attemptedResult user <- singleUser(user.username, result) } yield user result.recoverWith { case Neo4JFailure(ce:ClientException) if ce.getMessage.contains("already exists with label User") => Attempt.Left(ClientFailure("User already exists")) } } override def importUser(user: DBUser, permissions: UserPermissions): Attempt[DBUser] = attemptTransaction { tx => val attemptedResult = tx.run( s""" |MERGE (user :User { username: {username} }) | | SET user.displayName = {displayName} | SET user.password = {password} | SET user.invalidationTime = {invalidationTime} | SET user.registered = {registered} | SET user.totpSecret = {totpSecret} | | ${permissionQuery(permissions)} | |RETURN user """.stripMargin, parameters( "username", user.username, "displayName", user.displayName.orNull, "password", user.password.map(_.hash).orNull, "invalidationTime", user.invalidationTime.map(_.asInstanceOf[java.lang.Long]).orNull, "registered", Boolean.box(user.registered), "granted", permissions.granted.map(_.toString).toArray, "totpSecret", user.totpSecret.map(_.toBase32).orNull ) ) for { result <- attemptedResult user <- singleUser(user.username, result) } yield user } override def registerUser(username: String, displayName: String, password: Option[BCryptPassword], secret: Option[Secret]): Attempt[DBUser] = for { user <- updateUser(username, "displayName" -> displayName, "totpSecret" -> secret.map(_.toBase32).orNull, "password" -> password.map(_.hash).orNull, "registered" -> true) _ <- createDefaultUserResources(user.toPartial) } yield { user } override def updateUserDisplayName(username: String, displayName: String): Attempt[DBUser] = updateUser(username, "displayName" -> displayName) override def updateUserPassword(username: String, password: BCryptPassword): Attempt[DBUser] = updateUser(username, "password" -> password.hash) override def updateTotpSecret(username: String, secret: Option[Secret]): Attempt[DBUser] = updateUser(username, "totpSecret" -> secret.map(_.toBase32).orNull) private def updateUser(username: String, fields: (String, Any)* ): Attempt[DBUser] = attemptTransaction { tx => val setStatements = fields.map { case (fieldName, value) => s"SET user.$fieldName = {$fieldName}" } val params: Seq[Any] = Seq("username", username) ++ fields.flatMap { case (fieldName, value) => Seq(fieldName, value) } val attemptedResult = tx.run( s""" |MATCH (user :User {username: {username}}) |${setStatements.mkString("\n")} |RETURN user """.stripMargin, parameters(params.asInstanceOf[Seq[AnyRef]]: _*) ) for { result <- attemptedResult user <- singleUser(username, result) } yield user } override def getUser(username: String): Attempt[DBUser] = attemptTransaction { tx => val attemptedResult = tx.run( """ |MATCH (user :User {username: {username}}) |RETURN user """.stripMargin, parameters("username", username) ) for { result <- attemptedResult user <- singleUser(username, result) } yield user } override def removeUser(username: String): Attempt[Unit] = attemptTransaction { tx => val attemptedResult = tx.run( """ |MATCH (user :User {username: {username}}) |DETACH DELETE user """.stripMargin, parameters("username", username) ) attemptedResult.flatMap { result => result.summary.counters.nodesDeleted match { case 1 => Attempt.Right(()) case 0 => Attempt.Left(NotFoundFailure(s"User '$username' doesn't exist")) case _ => Attempt.Left(IllegalStateFailure(s"Deleted multiple users when trying to delete $username")) } } } override def updateInvalidatedTime(username: String, invalidationTime: Long): Attempt[DBUser] = attemptTransaction { tx => val attemptedResult = tx.run( """ |MATCH (user :User {username: {username}}) |SET user.invalidationTime = {invalidationTime} |RETURN user """.stripMargin, parameters( "username", username, "invalidationTime", invalidationTime.asInstanceOf[java.lang.Long] ) ) for { result <- attemptedResult user <- singleUser(username, result) } yield user } override def getAllCollectionUrisAndUsernames(): Attempt[Map[String, Set[String]]] = attemptTransaction { tx => tx.run( """ | MATCH (user: User)-[:CAN_SEE]->(collection: Collection) | RETURN user.username as username, collection.uri as collection """.stripMargin ).map { result => result.list.asScala.foldLeft(Map.empty[String, Set[String]]) { (acc, record) => val username = record.get("username").asString() val collection = record.get("collection").asString() val before = acc.getOrElse(collection, Set.empty) val after = before + username acc + (collection -> after) } } } override def getUsersForCollection(collectionUri: String): Attempt[Set[String]] = attemptTransaction { tx => tx.run( """ | MATCH (collection: Collection { uri: {collection} }) | MATCH (user: User)-[:CAN_SEE]->(collection) | RETURN user.username as username """.stripMargin, parameters( "collection", collectionUri ) ).map { result => result.list.asScala.map(_.get("username").asString()).toSet } } override def getVisibleCollectionUrisForUser(username: String): Attempt[Set[String]] = attemptTransaction { tx => val result = tx.run( """ |MATCH (u: User { username: {username} })-[:CAN_SEE]->(collection: Collection) |MATCH (collection)<-[:CAN_SEE]-(user: User) |RETURN collection.uri as collection """.stripMargin, parameters("username", username) ) for { summary <- result collections <- Attempt.traverse(summary.list.asScala.toList) { record => record.hasKeyOrFailure("collection", IllegalStateFailure("Missing collection in response")) .map(_.get("collection").asString()) } } yield collections.toSet } override def addUserCollection(username: String, collection: String): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (user: User { username: {username} }) |MATCH (collection: Collection { uri: {collection} }) |CREATE UNIQUE (user)-[:CAN_SEE]->(collection) """.stripMargin, parameters( "username", username, "collection", collection ) ).map(_ => ()) } override def removeUserCollection(username: String, collection: String): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (u: User { username: {username} })-[r:CAN_SEE]->(c: Collection { uri: {collection}}) |DETACH DELETE r """.stripMargin, parameters( "username", username, "collection", collection ) ).map(_ => ()) } override def setPermissions(user: String, permissions: UserPermissions): Attempt[Unit] = attemptTransaction { tx => tx.run( s""" |MATCH (user: User { username: { username } }) |${permissionQuery(permissions)} |RETURN user """.stripMargin, parameters( "username", user, "granted", permissions.granted.map(_.toString).toArray ) ).flatMap { r => singleUser(user, r).map(_ => ()) } } private def singleUser(username: String, statementResult: StatementResult, field: String = "user"): Attempt[DBUser] = { statementResult.hasKeyOrFailure(field, UserDoesNotExistFailure(username)).map { result => DBUser.fromNeo4jValue(result.get(field)) } } private def userPermissions(result: StatementResult): Attempt[UserPermissions] = { val results = result.list().asScala.toList results.headOption match { case Some(record) if record.containsKey("permission") && record.get("permission").isNull => Attempt.Right(UserPermissions(granted = Set.empty)) case Some(_) => Attempt.sequence(results.map(userPermission)).map { perms => UserPermissions(perms.flatten.toSet) } case None => Attempt.Right(UserPermissions(granted = Set.empty)) } } private def userPermission(record: Record): Attempt[Option[UserPermission]] = { val name = record.get("permission").get("name") if(name.isNull) { Attempt.Left(IllegalStateFailure(s"Missing permission name")) } else { try { UserPermission.withNameOption(name.asString()) match { case Some(perm) => Attempt.Right(Some(perm)) case None => logger.warn(s"Unknown permission name $name") Attempt.Right(None) } } catch { case NonFatal(err) => Attempt.Left(UnknownFailure(err)) } } } private def permissionQuery(permissions: UserPermissions): String = { if(permissions.granted.isEmpty) { """ |WITH user |OPTIONAL MATCH (user)-[existing: HAS_PERMISSION]->(:Permission) |DELETE existing """.stripMargin } else { """ |WITH {granted} as permissions, user |OPTIONAL MATCH (user)-[existing: HAS_PERMISSION]->(otherPermission: Permission) | WHERE NOT otherPermission.name IN permissions | DELETE existing | |WITH permissions, user |UNWIND permissions as permission | MERGE (p: Permission { name: permission })<-[:HAS_PERMISSION]-(user) """.stripMargin } } private def createDefaultUserResources(user: PartialUser)(implicit ec: ExecutionContext): Attempt[Unit] = { val username = user.username val newCollectionName = s"${user.displayName} Documents" val ingestionData = CreateIngestionRequest(path = None, name = None, List("english"), fixed = Some(false), default = Some(true)) for { collection <- CreateCollection(newCollectionName, username, manifest).process() _ <- CreateIngestion(ingestionData, collection.uri, manifest, index, pages).process() _ <- addUserCollection(user.username, newCollectionName) } yield { () } } }