Skip to content

Commit

Permalink
Support adding/removing superuser role via API
Browse files Browse the repository at this point in the history
Create endpoints and service startup code to support adding/removing
superuser roles via the API. Only existing superuser are authorized to
make the calls.
  • Loading branch information
ljdelight committed Nov 24, 2023
1 parent ecf97a9 commit f6a2f97
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 20 deletions.
52 changes: 52 additions & 0 deletions app/org/maproulette/framework/controller/UserController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
}
33 changes: 30 additions & 3 deletions app/org/maproulette/framework/service/GrantService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
94 changes: 91 additions & 3 deletions app/org/maproulette/framework/service/UserService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@
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
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._
Expand All @@ -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}

/**
Expand All @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions app/org/maproulette/jobs/Bootstrap.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -33,6 +39,8 @@ class Bootstrap @Inject() (appLifeCycle: ApplicationLifecycle, db: Database, con
case _ => // do nothing
}
}

userService.get().promoteSuperUsersInConfig()
}

appLifeCycle.addStopHook { () =>
Expand Down
19 changes: 7 additions & 12 deletions app/org/maproulette/permissions/Permission.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
62 changes: 62 additions & 0 deletions conf/v2_route/user.api
Original file line number Diff line number Diff line change
Expand Up @@ -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()
1 change: 1 addition & 0 deletions test/org/maproulette/utils/TestSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down

0 comments on commit f6a2f97

Please sign in to comment.