From f6a2f979f0350efc85df89bb5837cdab24867f9b Mon Sep 17 00:00:00 2001 From: Lucas Burson Date: Fri, 24 Nov 2023 14:07:48 -0600 Subject: [PATCH] Support adding/removing superuser role via API Create endpoints and service startup code to support adding/removing superuser roles via the API. Only existing superuser are authorized to make the calls. --- .../framework/controller/UserController.scala | 52 ++++++++++ .../framework/service/GrantService.scala | 33 ++++++- .../framework/service/UserService.scala | 94 ++++++++++++++++++- app/org/maproulette/jobs/Bootstrap.scala | 12 ++- .../maproulette/permissions/Permission.scala | 19 ++-- conf/v2_route/user.api | 62 ++++++++++++ test/org/maproulette/utils/TestSpec.scala | 1 + 7 files changed, 253 insertions(+), 20 deletions(-) diff --git a/app/org/maproulette/framework/controller/UserController.scala b/app/org/maproulette/framework/controller/UserController.scala index 6a5c267a6..bcba62dff 100644 --- a/app/org/maproulette/framework/controller/UserController.scala +++ b/app/org/maproulette/framework/controller/UserController.scala @@ -556,4 +556,56 @@ class UserController @Inject() ( } } } + + /** + * Get the current list of superusers + * @return the list of maproulette user ids that are superusers + */ + def getSuperUserIds(): Action[AnyContent] = Action.async { implicit request => + implicit val requireSuperUser: Boolean = true + this.sessionManager.authenticatedRequest { implicit grantor => + Ok(Json.toJson(this.serviceManager.user.superUsers)) + } + } + + /** + * Promotes a user to a super user + * @param maprouletteUserId the maproulette user id to promote + * @return NoContent if successful or if the user is already a super user + */ + def promoteUserToSuperUser(maprouletteUserId: Long): Action[AnyContent] = Action.async { + implicit request => + implicit val requireSuperUser: Boolean = true + this.sessionManager.authenticatedRequest { implicit grantor => + this.serviceManager.user.retrieve(maprouletteUserId) match { + case Some(grantee) => + this.serviceManager.user.promoteUserToSuperUser(grantee, grantor) + NoContent + case None => + throw new NotFoundException(s"Could not find user with ID $maprouletteUserId") + } + } + } + + /** + * Demotes a user from a super user to a normal user + * @param maprouletteUserId the maproulette user id to demote + * @return NoContent if successful or if the user is already a normal user + */ + def demoteSuperUserToUser(maprouletteUserId: Long): Action[AnyContent] = Action.async { + implicit request => + implicit val requireSuperUser: Boolean = true + this.sessionManager.authenticatedRequest { implicit grantor => + this.serviceManager.user.retrieve(maprouletteUserId) match { + case Some(grantee) => + if (grantee.id == grantor.id) { + throw new IllegalAccessException("A superuser cannot demote themselves") + } + this.serviceManager.user.demoteSuperUserToUser(grantee) + NoContent + case None => + throw new NotFoundException(s"Could not find user with ID $maprouletteUserId") + } + } + } } diff --git a/app/org/maproulette/framework/service/GrantService.scala b/app/org/maproulette/framework/service/GrantService.scala index 6d9866c16..b83afe1ba 100644 --- a/app/org/maproulette/framework/service/GrantService.scala +++ b/app/org/maproulette/framework/service/GrantService.scala @@ -5,13 +5,15 @@ package org.maproulette.framework.service +import anorm.SqlParser + import javax.inject.{Inject, Singleton} import org.maproulette.Config import org.maproulette.exception.InvalidException -import org.maproulette.framework.model.{Grant, Grantee, GrantTarget, User} +import org.maproulette.framework.model.{Grant, GrantTarget, Grantee, User} import org.maproulette.framework.psql.Query -import org.maproulette.framework.psql.filter.{Parameter, BaseParameter, Operator} -import org.maproulette.framework.repository.{GrantRepository} +import org.maproulette.framework.psql.filter.{BaseParameter, Operator, Parameter} +import org.maproulette.framework.repository.GrantRepository import org.maproulette.data._ import org.maproulette.permissions.Permission @@ -196,6 +198,31 @@ class GrantService @Inject() ( ) } + def getSuperUserIdsFromDatabase: List[Long] = { + repository.withMRConnection { implicit c => + // Search the grants table for grantee_id (eg the user's maproulette id) where the role is -1 (superuser) + // and the grantee_type is 5 (user). + anorm + .SQL("SELECT grantee_id AS id FROM grants WHERE role = -1 AND grantee_type = 5") + .as(SqlParser.scalar[Long].*) + } + } + + def deleteSuperUserFromDatabase(maprouletteUserId: Long): Boolean = { + if (maprouletteUserId == User.DEFAULT_SUPER_USER_ID) { + throw new IllegalAccessException("The system super user is not allowed to be removed") + } + repository.withMRTransaction { implicit c => + // Delete the grantee_id (eg the user's maproulette id) where the role is -1 (superuser). + anorm + .SQL( + "DELETE FROM grants WHERE grantee_id = {maprouletteUserId} AND role = -1 AND grantee_type = 5" + ) + .on("maprouletteUserId" -> maprouletteUserId) + .executeUpdate() >= 1 + } + } + /** * Generates List of query Parameters setup to match the given filter * arguments diff --git a/app/org/maproulette/framework/service/UserService.scala b/app/org/maproulette/framework/service/UserService.scala index 38db8e195..ba02db74b 100644 --- a/app/org/maproulette/framework/service/UserService.scala +++ b/app/org/maproulette/framework/service/UserService.scala @@ -6,7 +6,6 @@ package org.maproulette.framework.service import java.util.UUID - import javax.inject.{Inject, Singleton} import org.apache.commons.lang3.StringUtils import org.joda.time.DateTime @@ -14,8 +13,8 @@ import org.locationtech.jts.geom.{Coordinate, GeometryFactory} import org.locationtech.jts.io.WKTWriter import org.maproulette.Config import org.maproulette.cache.CacheManager -import org.maproulette.data.{UserType, GroupType} -import org.maproulette.exception.{NotFoundException, InvalidException} +import org.maproulette.data.{GroupType, UserType} +import org.maproulette.exception.{InvalidException, NotFoundException} import org.maproulette.framework.model._ import org.maproulette.framework.psql._ import org.maproulette.framework.psql.filter._ @@ -24,6 +23,7 @@ import org.maproulette.models.dal.TaskDAL import org.maproulette.permissions.Permission import org.maproulette.session.SearchParameters import org.maproulette.utils.{Crypto, Utils, Writers} +import org.slf4j.LoggerFactory import play.api.libs.json.{JsString, JsValue, Json} /** @@ -42,8 +42,96 @@ class UserService @Inject() ( crypto: Crypto ) extends ServiceMixin[User] with Writers { + private val logger = LoggerFactory.getLogger(this.getClass) + // The cache manager for the users val cacheManager = new CacheManager[Long, User](config, Config.CACHE_ID_USERS) + val superUsers = scala.collection.mutable.Set[Long]() + + // On class initialization (called when Play initializes), seed the super user from the existing database entries + seedSuperUserIds() + + /** + * Check if a given maproulette user id is a superuser + * @param maprouletteUserId the id of the user to check + * @return true if the user is a superuser, false otherwise + */ + def isSuperUser(maprouletteUserId: Long): Boolean = { + superUsers.contains(maprouletteUserId) + } + + def promoteUserToSuperUser(user: User, grantor: User): Boolean = { + if (isSuperUser(user.id)) { + logger.info( + s"MapRoulette uid=${user.id} (osm_id=${user.osmProfile.id}) is already a superuser, skipping role promotion" + ) + return true + } + + logger.warn(s"Adding superuser role to uid=${user.id} (osm_id=${user.osmProfile.id})") + val grantName = + s"Grant superuser role on uid=${user.id} (osm_id=${user.osmProfile.id}), requested by uid=${grantor.id}" + val superUserGrant = + new Grant(-1, grantName, Grantee.user(user.id), Grant.ROLE_SUPER_USER, GrantTarget.project(0)) + + serviceManager.grant.createGrant(superUserGrant, grantor) match { + case Some(grant) => + superUsers += grant.grantee.granteeId + clearCache(user.id) + true + case _ => + // The grant.createGrant function will throw an exception if it fails, so we should never get here. But just in case... + logger.warn( + s"Failed to add superuser role to uid=${user.id} requested by uid=${grantor.id}" + ) + false + } + } + + def demoteSuperUserToUser(user: User): Boolean = { + if (!isSuperUser(user.id)) { + logger.info( + s"MapRoulette uid=${user.id} (osm_id=${user.osmProfile.id}) is not a superuser, skipping role demotion" + ) + return true + } + + logger.warn(s"Removing superuser role for uid=${user.id} (osm_id=${user.osmProfile.id})") + if (serviceManager.grant.deleteSuperUserFromDatabase(user.id)) { + superUsers -= user.id + clearCache(user.id) + true + } else { + logger.warn( + s"Failed to remove superuser role for uid=${user.id} (osm_id=${user.osmProfile.id})" + ) + false + } + } + + def promoteSuperUsersInConfig(): Unit = { + // Look at config.superAccounts and ensure that all of those users are superusers. The below List will contain + // both the promoted users and the users that were already superusers. + val superusersFromConfig: List[User] = config.superAccounts + .filter(_ != "*") + .flatMap { osmId => + retrieveByOSMId(osmId.toLong) match { + case Some(it) => Some(it) + case None => + logger.warn( + s"osm_id=${osmId.toLong} has not logged in to this MapRoulette instance! Superuser promotion is not possible as there is no associated MapRoulette User" + ) + None + } + } + .flatMap { user => + if (promoteUserToSuperUser(user, User.superUser)) Some(user) else None + } + } + + private def seedSuperUserIds(): Unit = { + superUsers ++= grantService.getSuperUserIdsFromDatabase + } /** * Find the User based on an API key, the API key is unique in the database. diff --git a/app/org/maproulette/jobs/Bootstrap.scala b/app/org/maproulette/jobs/Bootstrap.scala index 43c35f4ea..402e11aa4 100644 --- a/app/org/maproulette/jobs/Bootstrap.scala +++ b/app/org/maproulette/jobs/Bootstrap.scala @@ -4,8 +4,9 @@ */ package org.maproulette.jobs -import javax.inject.{Inject, Singleton} +import javax.inject.{Inject, Provider, Singleton} import org.maproulette.Config +import org.maproulette.framework.service.UserService import org.slf4j.LoggerFactory import play.api.db.Database import play.api.inject.ApplicationLifecycle @@ -16,7 +17,12 @@ import scala.concurrent.Future * @author mcuthbert */ @Singleton -class Bootstrap @Inject() (appLifeCycle: ApplicationLifecycle, db: Database, config: Config) { +class Bootstrap @Inject() ( + appLifeCycle: ApplicationLifecycle, + db: Database, + config: Config, + userService: Provider[UserService] +) { private val logger = LoggerFactory.getLogger(this.getClass) @@ -33,6 +39,8 @@ class Bootstrap @Inject() (appLifeCycle: ApplicationLifecycle, db: Database, con case _ => // do nothing } } + + userService.get().promoteSuperUsersInConfig() } appLifeCycle.addStopHook { () => diff --git a/app/org/maproulette/permissions/Permission.scala b/app/org/maproulette/permissions/Permission.scala index 02cf237e0..8cd9ec9fa 100644 --- a/app/org/maproulette/permissions/Permission.scala +++ b/app/org/maproulette/permissions/Permission.scala @@ -229,18 +229,13 @@ class Permission @Inject() ( * Determines if the given user has been configured as a superuser */ def isSuperUser(user: User): Boolean = { - if (user.id == User.DEFAULT_SUPER_USER_ID) { - return true - } - - config.superAccounts.headOption match { - case Some("*") => true - case Some("") => false - case _ => - config.superAccounts.exists { superId => - superId.toInt == user.osmProfile.id - } - } + // Check if the user is the system super user (id=-999) + if (user.id == User.DEFAULT_SUPER_USER_ID) true + else + config.superAccounts match { + case List("*") if config.isDevMode => true + case _ => serviceManager.user.isSuperUser(user.id) + } } private def getItem( diff --git a/conf/v2_route/user.api b/conf/v2_route/user.api index 8349492e3..6fbae0554 100644 --- a/conf/v2_route/user.api +++ b/conf/v2_route/user.api @@ -596,3 +596,65 @@ DELETE /user/:userId/project/:projectId/:role @org.maproulette.framework.contr # format: int64 ### DELETE /user/project/:projectId/:role @org.maproulette.framework.controller.UserController.removeUsersFromProject(projectId:Long, role:Int, isOSMUserId:Boolean ?= false) +### +# tags: [ User ] +# summary: Promote a standard user to a super user +# description: Promote a standard user, a 'grantee', to a super user role; the requesting user is called a 'grantor'. +# This will add the superuser role to the grantee user, allowing the grantee to perform super user actions. +# The grantor must be a super user. +# responses: +# '204': +# description: The user was promoted to a superuser or was already a superuser +# '401': +# description: The request lacks authentication +# '403': +# description: Use 403 Forbidden if the grantor is not authorized (not a superuser) +# '404': +# description: The grantee was not found +# parameters: +# - name: userId +# in: path +# description: The MapRoulette user id of the user (the grantee) to be promoted +### +PUT /superuser/:userId @org.maproulette.framework.controller.UserController.promoteUserToSuperUser(userId:Long) +### +# tags: [ User ] +# summary: Remove the superuser role from a super user +# description: Demote a super user, a 'grantee', back to a standard user; the requesting user is called a 'grantor'. +# This will remove the superuser role from the grantee. +# The grantor must be a superuser. +# responses: +# '204': +# description: The superuser role was removed from the user or the user was not a superuser +# '401': +# description: The request lacks authentication +# '403': +# description: Use 403 Forbidden if the grantor is not a superuser, or trying to demote themselves or the system superuser +# '404': +# description: The grantee was not found +# parameters: +# - name: userId +# in: path +# description: The MapRoulette user id of the user (the grantee) to be promoted +### +DELETE /superuser/:userId @org.maproulette.framework.controller.UserController.demoteSuperUserToUser(userId:Long) +### +# tags: [ User ] +# summary: Get all current superusers +# description: Return a list of MapRoulette user ids who are superusers. The requesting user must be a super user. +# responses: +# '200': +# description: The list was obtained and the response contains the list of superusers +# content: +# application/json: +# schema: +# type: array +# items: +# type: integer +# uniqueItems: true +# '401': +# description: The request lacks authentication +# '403': +# description: Use 403 Forbidden if the user is not authorized to make this request +### +GET /superusers @org.maproulette.framework.controller.UserController.getSuperUserIds() diff --git a/test/org/maproulette/utils/TestSpec.scala b/test/org/maproulette/utils/TestSpec.scala index fc284185c..d6e34dcbc 100644 --- a/test/org/maproulette/utils/TestSpec.scala +++ b/test/org/maproulette/utils/TestSpec.scala @@ -237,6 +237,7 @@ trait TestSpec extends PlaySpec with MockitoSugar { // Mocks for users when(this.userService.retrieve(-999L)).thenReturn(Some(User.superUser)) + when(this.userService.isSuperUser(-999L)).thenReturn(true) when(this.userService.retrieve(-998L)).thenReturn(Some(User.guestUser)) doAnswer(_ => Some(User.superUser)).when(this.userService).retrieve(-999L) when(this.userService.retrieve(-1L)).thenReturn(Some(User.guestUser))