diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0c5d8d13..a77d911b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: github branch run: | if [ "${{ github.event.release.target_commitish }}" != "" ]; then @@ -46,9 +46,10 @@ jobs: else echo "CLOWDER_VERSION=testing" >> $GITHUB_ENV fi - - uses: actions/setup-java@v1 + - uses: actions/setup-java@v3 with: - java-version: 1.8 + distribution: 'zulu' + java-version: 8 - name: Cache SBT ivy cache uses: actions/cache@v1 with: @@ -84,7 +85,7 @@ jobs: ports: - 27017:27017 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: github branch run: | if [ "${{ github.event.release.target_commitish }}" != "" ]; then @@ -100,16 +101,17 @@ jobs: else echo "CLOWDER_VERSION=testing" >> $GITHUB_ENV fi - - uses: actions/setup-java@v1 + - uses: actions/setup-java@v3 with: - java-version: 1.8 + distribution: 'zulu' + java-version: 8 - name: Cache SBT ivy cache - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.ivy2/cache key: ${{ runner.os }}-sbt-ivy-cache-${{ hashFiles('project/Build.scala') }} - name: Cache SBT - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.sbt key: ${{ runner.os }}-sbt-${{ hashFiles('project/Build.scala') }} @@ -128,7 +130,7 @@ jobs: runs-on: ubuntu-latest needs: build steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: github branch run: | if [ "${{ github.event.release.target_commitish }}" != "" ]; then @@ -144,16 +146,17 @@ jobs: else echo "CLOWDER_VERSION=testing" >> $GITHUB_ENV fi - - uses: actions/setup-java@v1 + - uses: actions/setup-java@v3 with: - java-version: 1.8 + distribution: 'zulu' + java-version: 8 - name: Cache SBT ivy cache - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.ivy2/cache key: ${{ runner.os }}-sbt-ivy-cache-${{ hashFiles('project/Build.scala') }} - name: Cache SBT - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.sbt key: ${{ runner.os }}-sbt-${{ hashFiles('project/Build.scala') }} @@ -204,7 +207,7 @@ jobs: runs-on: ubuntu-latest needs: build steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: github branch run: | if [ "${{ github.event.release.target_commitish }}" != "" ]; then @@ -220,16 +223,17 @@ jobs: else echo "CLOWDER_VERSION=testing" >> $GITHUB_ENV fi - - uses: actions/setup-java@v1 + - uses: actions/setup-java@v3 with: - java-version: 1.8 + distribution: 'zulu' + java-version: 8 - name: Cache SBT ivy cache - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.ivy2/cache key: ${{ runner.os }}-sbt-ivy-cache-${{ hashFiles('project/Build.scala') }} - name: Cache SBT - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.sbt key: ${{ runner.os }}-sbt-${{ hashFiles('project/Build.scala') }} diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index b0f328a67..b55dfb26f 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -20,7 +20,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: openapi-lint uses: mbowman100/swagger-validator-action@master diff --git a/CHANGELOG.md b/CHANGELOG.md index 4117fd2e5..16e57e087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased ### Added +- Users can be marked as ReadOnly [#405](https://github.com/clowder-framework/clowder/issues/405) - Added Trash button to delete section [#347](https://github.com/clowder-framework/clowder/issues/347) - Add "when" parameter in a few GET API endpoints to enable pagination [#266](https://github.com/clowder-framework/clowder/issues/266) - Extractors can now specify an extractor_key and an owner (email address) when sending a diff --git a/app/api/Admin.scala b/app/api/Admin.scala index d8c878df0..27bc8f482 100644 --- a/app/api/Admin.scala +++ b/app/api/Admin.scala @@ -125,10 +125,10 @@ class Admin @Inject() (userService: UserService, list.foreach(id => userService.findById(UUID(id)) match { case Some(u: ClowderUser) => { - if (u.status == UserStatus.Inactive) { + if (u.status != UserStatus.Active) { userService.update(u.copy(status = UserStatus.Active)) - val subject = s"[${AppConfiguration.getDisplayName}] account activated" - val body = views.html.emails.userActivated(u, active = true)(request) + val subject = s"[${AppConfiguration.getDisplayName}] account is now active" + val body = views.html.emails.userChanged(u, "activated")(request) util.Mail.sendEmail(subject, request.user, u, body) } } @@ -138,10 +138,10 @@ class Admin @Inject() (userService: UserService, list.foreach(id => userService.findById(UUID(id)) match { case Some(u: ClowderUser) => { - if (!(u.status == UserStatus.Inactive)) { + if (u.status != UserStatus.Inactive) { userService.update(u.copy(status = UserStatus.Inactive)) - val subject = s"[${AppConfiguration.getDisplayName}] account deactivated" - val body = views.html.emails.userActivated(u, active = false)(request) + val subject = s"[${AppConfiguration.getDisplayName}] account is deactivated" + val body = views.html.emails.userChanged(u, "deactivated")(request) util.Mail.sendEmail(subject, request.user, u, body) } } @@ -150,26 +150,27 @@ class Admin @Inject() (userService: UserService, (request.body \ "admin").asOpt[List[String]].foreach(list => list.foreach(id => userService.findById(UUID(id)) match { - case Some(u: ClowderUser) if (u.status == UserStatus.Active) => { - - userService.update(u.copy(status = UserStatus.Admin)) - val subject = s"[${AppConfiguration.getDisplayName}] admin access granted" - val body = views.html.emails.userAdmin(u, admin = true)(request) - util.Mail.sendEmail(subject, request.user, u, body) - + case Some(u: ClowderUser) => { + if (u.status != UserStatus.Admin) { + userService.update(u.copy(status = UserStatus.Admin)) + val subject = s"[${AppConfiguration.getDisplayName}] account is now an admin" + val body = views.html.emails.userChanged(u, "an admin account")(request) + util.Mail.sendEmail(subject, request.user, u, body) + } } case _ => Logger.error(s"Could not update user with id=${id}") })) - (request.body \ "unadmin").asOpt[List[String]].foreach(list => + (request.body \ "readonly").asOpt[List[String]].foreach(list => list.foreach(id => userService.findById(UUID(id)) match { - case Some(u: ClowderUser) if (u.status == UserStatus.Admin) => { - userService.update(u.copy(status = UserStatus.Active)) - val subject = s"[${AppConfiguration.getDisplayName}] admin access revoked" - val body = views.html.emails.userAdmin(u, admin = false)(request) - util.Mail.sendEmail(subject, request.user, u, body) + case Some(u: ClowderUser) => { + if (u.status != UserStatus.ReadOnly) { + userService.update(u.copy(status = UserStatus.ReadOnly)) + val subject = s"[${AppConfiguration.getDisplayName}] account is now an read-only" + val body = views.html.emails.userChanged(u, "read-only")(request) + util.Mail.sendEmail(subject, request.user, u, body) + } } - case _ => Logger.error(s"Could not update user with id=${id}") })) Ok(toJson(Map("status" -> "success"))) diff --git a/app/api/ApiController.scala b/app/api/ApiController.scala index d996b6786..2a5c435b1 100644 --- a/app/api/ApiController.scala +++ b/app/api/ApiController.scala @@ -88,12 +88,13 @@ trait ApiController extends Controller { userRequest.user match { case Some(u) if !AppConfiguration.acceptedTermsOfServices(u.termsOfServices) => Future.successful(Unauthorized("Terms of Service not accepted")) case Some(u) if (u.status == UserStatus.Inactive) => Future.successful(Unauthorized("Account is not activated")) + case Some(u) if (u.status == UserStatus.ReadOnly && !api.Permission.READONLY.contains(permission) && permission != Permission.DownloadFiles) => Future.successful(Unauthorized("Account is ReadOnly")) case Some(u) if u.superAdminMode || Permission.checkPermission(userRequest.user, permission, resourceRef) => block(userRequest) case Some(u) => { affectedResource match { case Some(resource) if Permission.checkOwner(u, resource) => block(userRequest) case _ => Future.successful(Unauthorized("Not authorized")) - } + } } case None if Permission.checkPermission(userRequest.user, permission, resourceRef) => block(userRequest) case _ => Future.successful(Unauthorized("Not authorized")) diff --git a/app/api/Metadata.scala b/app/api/Metadata.scala index 1c2be48fb..1dec8f676 100644 --- a/app/api/Metadata.scala +++ b/app/api/Metadata.scala @@ -257,7 +257,7 @@ class Metadata @Inject() ( // Given a list of terms, create a new standard vocabulary from the list // Expects a JSON array of Strings as the request body - def createVocabulary() = AuthenticatedAction(parse.json) { + def createVocabulary() = PermissionAction(Permission.CreateVocabulary)(parse.json) { implicit request => request.user match { case None => BadRequest(toJson("Invalid user")) @@ -278,7 +278,7 @@ class Metadata @Inject() ( // Given an ID, replace the entire terms list of a standard vocabulary // Expects a JSON array of Strings as the request body - def updateVocabulary(id: UUID) = AuthenticatedAction(parse.json) { + def updateVocabulary(id: UUID) = PermissionAction(Permission.EditVocabulary)(parse.json) { implicit request => request.user match { case None => BadRequest(toJson("Invalid user")) @@ -304,7 +304,7 @@ class Metadata @Inject() ( } // Given an ID, delete the standard vocabulary with that ID - def deleteVocabulary(id: UUID) = AuthenticatedAction(parse.empty) { + def deleteVocabulary(id: UUID) = PermissionAction(Permission.DeleteVocabulary)(parse.empty) { implicit request => request.user match { case None => BadRequest(toJson("Invalid user")) @@ -341,7 +341,7 @@ class Metadata @Inject() ( } } - def editDefinition(id: UUID, spaceId: Option[String]) = AuthenticatedAction(parse.json) { + def editDefinition(id: UUID, spaceId: Option[String]) = PermissionAction(Permission.EditVocabulary)(parse.json) { implicit request => request.user match { case Some(user) => { @@ -387,7 +387,7 @@ class Metadata @Inject() ( } } - def deleteDefinition(id: UUID) = AuthenticatedAction { implicit request => + def deleteDefinition(id: UUID) = PermissionAction(Permission.CreateVocabulary) { implicit request => implicit val user = request.user user match { case Some(user) => { diff --git a/app/api/Permissions.scala b/app/api/Permissions.scala index ac902a31d..e3c6ca5c3 100644 --- a/app/api/Permissions.scala +++ b/app/api/Permissions.scala @@ -426,6 +426,7 @@ object Permission extends Enumeration { def checkPermission(user: User, permission: Permission, resourceRef: ResourceRef): Boolean = { // check if user is owner, in that case they can do what they want. if (user.superAdminMode) return true + if (user.status == UserStatus.ReadOnly && !READONLY.contains(permission) && permission != Permission.DownloadFiles) return false if (checkOwner(users.findByIdentity(user), resourceRef)) return true resourceRef match { diff --git a/app/api/Spaces.scala b/app/api/Spaces.scala index 8dd34edda..e48de645b 100644 --- a/app/api/Spaces.scala +++ b/app/api/Spaces.scala @@ -32,35 +32,39 @@ class Spaces @Inject()(spaces: SpaceService, val spaceTitle: String = Messages("space.title") //TODO- Minimal Space created with Name and description. URLs are not yet put in - def createSpace() = AuthenticatedAction(parse.json) { implicit request => + def createSpace() = PermissionAction(Permission.CreateSpace)(parse.json) { implicit request => Logger.debug("Creating new space") - val nameOpt = (request.body \ "name").asOpt[String] - val descOpt = (request.body \ "description").asOpt[String] - (nameOpt, descOpt) match { - case (Some(name), Some(description)) => { - // TODO: add creator - val userId = request.user.get.id - val c = ProjectSpace(name = name, description = description, created = new Date(), creator = userId, - homePage = List.empty, logoURL = None, bannerURL = None, collectionCount = 0, - datasetCount = 0, fileCount = 0, userCount = 0, spaceBytes = 0, metadata = List.empty) - spaces.insert(c) match { - case Some(id) => { - appConfig.incrementCount('spaces, 1) - events.addObjectEvent(request.user, c.id, c.name, "create_space") - userService.findRoleByName("Admin") match { - case Some(realRole) => { - spaces.addUser(userId, realRole, UUID(id)) - } - case None => Logger.info("No admin role found") + if(request.user.get.status == UserStatus.ReadOnly) { + BadRequest(toJson("User is Read-Only")) + } else { + val nameOpt = (request.body \ "name").asOpt[String] + val descOpt = (request.body \ "description").asOpt[String] + (nameOpt, descOpt) match { + case (Some(name), Some(description)) => { + // TODO: add creator + val userId = request.user.get.id + val c = ProjectSpace(name = name, description = description, created = new Date(), creator = userId, + homePage = List.empty, logoURL = None, bannerURL = None, collectionCount = 0, + datasetCount = 0, fileCount = 0, userCount = 0, spaceBytes = 0, metadata = List.empty) + spaces.insert(c) match { + case Some(id) => { + appConfig.incrementCount('spaces, 1) + events.addObjectEvent(request.user, c.id, c.name, "create_space") + userService.findRoleByName("Admin") match { + case Some(realRole) => { + spaces.addUser(userId, realRole, UUID(id)) + } + case None => Logger.info("No admin role found") + } + Ok(toJson(Map("id" -> id))) } - Ok(toJson(Map("id" -> id))) + case None => Ok(toJson(Map("status" -> "error"))) } - case None => Ok(toJson(Map("status" -> "error"))) - } + } + case (_, _) => BadRequest(toJson("Missing required parameters")) } - case (_, _) => BadRequest(toJson("Missing required parameters")) } } diff --git a/app/controllers/SecuredController.scala b/app/controllers/SecuredController.scala index 0cbedeec3..ce4dae8c6 100644 --- a/app/controllers/SecuredController.scala +++ b/app/controllers/SecuredController.scala @@ -104,6 +104,9 @@ trait SecuredController extends Controller { userRequest.user match { case Some(u) if !AppConfiguration.acceptedTermsOfServices(u.termsOfServices) => Future.successful(Results.Redirect(routes.Application.tos(Some(request.uri)))) case Some(u) if (u.status==UserStatus.Inactive) => Future.successful(Results.Redirect(routes.Error.notActivated())) + case Some(u) if (u.status==UserStatus.ReadOnly && !api.Permission.READONLY.contains(permission) && permission != Permission.DownloadFiles) => { + Future.successful(Results.Redirect(routes.Error.notAuthorized("Account is ReadOnly", "", ""))) + } case Some(u) if u.superAdminMode || Permission.checkPermission(userRequest.user, permission, resourceRef) => block(userRequest) case Some(u) => notAuthorizedMessage(userRequest.user, resourceRef) case None if Permission.checkPermission(userRequest.user, permission, resourceRef) => block(userRequest) diff --git a/app/controllers/Spaces.scala b/app/controllers/Spaces.scala index 49e724aea..1c197060a 100644 --- a/app/controllers/Spaces.scala +++ b/app/controllers/Spaces.scala @@ -222,7 +222,7 @@ class Spaces @Inject() (spaces: SpaceService, users: UserService, events: EventS } } - def newSpace() = AuthenticatedAction { implicit request => + def newSpace() = PermissionAction(Permission.CreateSpace) { implicit request => implicit val user = request.user Ok(views.html.spaces.newSpace(spaceForm)) } @@ -396,10 +396,10 @@ class Spaces @Inject() (spaces: SpaceService, users: UserService, events: EventS * Submit action for new or edit space */ // TODO this should check to see if user has editspace for specific space - def submit() = AuthenticatedAction { implicit request => + def submit() = PermissionAction(Permission.CreateSpace) { implicit request => implicit val user = request.user user match { - case Some(identity) => { + case Some(identity) if identity.status != UserStatus.ReadOnly => { val userId = request.user.get.id //need to get the submitValue before binding form data, in case of errors we want to trigger different forms request.body.asMultipartFormData.get.dataParts.get("submitValue").headOption match { @@ -483,7 +483,7 @@ class Spaces @Inject() (spaces: SpaceService, users: UserService, events: EventS case None => { BadRequest("Did not get any submit button value.") } } } //some identity - case None => Redirect(routes.Spaces.list()).flashing("error" -> "You are not authorized to create/edit $spaceTitle.") + case _ => Redirect(routes.Spaces.list()).flashing("error" -> "You are not authorized to create/edit $spaceTitle.") } } def followingSpaces(index: Int, limit: Int, mode: String) = PrivateServerAction { implicit request => diff --git a/app/models/User.scala b/app/models/User.scala index 5daae5c46..d07fe8020 100644 --- a/app/models/User.scala +++ b/app/models/User.scala @@ -13,7 +13,7 @@ import play.api.libs.json._ object UserStatus extends Enumeration { type UserStatus = Value - val Inactive, Active, Admin = Value + val Inactive, Active, Admin, ReadOnly = Value } /** diff --git a/app/views/admin/users.scala.html b/app/views/admin/users.scala.html index a5b0ed626..046a47c1a 100644 --- a/app/views/admin/users.scala.html +++ b/app/views/admin/users.scala.html @@ -18,32 +18,32 @@