Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Super admin routes for user and orga management #7196

Merged
merged 15 commits into from
Jul 24, 2023
Merged
3 changes: 2 additions & 1 deletion CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/23.07.0...HEAD)

### Added
- Added a modal to the voxelytics workflow view that lists all artifacts with their file size and inode count. This helps identifying the largest artifacts to free disk space. [#7152](https://github.com/scalableminds/webknossos/pull/7152)
- Added a modal to the voxelytics workflow view that lists all artifacts with their file size and inode count. This helps to identify the largest artifacts to free disk space. [#7152](https://github.com/scalableminds/webknossos/pull/7152)
- In order to facilitate changing the brush size in the brush tool, buttons with preset brush sizes were added. These presets are user configurable by assigning the current brush size to any of the preset buttons. Additionally the preset brush sizes can be set with keyboard shortcuts. [#7101](https://github.com/scalableminds/webknossos/pull/7101)
- Added new graphics and restyled empty dashboards. [#7008](https://github.com/scalableminds/webknossos/pull/7008)
- Added warning when using WEBKNOSSOS in an outdated browser. [#7165](https://github.com/scalableminds/webknossos/pull/7165)
Expand All @@ -20,6 +20,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
- Added support for transformations with thin plate splines. [#7131](https://github.com/scalableminds/webknossos/pull/7131)
- WEBKNOSSOS can now read S3 remote dataset credentials from environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_KEY`. Those will be used, if available, when accessing remote datasets for which no explicit credentials are supplied. [#7170](https://github.com/scalableminds/webknossos/pull/7170)
- Added security.txt according to [RFC 9116](https://www.rfc-editor.org/rfc/rfc9116). The content is configurable and it can be disabled. [#7182](https://github.com/scalableminds/webknossos/pull/7182)
- Added routes for super-users to manage users and organizations. [#7196](https://github.com/scalableminds/webknossos/pull/7196)
- Added tooltips to explain the task actions "Reset" and "Reset and Cancel". [#7201](https://github.com/scalableminds/webknossos/pull/7201)
- Thumbnail improvements: Thumbnails now use intensity configuration, thumbnails can now be created for float datasets, and they are cached across webknossos restarts. [#7190](https://github.com/scalableminds/webknossos/pull/7190)
- Added batch actions for segment groups, such as changing the color and loading or downloading all corresponding meshes. [#7164](https://github.com/scalableminds/webknossos/pull/7164).
Expand Down
231 changes: 138 additions & 93 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import oxalis.thirdparty.BrainTracing
import play.api.data.Form
import play.api.data.Forms.{email, _}
import play.api.data.validation.Constraints._
import play.api.i18n.Messages
import play.api.i18n.{Messages, MessagesProvider}
import play.api.libs.json._
import play.api.mvc.{Action, AnyContent, Cookie, PlayBodyParsers, Request, Result}
import utils.{ObjectId, WkConf}
Expand Down Expand Up @@ -75,47 +75,36 @@ class AuthenticationController @Inject()(
signUpForm.bindFromRequest.fold(
bogusForm => Future.successful(BadRequest(bogusForm.toString)),
signUpData => {
val email = signUpData.email.toLowerCase
var errors = List[String]()
val firstName = TextUtils.normalizeStrong(signUpData.firstName).getOrElse {
errors ::= Messages("user.firstName.invalid")
""
}
val lastName = TextUtils.normalizeStrong(signUpData.lastName).getOrElse {
errors ::= Messages("user.lastName.invalid")
""
}
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).futureBox.flatMap {
case Full(_) =>
errors ::= Messages("user.email.alreadyInUse")
for {
(firstName, lastName, email, errors) <- validateNameAndEmail(signUpData.firstName,
signUpData.lastName,
signUpData.email)
result <- if (errors.nonEmpty) {
Fox.successful(BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
case Empty =>
if (errors.nonEmpty) {
Fox.successful(BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
} else {
for {
inviteBox: Box[Invite] <- inviteService.findInviteByTokenOpt(signUpData.inviteToken).futureBox
organizationName = Option(signUpData.organization).filter(_.trim.nonEmpty)
organization <- organizationService.findOneByInviteByNameOrDefault(
inviteBox.toOption,
organizationName)(GlobalAccessContext) ?~> Messages("organization.notFound", signUpData.organization)
_ <- organizationService
.assertUsersCanBeAdded(organization._id)(GlobalAccessContext, ec) ?~> "organization.users.userLimitReached"
autoActivate = inviteBox.toOption.map(_.autoActivate).getOrElse(organization.enableAutoVerify)
_ <- createUser(organization,
email,
firstName,
lastName,
autoActivate,
Option(signUpData.password),
inviteBox,
registerBrainDB = true)
} yield {
Ok
}
}
case f: Failure => Fox.failure(f.msg)
} else {
for {
_ <- Fox.successful(())
inviteBox: Box[Invite] <- inviteService.findInviteByTokenOpt(signUpData.inviteToken).futureBox
organizationName = Option(signUpData.organization).filter(_.trim.nonEmpty)
organization <- organizationService.findOneByInviteByNameOrDefault(inviteBox.toOption, organizationName)(
GlobalAccessContext) ?~> Messages("organization.notFound", signUpData.organization)
_ <- organizationService
.assertUsersCanBeAdded(organization._id)(GlobalAccessContext, ec) ?~> "organization.users.userLimitReached"
autoActivate = inviteBox.toOption.map(_.autoActivate).getOrElse(organization.enableAutoVerify)
_ <- createUser(organization,
email,
firstName,
lastName,
autoActivate,
Option(signUpData.password),
inviteBox,
registerBrainDB = true)
} yield Ok
}
} yield {
result
}

}
)
}
Expand All @@ -127,9 +116,8 @@ class AuthenticationController @Inject()(
autoActivate: Boolean,
password: Option[String],
inviteBox: Box[Invite] = Empty,
registerBrainDB: Boolean = false)(implicit request: Request[AnyContent]): Fox[User] = {
val passwordInfo: PasswordInfo =
password.map(passwordHasher.hash).getOrElse(userService.getOpenIdConnectPasswordInfo)
registerBrainDB: Boolean = false)(implicit mp: MessagesProvider): Fox[User] = {
val passwordInfo: PasswordInfo = userService.getPasswordInfo(password)
for {
user <- userService.insert(organization._id,
email,
Expand Down Expand Up @@ -234,8 +222,8 @@ class AuthenticationController @Inject()(
workflowHash: Option[String]): Action[AnyContent] = sil.SecuredAction.async {
implicit request =>
for {
isSuperuser <- multiUserDAO.findOne(request.identity._multiUser).map(_.isSuperUser)
selectedOrganization <- if (isSuperuser)
isSuperUser <- multiUserDAO.findOne(request.identity._multiUser).map(_.isSuperUser)
selectedOrganization <- if (isSuperUser)
accessibleBySwitchingForSuperUser(organizationName, dataSetName, annotationId, workflowHash)
else
accessibleBySwitchingForMultiUser(request.identity._multiUser,
Expand Down Expand Up @@ -554,63 +542,120 @@ class AuthenticationController @Inject()(
signUpData => {
organizationService.assertMayCreateOrganization(request.identity).futureBox.flatMap {
case Full(_) =>
val email = signUpData.email.toLowerCase
var errors = List[String]()
val firstName = TextUtils.normalizeStrong(signUpData.firstName).getOrElse {
errors ::= Messages("user.firstName.invalid")
""
}
val lastName = TextUtils.normalizeStrong(signUpData.lastName).getOrElse {
errors ::= Messages("user.lastName.invalid")
""
}
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).futureBox.flatMap {
case Full(_) =>
errors ::= Messages("user.email.alreadyInUse")
for {
(firstName, lastName, email, errors) <- validateNameAndEmail(signUpData.firstName,
signUpData.lastName,
signUpData.email)
result <- if (errors.nonEmpty) {
Fox.successful(BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
case Empty =>
if (errors.nonEmpty) {
Fox.successful(
BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
} else {
for {
organization <- organizationService.createOrganization(
Option(signUpData.organization).filter(_.trim.nonEmpty),
signUpData.organizationDisplayName) ?~> "organization.create.failed"
user <- userService.insert(organization._id,
email,
firstName,
lastName,
isActive = true,
passwordHasher.hash(signUpData.password),
isAdmin = true,
isOrganizationOwner = true) ?~> "user.creation.failed"
_ = analyticsService.track(SignupEvent(user, hadInvite = false))
multiUser <- multiUserDAO.findOne(user._multiUser)
dataStoreToken <- bearerTokenAuthenticatorService
.createAndInit(user.loginInfo, TokenType.DataStore, deleteOld = false)
.toFox
_ <- organizationService
.createOrganizationFolder(organization.name, dataStoreToken) ?~> "organization.folderCreation.failed"
} yield {
Mailer ! Send(
defaultMails.newOrganizationMail(organization.displayName,
email.toLowerCase,
request.headers.get("Host").getOrElse("")))
if (conf.Features.isWkorgInstance) {
mailchimpClient.registerUser(user, multiUser, MailchimpTag.RegisteredAsAdmin)
}
Ok
} else {
for {
organization <- organizationService.createOrganization(
Option(signUpData.organization).filter(_.trim.nonEmpty),
signUpData.organizationDisplayName) ?~> "organization.create.failed"
user <- userService.insert(organization._id,
email,
firstName,
lastName,
isActive = true,
passwordHasher.hash(signUpData.password),
isAdmin = true,
isOrganizationOwner = true) ?~> "user.creation.failed"
_ = analyticsService.track(SignupEvent(user, hadInvite = false))
multiUser <- multiUserDAO.findOne(user._multiUser)
dataStoreToken <- bearerTokenAuthenticatorService
.createAndInit(user.loginInfo, TokenType.DataStore, deleteOld = false)
.toFox
_ <- organizationService
.createOrganizationFolder(organization.name, dataStoreToken) ?~> "organization.folderCreation.failed"
} yield {
Mailer ! Send(defaultMails
.newOrganizationMail(organization.displayName, email, request.headers.get("Host").getOrElse("")))
if (conf.Features.isWkorgInstance) {
mailchimpClient.registerUser(user, multiUser, MailchimpTag.RegisteredAsAdmin)
}
Ok
}
case f: Failure => Fox.failure(f.msg)
}
}
} yield result
case _ => Fox.failure(Messages("organization.create.forbidden"))
}
}
)
}

case class CreateUserInOrganizationParameters(firstName: String,
lastName: String,
email: String,
password: Option[String],
autoActivate: Option[Boolean])

object CreateUserInOrganizationParameters {
implicit val jsonFormat: OFormat[CreateUserInOrganizationParameters] =
Json.format[CreateUserInOrganizationParameters]
}

def createUserInOrganization(organizationName: String): Action[CreateUserInOrganizationParameters] =
sil.SecuredAction.async(validateJson[CreateUserInOrganizationParameters]) { implicit request =>
for {
_ <- userService.assertIsSuperUser(request.identity._multiUser) ?~> "notAllowed" ~> FORBIDDEN
organization <- organizationDAO.findOneByName(organizationName) ?~> "organization.notFound"
(firstName, lastName, email, errors) <- validateNameAndEmail(request.body.firstName,
request.body.lastName,
request.body.email)
result <- if (errors.isEmpty) {
createUser(organization,
email,
firstName,
lastName,
request.body.autoActivate.getOrElse(false),
request.body.password,
Empty,
registerBrainDB = true).map(u => Ok(u._id.toString))
} else {
Fox.successful(BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
}
} yield {
result
}
}

private def validateNameAndEmail(firstName: String,
lastName: String,
email: String): Fox[(String, String, String, List[String])] = {
var (errors, fN, lN) = normalizeName(firstName, lastName)
for {
nameEmailErrorBox: Box[(String, String, String, List[String])] <- multiUserDAO
.findOneByEmail(email.toLowerCase)(GlobalAccessContext)
.futureBox
.flatMap {
case Full(_) =>
errors ::= "user.email.alreadyInUse"
Fox.successful(("", "", "", errors))
case Empty =>
if (errors.nonEmpty) {
Fox.successful(("", "", "", errors))
} else {
Fox.successful((fN, lN, email.toLowerCase, List()))
}
case f: Failure => Fox.failure(f.msg)
}
} yield nameEmailErrorBox
}

private def normalizeName(firstName: String, lastName: String) = {
var errors = List[String]()
val fN = TextUtils.normalizeStrong(firstName).getOrElse {
errors ::= "user.firstName.invalid"
""
}
val lN = TextUtils.normalizeStrong(lastName).getOrElse {
errors ::= "user.lastName.invalid"
""
}
(errors, fN, lN)
}

}

case class InviteParameters(
Expand Down
Loading