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

Support adding/removing superuser role via API #1078

Merged
merged 1 commit into from
Nov 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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