diff --git a/backend/schema.sql b/backend/schema.sql index e070bad..9fda75b 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -35,3 +35,15 @@ CREATE TABLE secret ( token VARCHAR(128) NOT NULL UNIQUE , PRIMARY KEY(id) ); + +DROP TABLE IF EXISTS permissions CASCADE; +CREATE TABLE permissions ( + id SERIAL , + user_id BIGINT REFERENCES users (id) , + permission VARCHAR(50) NOT NULL , + created_by BIGINT REFERENCES users (id) , + created TIMESTAMP NOT NULL , + deleted_by BIGINT REFERENCES users (id) , + deleted TIMESTAMP , + PRIMARY KEY(id) +); diff --git a/backend/src/main/resources/routes b/backend/src/main/resources/routes index b88b951..1e3ab5c 100644 --- a/backend/src/main/resources/routes +++ b/backend/src/main/resources/routes @@ -1,11 +1,12 @@ -GET /i/ping xingu.commons.play.controllers.InternalController.ping() -GET /i/conf xingu.commons.play.controllers.InternalController.conf() -GET /i/stat xingu.commons.play.controllers.InternalController.stat() -POST /account/create controllers.AccountController.create() -GET /account/id/:it controllers.AccountController.byId(it: Long) -POST /user/create controllers.UserController.create() -POST /user/login controllers.UserController.login() -GET /user/id/:it controllers.UserController.byId(it: Long) -GET /user/token/:it controllers.UserController.byToken(it: String) -POST /user/password/reset controllers.UserController.resetPassword() -POST /user/password/change controllers.UserController.changePassword() \ No newline at end of file +GET /i/ping xingu.commons.play.controllers.InternalController.ping() +GET /i/conf xingu.commons.play.controllers.InternalController.conf() +GET /i/stat xingu.commons.play.controllers.InternalController.stat() +POST /account/create controllers.AccountController.create() +GET /account/id/:it controllers.AccountController.byId(it: Long) +POST /user/create controllers.UserController.create() +POST /user/login controllers.UserController.login() +GET /user/id/:it controllers.UserController.byId(it: Long) +GET /user/token/:it controllers.UserController.byToken(it: String) +POST /user/password/reset controllers.UserController.resetPassword() +POST /user/password/change controllers.UserController.changePassword() +POST /user/permission/create controllers.UserController.addPermissionsFor() \ No newline at end of file diff --git a/backend/src/main/scala/controllers/ControllerSupport.scala b/backend/src/main/scala/controllers/ControllerSupport.scala index 20216d7..8e158a8 100644 --- a/backend/src/main/scala/controllers/ControllerSupport.scala +++ b/backend/src/main/scala/controllers/ControllerSupport.scala @@ -21,16 +21,20 @@ class ControllerSupport (services: AppServices) extends InjectedController with val log = LoggerFactory.getLogger(getClass) def createResource[RESOURCE, CREATE](actor: ActorRef)(implicit req: Request[JsValue], writer: Writes[RESOURCE], reader: Reads[CREATE]): Future[Result] = + createResource[RESOURCE, CREATE](actor, withPayload = true)(req, writer, reader) + + def createResource[RESOURCE, CREATE](actor: ActorRef, withPayload: Boolean)(implicit req: Request[JsValue], writer: Writes[RESOURCE], reader: Reads[CREATE]): Future[Result] = req.body.validate[CREATE] match { case success: JsSuccess[CREATE] => inquire(actor) { success.get } map { case ResourceAlreadyExists => Conflict("Resource Already Exists") case Failure(e) => log.error("Error Creating Resource", e); InternalServerError - case resource: RESOURCE => Ok(Json.toJson(resource)) + case resource: RESOURCE => if(withPayload) Ok(Json.toJson(resource)) else Ok } recover { case NonFatal(e) => log.error("Error Creating Resource", e); InternalServerError } - case JsError(err) => Future.successful(BadRequest) + case JsError(err) => + Future.successful(BadRequest(JsError.toJson(err).toString())) } def createResourceDirectly[RESOURCE, REQUEST](collection: ObjectStore[RESOURCE, REQUEST])(implicit req: Request[JsValue], writer: Writes[RESOURCE], reader: Reads[REQUEST]): Future[Result] = { diff --git a/backend/src/main/scala/controllers/UserController.scala b/backend/src/main/scala/controllers/UserController.scala index 855e6bf..4f9cbf0 100644 --- a/backend/src/main/scala/controllers/UserController.scala +++ b/backend/src/main/scala/controllers/UserController.scala @@ -4,6 +4,7 @@ import domain._ import domain.json._ import javax.inject.Inject import play.api.libs.json._ +import play.api.mvc.Request import services.AppServices import shapeless.TypeCase import store.{RootActors, Stores} @@ -20,6 +21,19 @@ class UserController @Inject()( val SuccessToken = TypeCase[Success[Token]] + def addPermissionsFor() = Action.async(parse.json) { implicit r => + validateThen[AddPermissionRequest] { req => + inquire(actors.users()) { req } map { + case UnknownUser => NotFound + case Failure(e) => Forbidden(e.getMessage) + case _ => Ok + } recover { + case NonFatal(e) => log.error("", e); InternalServerError + case _ => InternalServerError("") + } + } + } + def create() = Action.async(parse.json) { implicit r => createResource[User, CreateUserRequest](actors.users()) } diff --git a/backend/src/main/scala/domain/Domain.scala b/backend/src/main/scala/domain/Domain.scala index eab5c54..a4ef0c5 100644 --- a/backend/src/main/scala/domain/Domain.scala +++ b/backend/src/main/scala/domain/Domain.scala @@ -32,15 +32,16 @@ case class Password( ) case class User( - id : Long, - account : Long, - created : Date, - deleted : Option[Date], - active : Boolean, - username : String, - email : String, - `type` : String, - password : Option[Password] + id : Long, + account : Long, + created : Date, + deleted : Option[Date], + active : Boolean, + username : String, + email : String, + `type` : String, + password : Option[Password], + permissions : Option[List[Permission]] ) case class Token( @@ -51,6 +52,17 @@ case class Token( expiresAt : Option[Date] ) +case class Permission( + id : Long, + userId : Long, + permission : String, + createdBy : Long, + created : Date, + deletedBy : Option[Long], + deleted : Option[Date] +) + +case class AddPermissionRequest(userId: Long, permissions: Option[List[String]], createdBy: Option[Long]) case class AuthenticateRequest(username: String, password: String) case class CreateAccountRequest(name: String) case class CreateUserRequest(account: Long, username: String, password: Option[String], email: String, `type`: String) @@ -64,10 +76,13 @@ object json { implicit val CustomDateWrites = dateWrites(format) implicit val CustomDateReads = dateReads(format) implicit val ServerTimeWriter = Json.writes[ServerTime] + implicit val AddPermissionRequestWriter = Json.writes[AddPermissionRequest] implicit val AccountWriter = Json.writes[Account] implicit val PasswordWriter = Json.writes[Password] + implicit val PermissionWriter = Json.writes[Permission] implicit val UserWriter = Json.writes[User] implicit val TokenWriter = Json.writes[Token] + implicit val AddPermissionRequestReader = Json.reads[AddPermissionRequest] implicit val AuthenticateRequestReader = Json.reads[AuthenticateRequest] implicit val CreateAccountRequestReader = Json.reads[CreateAccountRequest] implicit val CreateUserRequestReader = Json.reads[CreateUserRequest] @@ -96,6 +111,7 @@ class UserTable(tag: Tag) extends Table[(Long, Long, Timestamp, Option[Timestamp def `type` : Rep[String] = column[String] ("type") def * = (id, account, created, deleted, active, username, email, `type`) } + class SecretTable(tag: Tag) extends Table[(Long, Long, Timestamp, Option[Timestamp], String, String, String)](tag, "secret") { def id : Rep[Long] = column[Long] ("id", O.PrimaryKey, O.AutoInc) def user : Rep[Long] = column[Long] ("user_id") @@ -107,8 +123,20 @@ class SecretTable(tag: Tag) extends Table[(Long, Long, Timestamp, Option[Timesta def * = (id, user, created, deleted, method, password, token) } +class PermissionTable(tag: Tag) extends Table[(Long, Long, String, Long, Timestamp, Option[Long], Option[Timestamp])](tag, "permissions") { + def id : Rep[Long] = column[Long] ("id", O.PrimaryKey, O.AutoInc) + def user : Rep[Long] = column[Long] ("user_id") + def permission: Rep[String] = column[String] ("permission") + def createdBy : Rep[Long] = column[Long] ("created_by") + def created : Rep[Timestamp] = column[Timestamp] ("created") + def deletedBy : Rep[Option[Long]] = column[Long] ("deleted_by") + def deleted : Rep[Option[Timestamp]] = column[Option[Timestamp]] ("deleted") + def * = (id, user, permission, createdBy, created, deletedBy, deleted) +} + object collections { - val accounts = TableQuery[AccountTable] - val users = TableQuery[UserTable] - val secrets = TableQuery[SecretTable] + val accounts = TableQuery[AccountTable] + val users = TableQuery[UserTable] + val secrets = TableQuery[SecretTable] + val permissions = TableQuery[PermissionTable] } \ No newline at end of file diff --git a/backend/src/main/scala/services/Services.scala b/backend/src/main/scala/services/Services.scala index 3a0e84b..3419473 100644 --- a/backend/src/main/scala/services/Services.scala +++ b/backend/src/main/scala/services/Services.scala @@ -5,25 +5,26 @@ import java.time.Clock import akka.actor.ActorSystem import javax.inject.{Inject, Singleton} import play.api.{Configuration, Environment} +import store.Permissions import xingu.commons.play.services.{BasicServices, Services} import scala.concurrent.ExecutionContext trait AppServices extends Services { - def rnd() : Random - def secrets() : SecretValidator + def rnd() : Random + def secrets(): SecretValidator } @Singleton class AppServicesImpl @Inject() ( - ec : ExecutionContext, - random : Random, - env : Environment, - config : Configuration, - clock : Clock, - validator : SecretValidator, - system : ActorSystem) extends BasicServices(ec, env, config, clock, system) with AppServices { + ec : ExecutionContext, + random : Random, + env : Environment, + config : Configuration, + clock : Clock, + validator : SecretValidator, + system : ActorSystem) extends BasicServices(ec, env, config, clock, system) with AppServices { - override def rnd() : Random = random - override def secrets() : SecretValidator = validator + override def rnd() : Random = random + override def secrets(): SecretValidator = validator } diff --git a/backend/src/main/scala/store/Permissions.scala b/backend/src/main/scala/store/Permissions.scala new file mode 100644 index 0000000..7d7e9ef --- /dev/null +++ b/backend/src/main/scala/store/Permissions.scala @@ -0,0 +1,66 @@ +package store + +import java.sql.Timestamp +import java.util +import java.util.Date + +import domain._ +import domain.collections._ +import javax.inject.Inject +import org.slf4j.{Logger, LoggerFactory} +import services.{AppServices, TokenGenerator} +import slick.jdbc.PostgresProfile.api._ + +import scala.concurrent.{ExecutionContext, Future} +import scala.language.postfixOps + +trait Permissions extends ObjectStore[List[Permission], AddPermissionRequest] { + def byId(userId: Long) : Future[Option[List[Permission]]] +} + +class DatabasePermissions(services: AppServices, db: Database) extends Permissions { + + implicit val ec: ExecutionContext = services.ec() + val log: Logger = LoggerFactory.getLogger(getClass) + + override def byId(it: Long): Future[Option[List[Permission]]] = Future.failed(new Exception("ERR - TODO")) + + override def create(request: AddPermissionRequest): Future[List[Permission]] = { + val option: Option[Seq[String]] = request.permissions + val permissionsList = List[Permission]() + + if(option.isEmpty) + Future.failed(new Exception("Permissions must be provided.")) + + option.get.foreach { permission => + + val instant = services.clock().instant() + val created = new Timestamp(instant.toEpochMilli) + + val v = db.run { + (permissions returning permissions.map(_.id)) += ( + 0l, + request.userId, + permission, + request.createdBy.get, + created, + None, + None, + ) + } map { id => + Permission( + id = id, + userId = request.userId, + permission = permission, + createdBy = request.createdBy.get, + created = Date.from(instant), + deletedBy = None, + deleted = None) + } + + permissionsList.::(v) + } + + Future.successful(permissionsList) + } +} \ No newline at end of file diff --git a/backend/src/main/scala/store/RootActors.scala b/backend/src/main/scala/store/RootActors.scala index 55a471f..0d9b6f9 100644 --- a/backend/src/main/scala/store/RootActors.scala +++ b/backend/src/main/scala/store/RootActors.scala @@ -6,7 +6,6 @@ import services.{AppServices, TokenGenerator} trait RootActors { def users(): ActorRef - } @Singleton @@ -19,6 +18,6 @@ class RootActorsImpl @Inject() ( .actorSystem() .actorOf(UsersSupervisor.props(services, tokens, accountManager), "users") - override def users() = usersRef + override def users(): ActorRef = usersRef } diff --git a/backend/src/main/scala/store/Stores.scala b/backend/src/main/scala/store/Stores.scala index c0ef97e..dfc62d5 100644 --- a/backend/src/main/scala/store/Stores.scala +++ b/backend/src/main/scala/store/Stores.scala @@ -14,9 +14,10 @@ trait ObjectStore[T, CREATE] { } trait Stores { - def accounts() : Accounts - def users() : Users - def passwords() : Passwords + def accounts() : Accounts + def users() : Users + def passwords() : Passwords + def permissions() : Permissions } @Singleton @@ -24,11 +25,13 @@ class StoresImpl @Inject()( services: AppServices, tokens : TokenGenerator) extends Stores { - private val db : PostgresProfile.backend.Database = Database.forConfig("database") - private val ACCOUNTS : Accounts = new DatabaseAccounts (services, db) - private val USERS : Users = new DatabaseUsers (services, db, tokens) - private val PASSWORDS : Passwords = new DatabasePasswords (services, db, tokens) - override def accounts() : Accounts = ACCOUNTS - override def users() : Users = USERS - override def passwords() : Passwords = PASSWORDS + private val db : PostgresProfile.backend.Database = Database.forConfig("database") + private val ACCOUNTS : Accounts = new DatabaseAccounts (services, db) + private val USERS : Users = new DatabaseUsers (services, db, tokens) + private val PASSWORDS : Passwords = new DatabasePasswords (services, db, tokens) + private val PERMISSIONS : Permissions = new DatabasePermissions(services, db) + override def accounts() : Accounts = ACCOUNTS + override def users() : Users = USERS + override def passwords() : Passwords = PASSWORDS + override def permissions(): Permissions = PERMISSIONS } diff --git a/backend/src/main/scala/store/Users.scala b/backend/src/main/scala/store/Users.scala index 113007c..95285bc 100644 --- a/backend/src/main/scala/store/Users.scala +++ b/backend/src/main/scala/store/Users.scala @@ -26,34 +26,50 @@ trait Users extends ObjectStore[User, CreateUserRequest] { def byUsername(username: String) : Future[Option[User]] } -class DatabaseUsers (services: AppServices, db: Database, tokens: TokenGenerator) extends Users { +class DatabaseUsers(services: AppServices, db: Database, tokens: TokenGenerator) extends Users { - type RowFilterParams = (UserTable, Rep[Option[SecretTable]]) // remove warning from intellij + type RowFilterParams = ((UserTable, Rep[Option[SecretTable]]), Rep[Option[PermissionTable]]) // remove warning from intellij implicit val ec = services.ec() val log = LoggerFactory.getLogger(getClass) def selectOne(rowFilter: RowFilterParams => Rep[Boolean]): Future[Option[User]] = { val query = for { - (user, secret) <- users joinLeft secrets on { _.id === _.user } filter { rowFilter } + ((user, secret), permission) <- { + users joinLeft secrets on { + _.id === _.user + } joinLeft permissions on { + _._1.id === _.user + } filter { + rowFilter + } + } } yield ( - (user.id, user.account, user.created, user.deleted, user.active, user.username, user.email , user.`type`), - secret.map(s => (s.id, s.user , s.created, s.deleted , s.method , s.password, s.token)) + (user.id, user.account, user.created, user.deleted, user.active, user.username, user.email, user.`type`), + secret.map(s => (s.id, s.user, s.created, s.deleted, s.method, s.password, s.token)), + permission.map(p => (p.id, p.user, p.permission, p.createdBy, p.created, p.deletedBy, p.deleted)) ) - /* merge users and their secrets */ + /* merge users and their secrets and permissions */ db.run(query.result) map { - _.map(pair => (toUser(pair._1), toPassword(pair._2))) + _.map(x => ((toUser(x._1), toPassword(x._2)), toPermission(x._3))) } map { _.groupBy(_._1) .map({ - case (user, tuples) => - val password = tuples.flatMap(_._2) + case ((user, password), tuples) => + val password = tuples.flatMap(_._1._2) .filter(_.deleted.isEmpty) /* not deleted */ .sortBy(_.created) .reverse /* most recent */ .headOption - (user.copy(password = password), tuples) + + val perms: List[Permission] = tuples.flatMap(_._2) + .filter(_.deleted.isEmpty) /* not deleted */ + .sortBy(_.created) + .reverse /* most recent */ + .toList + + (user.copy(password = password, permissions = Option[List[Permission]](perms)), tuples) }) .keys .toSeq @@ -61,6 +77,18 @@ class DatabaseUsers (services: AppServices, db: Database, tokens: TokenGenerator } } + def toPermission(tuple: Option[(Long, Long, String, Long, Timestamp, Option[Long], Option[Timestamp])]) = tuple map { + case (id, account, permission, createdBy, created, deletedBy, deleted) => + Permission( + id = id, + userId = account, + permission = permission, + createdBy = createdBy, + created = new Date(created.getTime), + deletedBy = deletedBy, + deleted = deleted.map(it => new Date(it.getTime))) + } + def toPassword(tuple: Option[(Long, Long, Timestamp, Option[Timestamp], String, String, String)]) = tuple map { case (id, user, created, deleted, method, password, token) => Password( @@ -84,12 +112,13 @@ class DatabaseUsers (services: AppServices, db: Database, tokens: TokenGenerator username = username, email = email, `type` = accType, - password = None) + password = None, + permissions = None) } - override def byId (it: Long) : Future[Option[User]] = selectOne { case (user, _) => user.id === it } - override def byUsername (it: String) : Future[Option[User]] = selectOne { case (user, _) => user.username === it } - override def byToken (it: String) : Future[Option[User]] = selectOne { case (_, secret) => + override def byId (it: Long) : Future[Option[User]] = selectOne { case ((user, _), _) => user.id === it } + override def byUsername (it: String) : Future[Option[User]] = selectOne { case ((user, _), _) => user.username === it } + override def byToken (it: String) : Future[Option[User]] = selectOne { case ((_, secret), _) => secret.map(value => value.token === it && value.deleted.isEmpty) getOrElse false } @@ -110,15 +139,16 @@ class DatabaseUsers (services: AppServices, db: Database, tokens: TokenGenerator ) } map { id => User( - id = id, - account = request.account, - created = Date.from(instant), - deleted = None, - active = true, - username = request.username, - email = request.email, - `type` = request.`type`, - password = None) + id = id, + account = request.account, + created = Date.from(instant), + deleted = None, + active = true, + username = request.username, + email = request.email, + `type` = request.`type`, + password = None, + permissions = None) } } } @@ -184,7 +214,6 @@ class UsersSupervisor ( .recover { case NonFatal(e) => replyTo ! Failure(e) } } - def validatePassword(it: CreateUserRequest): Future[Try[String]] = Future.successful { it .password @@ -220,6 +249,9 @@ class UsersSupervisor ( case it @ GetByToken(token) => fw(it, byToken.get(token), users.byToken(token)) + case it @ AddPermissionRequest(userId, _, _) => + fw(it, byId.get(userId), users.byId(userId)) + case it @ AuthenticateRequest(username, _) => fw(it, byUsername.get(username), users.byUsername(username)) @@ -266,6 +298,33 @@ class SingleUserSupervisor ( services.clock().instant().minus(daysAgo) } + def addPermissionFor(userId: Long, permissions: Option[List[String]]) = { + val permissionIssuer = Option(user.id) + val target = accountManager.users().byId(userId) + + target map { + case Some(targetUser) => + val t = targetUser.permissions.get + var valid = true + + t.foreach { + perm => + if (permissions.getOrElse(Seq()).contains(perm.permission)) + valid = false + } + + if(!valid) { + throw new Exception("User already have one of the given permissions.") + } else { + val request = AddPermissionRequest(userId = userId, permissions = permissions, createdBy = permissionIssuer) + val perms = accountManager.permissions() + Success { perms.create(request) } + } + } recover { + case e: Exception => Failure { e } + } + } + def checkPassword(provided: String)(password: Password): Try[Token] = { if(password.created.before(passwordLifetimeLimit())) { Failure { new Exception("Password Too Old") } @@ -292,6 +351,7 @@ class SingleUserSupervisor ( override def receive = { case Refresh => println("refreshing") + case AddPermissionRequest(userId, perms, _) => to(sender) { addPermissionFor(userId, perms) } case ReceiveTimeout => context.parent ! DecommissionSupervisor(user) case AuthenticateRequest(_, password) => sender ! authenticate(password) case GetByToken(_) => sender ! user