From 5eedb39a6b66bbbd41a21ab3f3a8df22c4b3692d Mon Sep 17 00:00:00 2001 From: Clark Andrianasolo Date: Fri, 2 Feb 2024 14:38:24 +0100 Subject: [PATCH] Fixes #24114: Migrate user-management API to zio-json --- user-management/pom-template.xml | 6 + .../rudder/plugin/UserManagementConf.scala | 5 +- .../plugins/usermanagement/DataTypes.scala | 123 +++++- .../usermanagement/Serialization.scala | 31 -- .../UserManagementService.scala | 23 +- .../api/UserManagementApi.scala | 216 +++++------ .../usermanagement/snippet/UserPlugin.scala | 6 +- .../src/test/resources/test-users.xml | 4 + .../usermanagement_api/api_usermanagement.yml | 365 ++++++++++++++++++ .../plugins/usermanagement/MockServices.scala | 102 +++++ .../api/UserManagementApiTest.scala | 98 +++++ 11 files changed, 793 insertions(+), 186 deletions(-) delete mode 100644 user-management/src/main/scala/com/normation/plugins/usermanagement/Serialization.scala create mode 100644 user-management/src/test/resources/test-users.xml create mode 100644 user-management/src/test/resources/usermanagement_api/api_usermanagement.yml create mode 100644 user-management/src/test/scala/com/normation/plugins/usermanagement/MockServices.scala create mode 100644 user-management/src/test/scala/com/normation/plugins/usermanagement/api/UserManagementApiTest.scala diff --git a/user-management/pom-template.xml b/user-management/pom-template.xml index fa3dd82c5..27c58bda4 100644 --- a/user-management/pom-template.xml +++ b/user-management/pom-template.xml @@ -48,6 +48,12 @@ + + javax.servlet + javax.servlet-api + 3.1.0 + provided + diff --git a/user-management/src/main/scala/bootstrap/rudder/plugin/UserManagementConf.scala b/user-management/src/main/scala/bootstrap/rudder/plugin/UserManagementConf.scala index fa361ebbd..63050ef70 100644 --- a/user-management/src/main/scala/bootstrap/rudder/plugin/UserManagementConf.scala +++ b/user-management/src/main/scala/bootstrap/rudder/plugin/UserManagementConf.scala @@ -38,6 +38,7 @@ package bootstrap.rudder.plugin import bootstrap.liftweb.RudderConfig +import bootstrap.liftweb.UserFileProcessing import com.normation.plugins.PluginStatus import com.normation.plugins.RudderPluginModule import com.normation.plugins.usermanagement.CheckRudderPluginEnableImpl @@ -66,9 +67,9 @@ object UserManagementConf extends RudderPluginModule { lazy val api = new UserManagementApiImpl( RudderConfig.userRepository, - RudderConfig.restExtractorService, RudderConfig.rudderUserListProvider, - new UserManagementService(RudderConfig.userRepository) + RudderConfig.authenticationProviders, + new UserManagementService(RudderConfig.userRepository, UserFileProcessing.getUserResourceFile()) ) RudderConfig.userAuthorisationLevel.overrideLevel(new UserManagementAuthorizationLevel(pluginStatusService)) diff --git a/user-management/src/main/scala/com/normation/plugins/usermanagement/DataTypes.scala b/user-management/src/main/scala/com/normation/plugins/usermanagement/DataTypes.scala index da0052c9b..c1ce6b52b 100644 --- a/user-management/src/main/scala/com/normation/plugins/usermanagement/DataTypes.scala +++ b/user-management/src/main/scala/com/normation/plugins/usermanagement/DataTypes.scala @@ -37,16 +37,17 @@ package com.normation.plugins.usermanagement +import bootstrap.liftweb.AuthBackendProvidersManager import bootstrap.liftweb.PasswordEncoder -import bootstrap.liftweb.RudderConfig import bootstrap.liftweb.ValidatedUserList +import com.normation.rudder.Role import com.normation.rudder.Role.Custom import com.normation.rudder.RudderRoles import com.normation.zio._ +import io.scalaland.chimney.Transformer import net.liftweb.common.Logger -import net.liftweb.json.{Serialization => S} -import net.liftweb.json.JsonAST.JValue import org.slf4j.LoggerFactory +import zio.json._ /** * Applicative log of interest for Rudder ops. @@ -57,22 +58,38 @@ object UserManagementLogger extends Logger { object Serialisation { + implicit val jsonUserFormDataDecoder: JsonDecoder[JsonUserFormData] = DeriveJsonDecoder.gen[JsonUserFormData] + implicit val jsonRoleAuthorizationsDecoder: JsonDecoder[JsonRoleAuthorizations] = DeriveJsonDecoder.gen[JsonRoleAuthorizations] + + implicit val jsonUserEncoder: JsonEncoder[JsonUser] = DeriveJsonEncoder.gen[JsonUser] + implicit val jsonAuthConfigEncoder: JsonEncoder[JsonAuthConfig] = DeriveJsonEncoder.gen[JsonAuthConfig] + implicit val jsonRoleEncoder: JsonEncoder[JsonRole] = DeriveJsonEncoder.gen[JsonRole] + implicit val jsonInternalUserDataEncoder: JsonEncoder[JsonInternalUserData] = DeriveJsonEncoder.gen[JsonInternalUserData] + implicit val jsonAddedUserEncoder: JsonEncoder[JsonAddedUser] = DeriveJsonEncoder.gen[JsonAddedUser] + implicit val jsonUpdatedUserEncoder: JsonEncoder[JsonUpdatedUser] = DeriveJsonEncoder.gen[JsonUpdatedUser] + implicit val jsonUsernameEncoder: JsonEncoder[JsonUsername] = DeriveJsonEncoder.gen[JsonUsername] + implicit val jsonDeletedUserEncoder: JsonEncoder[JsonDeletedUser] = DeriveJsonEncoder.gen[JsonDeletedUser] + implicit val jsonReloadStatusEncoder: JsonEncoder[JsonReloadStatus] = DeriveJsonEncoder.gen[JsonReloadStatus] + implicit val jsonReloadResultEncoder: JsonEncoder[JsonReloadResult] = DeriveJsonEncoder.gen[JsonReloadResult] + implicit val jsonRoleCoverageEncoder: JsonEncoder[JsonRoleCoverage] = DeriveJsonEncoder.gen[JsonRoleCoverage] + implicit val jsonCoverageEncoder: JsonEncoder[JsonCoverage] = DeriveJsonEncoder.gen[JsonCoverage] + implicit class AuthConfigSer(auth: ValidatedUserList) { - def toJson: JValue = { + def serialize(implicit authProviderManager: AuthBackendProvidersManager): JsonAuthConfig = { val encoder: String = PassEncoderToString(auth) - val authBackendsProvider = RudderConfig.authenticationProviders.getConfiguredProviders().map(_.name).toSet + val authBackendsProvider = authProviderManager.getConfiguredProviders().map(_.name).toSet // for now, we can only guess if the role list can be extended/overridden (and only guess for the worse). // The correct solution is to get that from rudder, but it will be done along with other enhancement about // user / roles management. // Also, until then, we need to update that test is other backend get that possibility - val roleListOverride = if(authBackendsProvider.contains("oidc") || authBackendsProvider.contains("oauth2")) { + val roleListOverride = if (authBackendsProvider.contains("oidc") || authBackendsProvider.contains("oauth2")) { "override" // should be a type provided by rudder core } else { "none" } - val jUser = auth.users.map { + val jUser = auth.users.map { case (_, u) => val (rs, custom) = { UserManagementService @@ -89,10 +106,8 @@ object Serialisation { rs.map(_.name) ) }.toList.sortBy(_.login) - val json = JsonAuthConfig(encoder, roleListOverride, authBackendsProvider, jUser) - import net.liftweb.json._ - implicit val formats = S.formats(NoTypeHints) - Extraction.decompose(json) + val json = JsonAuthConfig(encoder, roleListOverride, authBackendsProvider, jUser) + json } } @@ -120,3 +135,89 @@ final case class JsonUser( authz: Set[String], permissions: Set[String] ) + +final case class JsonRole( + @jsonField("id") name: String, + rights: List[String] +) + +final case class JsonReloadResult(reload: JsonReloadStatus) + +object JsonReloadResult { + val Done = JsonReloadResult(JsonReloadStatus("Done")) +} +final case class JsonReloadStatus(status: String) + +final case class JsonInternalUserData( + username: String, + password: String, + permissions: List[String] +) + +object JsonInternalUserData { + implicit val transformer: Transformer[User, JsonInternalUserData] = Transformer.derive[User, JsonInternalUserData] +} + +final case class JsonAddedUser( + addedUser: JsonInternalUserData +) extends AnyVal +object JsonAddedUser { + implicit val transformer: Transformer[User, JsonAddedUser] = Transformer.derive[User, JsonAddedUser] +} + +final case class JsonUpdatedUser( + updatedUser: JsonInternalUserData +) extends AnyVal +object JsonUpdatedUser { + implicit val transformer: Transformer[User, JsonUpdatedUser] = Transformer.derive[User, JsonUpdatedUser] +} + +final case class JsonUsername( + username: String +) + +final case class JsonDeletedUser( + deletedUser: JsonUsername +) extends AnyVal +object JsonDeletedUser { + implicit val usernameTransformer: Transformer[String, JsonUsername] = JsonUsername(_) + implicit val transformer: Transformer[String, JsonDeletedUser] = Transformer.derive[String, JsonDeletedUser] +} + +final case class JsonUserFormData( + username: String, + password: String, + permissions: List[String], + isPreHashed: Boolean +) + +object JsonUserFormData { + implicit val transformer: Transformer[JsonUserFormData, User] = Transformer.derive[JsonUserFormData, User] +} + +final case class JsonCoverage( + coverage: JsonRoleCoverage +) extends AnyVal +object JsonCoverage { + implicit val transformer: Transformer[(Set[Role], Set[Custom]), JsonCoverage] = + Transformer.derive[(Set[Role], Set[Custom]), JsonCoverage] +} + +final case class JsonRoleCoverage( + permissions: Set[String], + custom: List[String] +) + +object JsonRoleCoverage { + implicit private[JsonRoleCoverage] val roleTransformer: Transformer[Role, String] = _.name + implicit private[JsonRoleCoverage] val customRolesTransformer: Transformer[Set[Custom], List[String]] = + _.flatMap(_.rights.authorizationTypes.map(_.id)).toList.sorted + + implicit val transformer: Transformer[(Set[Role], Set[Custom]), JsonRoleCoverage] = + Transformer.derive[(Set[Role], Set[Custom]), JsonRoleCoverage] +} + +final case class JsonRoleAuthorizations( + permissions: List[String], + authz: List[String] +) diff --git a/user-management/src/main/scala/com/normation/plugins/usermanagement/Serialization.scala b/user-management/src/main/scala/com/normation/plugins/usermanagement/Serialization.scala deleted file mode 100644 index 09f2506cf..000000000 --- a/user-management/src/main/scala/com/normation/plugins/usermanagement/Serialization.scala +++ /dev/null @@ -1,31 +0,0 @@ -package com.normation.plugins.usermanagement - -import com.normation.rudder.Role -import com.normation.rudder.Role.Custom -import net.liftweb.json.JsonAST.JValue -import net.liftweb.json.JsonDSL._ - -object Serialization { - def serializeRoleInfo(infos: Map[String, List[String]]): JValue = { - infos.map { - case (k, v) => - (("id" -> k) - ~ ("rights" -> v)) - } - } - - def serializeUser(u: User): JValue = { - (("username" -> u.username) - ~ ("password" -> u.password) - ~ ("permissions" -> u.permissions)) - } - - def serializeRole(rs: Set[Role]): JValue = { - val (permissions, customs) = rs.partition { - case Custom(_) => false - case _ => true - } - (("permissions" -> permissions.map(_.name)) - ~ ("custom" -> customs.flatMap(_.rights.authorizationTypes.map(_.id)).toSeq.sorted)) - } -} diff --git a/user-management/src/main/scala/com/normation/plugins/usermanagement/UserManagementService.scala b/user-management/src/main/scala/com/normation/plugins/usermanagement/UserManagementService.scala index c9513f686..c666d28cb 100644 --- a/user-management/src/main/scala/com/normation/plugins/usermanagement/UserManagementService.scala +++ b/user-management/src/main/scala/com/normation/plugins/usermanagement/UserManagementService.scala @@ -111,14 +111,11 @@ object UserManagementIO { } } - def getUserFilePath: IOResult[File] = { - val resources: IOResult[UserFile] = UserFileProcessing.getUserResourceFile() - resources.map { r => - if (r.name.startsWith("classpath:")) - File(new CPResource(UserFileProcessing.DEFAULT_AUTH_FILE_NAME).getPath) - else - File(r.name) - } + def getUserFilePath(resourceFile: UserFile): File = { + if (resourceFile.name.startsWith("classpath:")) + File(new CPResource(UserFileProcessing.DEFAULT_AUTH_FILE_NAME).getPath) + else + File(resourceFile.name) } } @@ -196,7 +193,7 @@ object UserManagementService { } -class UserManagementService(userRepository: UserRepository) { +class UserManagementService(userRepository: UserRepository, getUserResourceFile: IOResult[UserFile]) { import UserManagementService._ /* @@ -205,7 +202,7 @@ class UserManagementService(userRepository: UserRepository) { */ def add(newUser: User, isPreHashed: Boolean): IOResult[User] = { for { - file <- getUserFilePath + file <- getUserResourceFile.map(getUserFilePath(_)) parsedFile <- IOResult.attempt(ConstructingParser.fromFile(file.toJava, preserveWS = true)) userXML <- IOResult.attempt(parsedFile.document().children) user <- (userXML \\ "authentication").head match { @@ -234,7 +231,7 @@ class UserManagementService(userRepository: UserRepository) { */ def remove(toDelete: String, actor: EventActor): IOResult[Unit] = { for { - file <- getUserFilePath + file <- getUserResourceFile.map(getUserFilePath(_)) parsedFile <- IOResult.attempt(ConstructingParser.fromFile(file.toJava, preserveWS = true)) userXML <- IOResult.attempt(parsedFile.document().children) toUpdate = (userXML \\ "authentication").head @@ -254,7 +251,7 @@ class UserManagementService(userRepository: UserRepository) { */ def update(currentUser: String, newUser: User, isPreHashed: Boolean): IOResult[Unit] = { for { - file <- getUserFilePath + file <- getUserResourceFile.map(getUserFilePath(_)) parsedFile <- IOResult.attempt(ConstructingParser.fromFile(file.toJava, preserveWS = true)) userXML <- IOResult.attempt(parsedFile.document().children) toUpdate = (userXML \\ "authentication").head @@ -285,7 +282,7 @@ class UserManagementService(userRepository: UserRepository) { def getAll: IOResult[UserFileInfo] = { for { - file <- getUserFilePath + file <- getUserResourceFile.map(getUserFilePath(_)) parsedFile <- IOResult.attempt(ConstructingParser.fromFile(file.toJava, preserveWS = true)) userXML <- IOResult.attempt(parsedFile.document().children) res <- (userXML \\ "authentication").head match { diff --git a/user-management/src/main/scala/com/normation/plugins/usermanagement/api/UserManagementApi.scala b/user-management/src/main/scala/com/normation/plugins/usermanagement/api/UserManagementApi.scala index 5747e6f1e..9dc0ab052 100644 --- a/user-management/src/main/scala/com/normation/plugins/usermanagement/api/UserManagementApi.scala +++ b/user-management/src/main/scala/com/normation/plugins/usermanagement/api/UserManagementApi.scala @@ -37,24 +37,34 @@ package com.normation.plugins.usermanagement.api +import bootstrap.liftweb.AuthBackendProvidersManager import bootstrap.liftweb.FileUserDetailListProvider -import com.normation.box._ import com.normation.errors._ -import com.normation.plugins.usermanagement.Serialization +import com.normation.plugins.usermanagement.JsonAddedUser +import com.normation.plugins.usermanagement.JsonCoverage +import com.normation.plugins.usermanagement.JsonDeletedUser +import com.normation.plugins.usermanagement.JsonReloadResult +import com.normation.plugins.usermanagement.JsonRole +import com.normation.plugins.usermanagement.JsonRoleAuthorizations +import com.normation.plugins.usermanagement.JsonUpdatedUser +import com.normation.plugins.usermanagement.JsonUserFormData +import com.normation.plugins.usermanagement.Serialisation._ import com.normation.plugins.usermanagement.User import com.normation.plugins.usermanagement.UserManagementService import com.normation.rudder.AuthorizationType import com.normation.rudder.Role +import com.normation.rudder.Role.Custom import com.normation.rudder.RudderRoles import com.normation.rudder.api.ApiAuthorization import com.normation.rudder.api.ApiVersion import com.normation.rudder.api.HttpAction.DELETE import com.normation.rudder.api.HttpAction.GET import com.normation.rudder.api.HttpAction.POST +import com.normation.rudder.apidata.ZioJsonExtractor import com.normation.rudder.facts.nodes.NodeSecurityContext -import com.normation.rudder.repository.json.DataExtractor.CompleteJson import com.normation.rudder.rest._ import com.normation.rudder.rest.EndpointSchema.syntax._ +import com.normation.rudder.rest.implicits._ import com.normation.rudder.rest.lift.DefaultParams import com.normation.rudder.rest.lift.LiftApiModule import com.normation.rudder.rest.lift.LiftApiModule0 @@ -62,19 +72,13 @@ import com.normation.rudder.rest.lift.LiftApiModuleProvider import com.normation.rudder.users.RudderAccount import com.normation.rudder.users.RudderUserDetail import com.normation.rudder.users.UserRepository -import com.normation.zio._ import com.softwaremill.quicklens._ -import net.liftweb.common.Box -import net.liftweb.common.Failure -import net.liftweb.common.Full +import io.scalaland.chimney.syntax._ import net.liftweb.http.LiftResponse import net.liftweb.http.Req -import net.liftweb.json.Formats -import net.liftweb.json.JsonDSL._ -import net.liftweb.json.JString -import net.liftweb.json.JValue -import net.liftweb.json.NoTypeHints import sourcecode.Line +import zio.ZIO +import zio.syntax._ /* * This file contains the internal API used to discuss with the JS application. @@ -151,30 +155,14 @@ object UserManagementApi extends ApiModuleProvider[UserManagementApi] { class UserManagementApiImpl( userRepo: UserRepository, - restExtractorService: RestExtractorService, userService: FileUserDetailListProvider, + authProvider: AuthBackendProvidersManager, userManagementService: UserManagementService ) extends LiftApiModuleProvider[UserManagementApi] { api => - implicit val formats: Formats = net.liftweb.json.Serialization.formats(NoTypeHints) - override def schemas = UserManagementApi - def extractUser(json: JValue): Box[User] = { - for { - username <- CompleteJson.extractJsonString(json, "username") - password <- CompleteJson.extractJsonString(json, "password") - permissions <- CompleteJson.extractJsonListString(json, "permissions") - } yield { - User(username, password, permissions.toSet) - } - } - - def extractIsHashed(json: JValue): Box[Boolean] = { - CompleteJson.extractJsonBoolean(json, "isPreHashed") - } - override def getLiftEndpoints(): List[LiftApiModule] = { UserManagementApi.endpoints.map { case UserManagementApi.GetUserInfo => GetUserInfo @@ -187,18 +175,11 @@ class UserManagementApiImpl( }.toList } - def response(function: Box[JValue], req: Req, errorMessage: String, id: Option[String], dataName: String)(implicit - action: String - ): LiftResponse = { - RestUtils.response(restExtractorService, dataName, id)(function, req, errorMessage) - } - /* * Return a Json Object that list users with their authorizations */ object GetUserInfo extends LiftApiModule0 { - val schema = UserManagementApi.GetUserInfo - val restExtractor = api.restExtractorService + val schema = UserManagementApi.GetUserInfo def process0(version: ApiVersion, path: ApiPath, req: Req, params: DefaultParams, authzToken: AuthzToken): LiftResponse = { import com.normation.plugins.usermanagement.Serialisation._ @@ -217,52 +198,46 @@ class UserManagementApiImpl( case Some(x) => (x.getUsername, x) } }) - file.modify(_.users).setTo(updatedUsers.toMap) - - }).either.runNow match { - case Left(err) => - RestUtils.toJsonError(None, JString(s"Error when retrieving user list: ${err.fullMsg}"))(schema.name, params.prettify) - case Right(authFile) => - RestUtils.toJsonResponse(None, authFile.toJson)(schema.name, params.prettify) - } + val authFile = file.modify(_.users).setTo(updatedUsers.toMap) + authFile.serialize(authProvider) + }).chainError("Error when retrieving user list").toLiftResponseOne(params, schema, None) } } object GetRoles extends LiftApiModule0 { - val schema = UserManagementApi.GetRoles - val restExtractor = api.restExtractorService + val schema = UserManagementApi.GetRoles def process0(version: ApiVersion, path: ApiPath, req: Req, params: DefaultParams, authzToken: AuthzToken): LiftResponse = { - val allRoleAndAuthz: Map[String, List[String]] = RudderRoles.getAllRoles.runNow.values - .map(role => role.name -> role.rights.authorizationTypes.map(_.id).toList.sorted) - .map { - case (k, v) => { - val authz_all = v - .map(_.split("_").head) - .map(authz => if (v.count(_.split("_").head == authz) == 3) s"${authz}_all" else authz) - .filter(_.contains("_")) - .distinct - val authz_type = v.filter(x => !authz_all.map(_.split("_").head).contains(x.split("_").head)) - k -> (authz_type ++ authz_all) - } - } - .toMap - RestUtils.toJsonResponse(None, Serialization.serializeRoleInfo(allRoleAndAuthz))(schema.name, params.prettify) + (for { + allRoles <- RudderRoles.getAllRoles + roles = allRoles.values.toList + + json = roles.map(role => { + val displayAuthz = role.rights.authorizationTypes.map(_.id).toList.sorted + val authz_all = displayAuthz + .map(_.split("_").head) + .map(authz => if (displayAuthz.count(_.split("_").head == authz) == 3) s"${authz}_all" else authz) + .filter(_.contains("_")) + .distinct + val authz_type = displayAuthz.filter(x => !authz_all.map(_.split("_").head).contains(x.split("_").head)) + JsonRole(role.name, authz_type ++ authz_all) + }) + + } yield { + json + }).toLiftResponseOne(params, schema, None) } } object ReloadUsersConf extends LiftApiModule0 { - val schema = UserManagementApi.ReloadUsersConf - val restExtractor = api.restExtractorService + val schema = UserManagementApi.ReloadUsersConf def process0(version: ApiVersion, path: ApiPath, req: Req, params: DefaultParams, authzToken: AuthzToken): LiftResponse = { - implicit val action = "reloadUserConf" - - val value: Box[JValue] = for { - response <- reload().toBox + (for { + _ <- reload() } yield { - "status" -> "Done" - } - response(value, req, "Could not reload user's configuration", None, "reload") + JsonReloadResult.Done + }).chainError("Could not reload user's configuration") + .toLiftResponseOne(params, schema, None) } } @@ -271,32 +246,26 @@ class UserManagementApiImpl( } object AddUser extends LiftApiModule0 { - val schema = UserManagementApi.AddUser - val restExtractor = api.restExtractorService + val schema = UserManagementApi.AddUser def process0(version: ApiVersion, path: ApiPath, req: Req, params: DefaultParams, authzToken: AuthzToken): LiftResponse = { - implicit val action = "addUser" - - val value: Box[JValue] = for { - json <- req.json ?~! "No JSON data sent" - user <- extractUser(json) - isPreHashed <- extractIsHashed(json) - checkExistence <- if (userService.authConfig.users.keySet contains user.username) - Failure(s"User '${user.username}' already exists") - else Full("ok") - added <- userManagementService.add(user, isPreHashed).toBox - _ <- reload().toBox + + (for { + user <- ZioJsonExtractor.parseJson[JsonUserFormData](req).toIO + _ <- ZIO.when(userService.authConfig.users.keySet contains user.username) { + Inconsistency(s"User '${user.username}' already exists").fail + } + added <- userManagementService.add(user.transformInto[User], user.isPreHashed) + _ <- reload() } yield { - Serialization.serializeUser(added) - } - response(value, req, "Could not add user", None, "addedUser") + added.transformInto[JsonAddedUser] + }).chainError("Could not add user").toLiftResponseOne(params, schema, None) } } object DeleteUser extends LiftApiModule { - val schema = UserManagementApi.DeleteUser - val restExtractor = api.restExtractorService + val schema = UserManagementApi.DeleteUser def process( version: ApiVersion, @@ -306,21 +275,18 @@ class UserManagementApiImpl( params: DefaultParams, authzToken: AuthzToken ): LiftResponse = { - implicit val action = "deleteUser" - val value: Box[JValue] = for { - _ <- userManagementService.remove(id, authzToken.qc.actor).toBox - _ <- reload().toBox + (for { + _ <- userManagementService.remove(id, authzToken.qc.actor) + _ <- reload() } yield { - "username" -> id - } - response(value, req, s"Could not delete user ${id}", None, "deletedUser") + id.transformInto[JsonDeletedUser] + }).chainError(s"Could not delete user ${id}").toLiftResponseOne(params, schema, None) } } object UpdateUserInfos extends LiftApiModule { - val schema = UserManagementApi.UpdateUserInfos - val restExtractor = api.restExtractorService + val schema = UserManagementApi.UpdateUserInfos def process( version: ApiVersion, @@ -330,32 +296,26 @@ class UserManagementApiImpl( params: DefaultParams, authzToken: AuthzToken ): LiftResponse = { - implicit val action = "updateInfosUser" - - val value: Box[JValue] = for { - json <- req.json ?~! "No JSON data sent" - user <- extractUser(json) - isPreHashed <- extractIsHashed(json) + (for { + user <- ZioJsonExtractor.parseJson[JsonUserFormData](req).toIO checkExistence <- if (!(userService.authConfig.users.keySet contains id)) { // we may have users that where added by OIDC, and still want to add them in file - userRepo.get(id).toBox.flatMap { - case Some(u) => userManagementService.add(User(u.id, "", Set()), isPreHashed).toBox - case None => Failure(s"'$id' does not exists") + userRepo.get(id).flatMap { + case Some(u) => userManagementService.add(User(u.id, "", Set()), user.isPreHashed) + case None => Inconsistency(s"'$id' does not exists").fail } } else { - userManagementService.update(id, user, isPreHashed).toBox + userManagementService.update(id, user.transformInto[User], user.isPreHashed) } - _ <- reload().toBox + _ <- reload() } yield { - Serialization.serializeUser(user) - } - response(value, req, s"Could not update user '$id''", None, "updatedUser") + user.transformInto[User].transformInto[JsonUpdatedUser] + }).chainError(s"Could not update user '$id'").toLiftResponseOne(params, schema, None) } } object RoleCoverage extends LiftApiModule { - val schema = UserManagementApi.RoleCoverage - val restExtractor = api.restExtractorService + val schema = UserManagementApi.RoleCoverage def process( version: ApiVersion, @@ -365,20 +325,22 @@ class UserManagementApiImpl( params: DefaultParams, authzToken: AuthzToken ): LiftResponse = { - implicit val action = "rolesCoverageOnRights" - - val value: Box[JValue] = for { - permissions <- restExtractorService.extractList("permissions")(req)(json => Full(json)) - authzs <- restExtractorService.extractList("authz")(req)(json => Full(json)) - parsed <- RudderRoles.parseRoles(permissions).toBox - coverage <- UserManagementService.computeRoleCoverage( - parsed.toSet, - authzs.flatMap(a => AuthorizationType.parseRight(a).getOrElse(Set())).toSet ++ Role.ua - ) + (for { + data <- ZioJsonExtractor.parseJson[JsonRoleAuthorizations](req).toIO + parsed <- RudderRoles.parseRoles(data.permissions) + coverage <- UserManagementService + .computeRoleCoverage( + parsed.toSet, + data.authz.flatMap(a => AuthorizationType.parseRight(a).getOrElse(Set())).toSet ++ Role.ua + ) + .notOptional("Could not compute role's coverage") + roleAndCustoms = coverage.partitionMap { + case c: Custom => Right(c) + case r => Left(r) + } } yield { - Serialization.serializeRole(coverage) - } - response(value, req, s"Could not get role's coverage user from request", None, "coverage") + roleAndCustoms.transformInto[JsonCoverage] + }).chainError(s"Could not get role's coverage user from request").toLiftResponseOne(params, schema, None) } } } diff --git a/user-management/src/main/scala/com/normation/plugins/usermanagement/snippet/UserPlugin.scala b/user-management/src/main/scala/com/normation/plugins/usermanagement/snippet/UserPlugin.scala index aabdfad6d..6d0f3d95f 100644 --- a/user-management/src/main/scala/com/normation/plugins/usermanagement/snippet/UserPlugin.scala +++ b/user-management/src/main/scala/com/normation/plugins/usermanagement/snippet/UserPlugin.scala @@ -43,10 +43,12 @@ import net.liftweb.http.DispatchSnippet import net.liftweb.http.js.JE._ import net.liftweb.http.js.JsCmds._ import scala.xml.NodeSeq +import zio.json._ class UserPlugin extends DispatchSnippet { - private[this] val userService = RudderConfig.rudderUserListProvider + private[this] val userService = RudderConfig.rudderUserListProvider + private[this] val authProviderManager = RudderConfig.authenticationProviders def dispatch = { case "getAuthzConfig" => getAuthzConfig } @@ -54,6 +56,6 @@ class UserPlugin extends DispatchSnippet { def getAuthzConfig: NodeSeq => NodeSeq = { xml: NodeSeq => import com.normation.plugins.usermanagement.Serialisation._ - Script(JsRaw(s"""var authzConfig = ${userService.authConfig.toJson};""")) + Script(JsRaw(s"""var authzConfig = ${userService.authConfig.serialize(authProviderManager).toJson};""")) } } diff --git a/user-management/src/test/resources/test-users.xml b/user-management/src/test/resources/test-users.xml new file mode 100644 index 000000000..1ba585b0f --- /dev/null +++ b/user-management/src/test/resources/test-users.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/user-management/src/test/resources/usermanagement_api/api_usermanagement.yml b/user-management/src/test/resources/usermanagement_api/api_usermanagement.yml new file mode 100644 index 000000000..ca9a23858 --- /dev/null +++ b/user-management/src/test/resources/usermanagement_api/api_usermanagement.yml @@ -0,0 +1,365 @@ +description: Get information about registered users in Rudder +method: GET +url: /api/latest/usermanagement/users +response: + code: 200 + content: >- + { + "action" : "getUserInfo", + "result" : "success", + "data" : { + "digest" : "BCRYPT", + "roleListOverride" : "none", + "authenticationBackends" : [], + "users" : [ + { + "login" : "user1", + "authz" : [], + "permissions" : [ + "inventory", + "configuration", + "rule_only", + "user" + ] + }, + { + "login" : "user2", + "authz" : [], + "permissions" : [ + "inventory", + "read_only", + "rule_only" + ] + }, + { + "login" : "user3", + "authz" : [], + "permissions" : [] + } + ] + } + } +--- +description: Get roles and their authorizations +method: GET +url: /api/latest/usermanagement/roles +response: + code: 200 + content: >- + { + "action" : "getRoles", + "result" : "success", + "data" : [ + { + "id" : "administration_only", + "rights" : [ + "administration_all", + "userAccount_all" + ] + }, + { + "id" : "administrator", + "rights" : [ + "any_rights" + ] + }, + { + "id" : "compliance", + "rights" : [ + "configuration_read", + "directive_read", + "group_read", + "node_read", + "parameter_read", + "rule_read", + "technique_read", + "compliance_all", + "userAccount_all" + ] + }, + { + "id" : "configuration", + "rights" : [ + "configuration_all", + "directive_all", + "parameter_all", + "rule_all", + "technique_all", + "userAccount_all" + ] + }, + { + "id" : "deployer", + "rights" : [ + "configuration_read", + "directive_read", + "group_read", + "node_read", + "parameter_read", + "rule_read", + "technique_read", + "compliance_all", + "deployer_all", + "userAccount_all" + ] + }, + { + "id" : "inventory", + "rights" : [ + "node_read", + "userAccount_all" + ] + }, + { + "id" : "no_rights", + "rights" : [ + "no_rights" + ] + }, + { + "id" : "read_only", + "rights" : [ + "administration_read", + "compliance_read", + "configuration_read", + "deployer_read", + "deployment_read", + "directive_read", + "group_read", + "node_read", + "parameter_read", + "rule_read", + "technique_read", + "validator_read", + "userAccount_all" + ] + }, + { + "id" : "rule_only", + "rights" : [ + "configuration_read", + "rule_read", + "userAccount_all" + ] + }, + { + "id" : "user", + "rights" : [ + "configuration_all", + "directive_all", + "group_all", + "node_all", + "parameter_all", + "rule_all", + "technique_all", + "userAccount_all" + ] + }, + { + "id" : "validator", + "rights" : [ + "configuration_read", + "directive_read", + "group_read", + "node_read", + "parameter_read", + "rule_read", + "technique_read", + "compliance_all", + "userAccount_all", + "validator_all" + ] + }, + { + "id" : "workflow", + "rights" : [ + "configuration_read", + "directive_read", + "group_read", + "node_read", + "parameter_read", + "rule_read", + "technique_read", + "compliance_all", + "deployer_all", + "userAccount_all", + "validator_all" + ] + } + ] + } +--- +description: Reload (read again rudder-users.xml and process result) information about registered users in Rudder +method: POST +url: /api/latest/usermanagement/users/reload +response: + code: 200 + content: >- + { + "action" : "reloadUsersConf", + "result" : "success", + "data" : { + "reload" : { + "status" : "Done" + } + } + } +--- +description: Add a user with his information and privileges +method: POST +url: /api/latest/usermanagement +headers: + - "Content-Type: application/json" +body: >- + { + "username" : "test_user", + "password" : "password", + "permissions" : ["any_rights"], + "isPreHashed" : true + } +response: + code: 200 + content: >- + { + "action" : "addUser", + "result" : "success", + "data" : { + "addedUser" : { + "username" : "test_user", + "password" : "password", + "permissions" : ["any_rights"] + } + } + } +--- +description: Add a user which already exists +method: POST +url: /api/latest/usermanagement +headers: + - "Content-Type: application/json" +body: >- + { + "username" : "user1", + "password" : "password", + "permissions" : ["any_rights"], + "isPreHashed" : true + } +response: + code: 500 + content: >- + { + "action" : "addUser", + "result" : "error", + "errorDetails" : "Could not add user; cause was: Inconsistency: User 'user1' already exists" + } +--- +description: Delete a user from the system +method: DELETE +url: /api/latest/usermanagement/test_user +response: + code: 200 + content: >- + { + "action" : "deleteUser", + "result" : "success", + "data" : { + "deletedUser" : { + "username" : "test_user" + } + } + } +--- +description: Delete a user which does not exist +method: DELETE +url: /api/latest/usermanagement/hello%20world +response: + code: 200 + content: >- + { + "action" : "deleteUser", + "result" : "success", + "data" : { + "deletedUser" : { + "username" : "hello+world" + } + } + } +--- +description: Update user's infos +method: POST +url: /api/latest/usermanagement/update/user1 +headers: + - "Content-Type: application/json" +body: >- + { + "username" : "user1", + "password" : "password1234", + "permissions" : ["any_rights"], + "isPreHashed" : true + } +response: + code: 200 + content: >- + { + "action" : "updateUserInfos", + "result" : "success", + "data" : { + "updatedUser" : { + "username" : "user1", + "password" : "password1234", + "permissions" : [ + "any_rights" + ] + } + } + } +--- +description: Update user's infos with a non-existing username +method: POST +url: /api/latest/usermanagement/update/hello%20world +headers: + - "Content-Type: application/json" +body: >- + { + "username" : "hello world", + "password" : "test", + "permissions" : [], + "isPreHashed" : true + } +response: + code: 500 + content: >- + { + "action" : "updateUserInfos", + "result" : "error", + "errorDetails" : "Could not update user 'hello+world'; cause was: Inconsistency: 'hello+world' does not exists" + } +--- +description: Get the coverage of roles over rights +method: POST +url: /api/latest/usermanagement/coverage/user1 +headers: + - "Content-Type: application/json" +body: >- + { + "permissions" : ["administration_only"], + "authz" : ["administration"] + } +response: + code: 200 + content: >- + { + "action" : "roleCoverage", + "result" : "success", + "data" : { + "coverage" : { + "permissions" : [], + "custom" : [ + "userAccount_edit", + "userAccount_read", + "userAccount_write" + ] + } + } + } + diff --git a/user-management/src/test/scala/com/normation/plugins/usermanagement/MockServices.scala b/user-management/src/test/scala/com/normation/plugins/usermanagement/MockServices.scala new file mode 100644 index 000000000..b3be450be --- /dev/null +++ b/user-management/src/test/scala/com/normation/plugins/usermanagement/MockServices.scala @@ -0,0 +1,102 @@ +package com.normation.plugins.usermanagement + +import better.files.File +import bootstrap.liftweb.FileUserDetailListProvider +import bootstrap.liftweb.UserFile +import com.normation.errors.IOResult +import com.normation.rudder.rest.AuthorizationApiMapping +import com.normation.rudder.rest.RoleApiMapping +import com.normation.rudder.users.EventTrace +import com.normation.rudder.users.SessionId +import com.normation.rudder.users.UserAuthorisationLevel +import com.normation.rudder.users.UserInfo +import com.normation.rudder.users.UserRepository +import com.normation.rudder.users.UserSession +import java.nio.charset.StandardCharsets +import org.apache.commons.io.IOUtils +import org.joda.time.DateTime +import zio.json.ast.Json +import zio.syntax._ + +class MockServices(userInfos: List[UserInfo], usersFile: File) { + + object userRepo extends UserRepository { + + override def logStartSession( + userId: String, + permissions: List[String], + tenants: String, + sessionId: SessionId, + authenticatorName: String, + date: DateTime + ): IOResult[Unit] = ??? + + override def logCloseSession(userId: String, date: DateTime, cause: String): IOResult[Unit] = ??? + + override def closeAllOpenSession(endDate: DateTime, endCause: String): IOResult[Unit] = ??? + + override def getLastPreviousLogin(userId: String): IOResult[Option[UserSession]] = ??? + + override def deleteOldSessions(olderThan: DateTime): IOResult[Unit] = ??? + + override def setExistingUsers(origin: String, users: List[String], trace: EventTrace): IOResult[Unit] = ??? + + override def disable( + userId: List[String], + notLoggedSince: Option[DateTime], + excludeFromOrigin: List[String], + trace: EventTrace + ): IOResult[List[String]] = ??? + + override def delete( + userId: List[String], + notLoggedSince: Option[DateTime], + excludeFromOrigin: List[String], + trace: EventTrace + ): IOResult[List[String]] = { + userId.succeed + } + + override def purge( + userId: List[String], + deletedSince: Option[DateTime], + excludeFromOrigin: List[String], + trace: EventTrace + ): IOResult[List[String]] = ??? + + override def setActive(userId: List[String], trace: EventTrace): IOResult[Unit] = ??? + + override def updateInfo( + id: String, + name: Option[Option[String]], + email: Option[Option[String]], + otherInfo: Option[Json.Obj] + ): IOResult[Unit] = ??? + + override def getAll(): IOResult[List[UserInfo]] = userInfos.succeed + + override def get(userId: String): IOResult[Option[UserInfo]] = { + userInfos.find(_.id == userId).succeed + } + + } + + val usersInputStream = () => IOUtils.toInputStream(usersFile.contentAsString, StandardCharsets.UTF_8) + + val userService = { + val usersFile = UserFile("test-users.xml", usersInputStream) + + val authLevel = new UserAuthorisationLevel { + override def userAuthEnabled: Boolean = true + override def name: String = "Test user auth level" + } + val roleApiMapping = new RoleApiMapping(AuthorizationApiMapping.Core) + + val res = new FileUserDetailListProvider(roleApiMapping, authLevel, usersFile) + res.reload() + res + } + + val userManagementService = + new UserManagementService(userRepo, UserFile(usersFile.pathAsString, usersInputStream).succeed) +} diff --git a/user-management/src/test/scala/com/normation/plugins/usermanagement/api/UserManagementApiTest.scala b/user-management/src/test/scala/com/normation/plugins/usermanagement/api/UserManagementApiTest.scala new file mode 100644 index 000000000..72319b3a1 --- /dev/null +++ b/user-management/src/test/scala/com/normation/plugins/usermanagement/api/UserManagementApiTest.scala @@ -0,0 +1,98 @@ +package com.normation.plugins.usermanagement.api + +import better.files.File +import better.files.Resource +import bootstrap.liftweb.AuthBackendProvidersManager +import com.normation.plugins.usermanagement.MockServices +import com.normation.rudder.api.ApiVersion +import com.normation.rudder.rest.TraitTestApiFromYamlFiles +import com.normation.rudder.users.UserInfo +import com.normation.rudder.users.UserStatus +import java.nio.file.Files +import net.liftweb.common.Loggable +import org.joda.time.DateTime +import org.junit.runner.RunWith +import org.specs2.mutable.Specification +import org.specs2.runner.JUnitRunner +import org.specs2.specification.AfterAll +import zio.json.ast.Json + +@RunWith(classOf[JUnitRunner]) +class UserManagementApiTest extends Specification with TraitTestApiFromYamlFiles with Loggable with AfterAll { + sequential + + val tmpDir: File = File(Files.createTempDirectory("rudder-test-")) + override def yamlSourceDirectory = "usermanagement_api" + override def yamlDestTmpDirectory = tmpDir / "templates" + + val testUserFile = Resource + .url("test-users.xml") + .map(File(_)) + .map(_.copyTo(tmpDir / "test-users.xml")) + .getOrElse(throw new Exception("Cannot find test-users.xml in test resources")) + + val mockServices = new MockServices( + List( + UserInfo( // user3 not in the file will get empty permissions and authz + "user3", + DateTime.parse("2024-02-01T01:01:01Z"), + UserStatus.Active, + "manager", + None, + None, + None, + List.empty, + Json.Obj() + ), + UserInfo( + "user2", + DateTime.parse("2024-02-01T01:01:01Z"), + UserStatus.Active, + "manager", + None, + None, + None, + List.empty, + Json.Obj() + ), + UserInfo( + "user1", + DateTime.parse("2024-02-01T01:01:01Z"), + UserStatus.Active, + "manager", + None, + None, + None, + List.empty, + Json.Obj() + ) + ), + testUserFile + ) + val modules = List( + new UserManagementApiImpl( + mockServices.userRepo, + mockServices.userService, + new AuthBackendProvidersManager(), + mockServices.userManagementService + ) + ) + + val apiVersions = ApiVersion(13, true) :: ApiVersion(14, false) :: Nil + val (rudderApi, liftRules) = TraitTestApiFromYamlFiles.buildLiftRules(modules, apiVersions, None) + + override def transformations: Map[String, String => String] = Map() + + // we are testing error cases, so we don't want to output error log for them + org.slf4j.LoggerFactory + .getLogger("com.normation.rudder.rest.RestUtils") + .asInstanceOf[ch.qos.logback.classic.Logger] + .setLevel(ch.qos.logback.classic.Level.OFF) + + override def afterAll(): Unit = { + tmpDir.delete() + } + + doTest(semanticJson = true) + +}