~jan0sch/smederee

Showing details for patch 1a3e36c52da001590a8cea80c8533469b6f0d704.
2024-07-17 (Wed), 7:34 PM - Jens Grassel - 1a3e36c52da001590a8cea80c8533469b6f0d704

vcs: Implement permissions for repositories

- add database table for mapping permissions between repositories and organisations
- extend VcsMetadataRepository with getter and setter for permissions
- extend BaseSpec with createOrganisation helper
- add tests for getter and setter
- add ScalaCheck Effect testing library for easier usage of scalacheck with effectful tests
Summary of changes
1 files added
  • modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql
6 files modified with 277 lines added and 30 lines removed
  • build.sbt with 31 added and 28 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 26 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala with 25 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala with 59 added and 1 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 129 added and 1 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 7 added and 0 removed lines
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-11 02:55:52.207097712 +0000
+++ new-smederee/build.sbt	2025-01-11 02:55:52.207097712 +0000
@@ -169,10 +169,11 @@
                 library.pureConfig,
                 library.quickLens,
                 library.springSecurityCrypto,
-                library.munit           % Test,
-                library.munitCatsEffect % Test,
-                library.munitScalaCheck % Test,
-                library.scalaCheck      % Test
+                library.munit            % Test,
+                library.munitCatsEffect  % Test,
+                library.munitScalaCheck  % Test,
+                library.scalaCheck       % Test,
+                library.scalaCheckEffect % Test
             ),
             TwirlKeys.templateImports ++= Seq(
                 "cats.*",
@@ -273,30 +274,31 @@
 lazy val library =
     new {
         object Version {
-            val apacheSshd      = "2.13.1"
-            val bouncyCastle    = "1.78.1"
-            val cats            = "2.12.0"
-            val catsEffect      = "3.5.4"
-            val circe           = "0.14.9"
-            val doobie          = "1.0.0-RC5"
-            val flyway          = "10.15.2"
-            val fs2             = "3.5.0"
-            val http4s          = "1.0.0-M41"
-            val ip4s            = "3.6.0"
-            val jansi           = "2.4.5"
-            val jclOverSlf4j    = "2.0.13"
-            val laika           = "1.1.0"
-            val log4cats        = "2.7.0"
-            val logback         = "1.5.6"
-            val munit           = "1.0.0"
-            val munitCatsEffect = "2.0.0"
-            val osLib           = "0.10.2"
-            val postgresql      = "42.7.3"
-            val pureConfig      = "0.17.7"
-            val quickLens       = "1.9.7"
-            val scalaCheck      = "1.18.0"
-            val simpleJavaMail  = "8.11.2"
-            val springSecurity  = "6.3.1"
+            val apacheSshd       = "2.13.1"
+            val bouncyCastle     = "1.78.1"
+            val cats             = "2.12.0"
+            val catsEffect       = "3.5.4"
+            val circe            = "0.14.9"
+            val doobie           = "1.0.0-RC5"
+            val flyway           = "10.15.2"
+            val fs2              = "3.5.0"
+            val http4s           = "1.0.0-M41"
+            val ip4s             = "3.6.0"
+            val jansi            = "2.4.5"
+            val jclOverSlf4j     = "2.0.13"
+            val laika            = "1.1.0"
+            val log4cats         = "2.7.0"
+            val logback          = "1.5.6"
+            val munit            = "1.0.0"
+            val munitCatsEffect  = "2.0.0"
+            val osLib            = "0.10.2"
+            val postgresql       = "42.7.3"
+            val pureConfig       = "0.17.7"
+            val quickLens        = "1.9.7"
+            val scalaCheck       = "1.18.0"
+            val scalaCheckEffect = "1.0.4"
+            val simpleJavaMail   = "8.11.2"
+            val springSecurity   = "6.3.1"
         }
         val apacheSshdCore       = "org.apache.sshd"              % "sshd-core"                  % Version.apacheSshd
         val apacheSshdSftp       = "org.apache.sshd"              % "sshd-sftp"                  % Version.apacheSshd
@@ -335,6 +337,7 @@
         val pureConfig           = "com.github.pureconfig"       %% "pureconfig-core"            % Version.pureConfig
         val quickLens            = "com.softwaremill.quicklens"  %% "quicklens"                  % Version.quickLens
         val scalaCheck           = "org.scalacheck"              %% "scalacheck"                 % Version.scalaCheck
+        val scalaCheckEffect     = "org.typelevel"               %% "scalacheck-effect-munit"    % Version.scalaCheckEffect
         val simpleJavaMail       = "org.simplejavamail"           % "simple-java-mail"           % Version.simpleJavaMail
         val springSecurityCrypto = "org.springframework.security" % "spring-security-crypto"     % Version.springSecurity
     }
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V9__repository_permissions.sql	2025-01-11 02:55:52.207097712 +0000
@@ -0,0 +1,20 @@
+CREATE TABLE hub.repo_orga_perms (
+  repository   BIGINT  NOT NULL,
+  organisation UUID    NOT NULL,
+  permissions  INTEGER NOT NULL DEFAULT 0,
+  CONSTRAINT repo_orga_perms_pk     PRIMARY KEY (repository, organisation),
+  CONSTRAINT repo_orga_perms_fk_rid FOREIGN KEY (repository)
+    REFERENCES hub.repositories (id) ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT repo_orga_perms_fk_oid FOREIGN KEY (organisation)
+    REFERENCES hub.organisations (id) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+-- Create an index to allow fast queries against repo and permissions.
+CREATE INDEX repo_orga_perms_idx ON hub.repo_orga_perms (repository, permissions);
+
+COMMENT ON TABLE hub.repo_orga_perms IS 'Mapping table to save permissions for organisations on repositories.';
+COMMENT ON COLUMN hub.repo_orga_perms.repository IS 'The internal database ID of a repository.';
+COMMENT ON COLUMN hub.repo_orga_perms.organisation IS 'The globally unique ID of an organisation.';
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-11 02:55:52.207097712 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-11 02:55:52.207097712 +0000
@@ -12,6 +12,7 @@
 import cats.syntax.all.*
 import de.smederee.email.EmailAddress
 import de.smederee.hub.VcsMetadataRepositoriesOrdering.*
+import de.smederee.security.Permission
 import de.smederee.security.UserId
 import de.smederee.security.Username
 import doobie.*
@@ -23,6 +24,8 @@
 
 final class DoobieVcsMetadataRepository[F[_]: Sync](tx: Transactor[F]) extends VcsMetadataRepository[F] {
     given Meta[EmailAddress]             = Meta[String].timap(EmailAddress.apply)(_.toString)
+    given Meta[OrganisationId]           = Meta[UUID].timap(OrganisationId.apply)(_.toUUID)
+    given Meta[Set[Permission]]          = Meta[Int].timap(Permission.decode)(_.encode)
     given Meta[Uri]                      = Meta[String].timap(Uri.unsafeFromString)(_.toString)
     given Meta[UserId]                   = Meta[UUID].timap(UserId.apply)(_.toUUID)
     given Meta[Username]                 = Meta[String].timap(Username.apply)(_.toString)
@@ -130,6 +133,15 @@
         query.query[VcsRepository].option.transact(tx)
     }
 
+    override def getPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation): F[Set[Permission]] =
+        sql"""SELECT COALESCE(
+            (
+                SELECT permissions
+                FROM hub.repo_orga_perms
+                WHERE repository = $repositoryId
+                AND organisation = ${organisation.oid}
+            ), 0)""".query[Set[Permission]].unique.transact(tx)
+
     override def listAllRepositories(
         requester: Option[Account]
     )(ordering: VcsMetadataRepositoriesOrdering): Stream[F, VcsRepository] = {
@@ -160,6 +172,20 @@
         query.query[VcsRepository].stream.transact(tx)
     }
 
+    override def setPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation)(
+        permissions: Set[Permission]
+    ): F[Int] =
+        sql"""INSERT INTO hub.repo_orga_perms (
+            repository,
+            organisation,
+            permissions
+        ) VALUES (
+            $repositoryId,
+            ${organisation.oid},
+            $permissions
+        ) ON CONFLICT (repository, organisation) DO UPDATE SET
+            permissions = EXCLUDED.permissions""".update.run.transact(tx)
+
     override def updateVcsRepository(repository: VcsRepository): F[Int] =
         sql"""UPDATE hub.repositories
             SET is_private = ${repository.isPrivate},
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-01-11 02:55:52.207097712 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-01-11 02:55:52.207097712 +0000
@@ -6,6 +6,7 @@
 
 package de.smederee.hub
 
+import de.smederee.security.Permission
 import de.smederee.security.Username
 import fs2.Stream
 
@@ -46,6 +47,17 @@
       */
     def deleteVcsRepository(repository: VcsRepository): F[Int]
 
+    /** Get the permissions for the given repository and organisation.
+      *
+      * @param repositoryId
+      *   The id of the repository.
+      * @param organisation
+      *   The organisation for which permissions shall be set.
+      * @return
+      *   A permission set that may be empty.
+      */
+    def getPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation): F[Set[Permission]]
+
     /** Search for the vcs repository entry with the given owner and name.
       *
       * @param owner
@@ -125,6 +137,19 @@
       */
     def listRepositories(requester: Option[Account])(owner: VcsRepositoryOwner): Stream[F, VcsRepository]
 
+    /** Set the permissions on the given repository for the given organisation.
+      *
+      * @param repositoryId
+      *   The id of the repository.
+      * @param organisation
+      *   The organisation for which permissions shall be set.
+      * @param permissions
+      *   The permissions that shall be set.
+      * @return
+      *   The number of affected database rows.
+      */
+    def setPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation)(permissions: Set[Permission]): F[Int]
+
     /** Update the database entry for the given vcs repository.
       *
       * @param repository
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala	2025-01-11 02:55:52.207097712 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala	2025-01-11 02:55:52.207097712 +0000
@@ -33,7 +33,7 @@
   * does initialise the test database for each suite. The latter means a possibly existing database with the name
   * configured **will be deleted**!
   */
-abstract class BaseSpec extends CatsEffectSuite {
+abstract class BaseSpec extends CatsEffectSuite with ScalaCheckEffectSuite {
 
     protected final val configuration: SmedereeHubConfig =
         ConfigSource
@@ -202,6 +202,64 @@
                 r <- IO.delay(statement.executeUpdate())
                 _ <- IO.delay(statement.close())
             } yield r
+        }
+
+    /** Create the given organisation in the database. Related data (i.e. the owner account) must already exists!
+      *
+      * @param organisation
+      *   The organisation to be created.
+      * @return
+      *   The number of affected database rows.
+      */
+    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+    protected def createOrganisation(organisation: Organisation): IO[Int] =
+        connectToDb(configuration).use { con =>
+            for {
+                statement <- IO.delay(
+                    con.prepareStatement("""|INSERT INTO hub.organisations (
+                                            |id,
+                                            |name,
+                                            |owner,
+                                            |full_name,
+                                            |description,
+                                            |is_private,
+                                            |website,
+                                            |created_at,
+                                            |updated_at
+                                            |) VALUES (
+                                            |  ?,
+                                            |  ?,
+                                            |  ?,
+                                            |  ?,
+                                            |  ?,
+                                            |  ?,
+                                            |  ?,
+                                            |  NOW(),
+                                            |  NOW()
+                                            |)""".stripMargin)
+                )
+                _ <- IO.delay(statement.setObject(1, organisation.oid))
+                _ <- IO.delay(statement.setString(2, organisation.name.toString))
+                _ <- IO.delay(statement.setObject(3, organisation.owner))
+                _ <- IO.delay(
+                    organisation.fullName.fold(statement.setNull(4, java.sql.Types.VARCHAR))(name =>
+                        statement.setString(4, name.toString)
+                    )
+                )
+                _ <- IO.delay(
+                    organisation.description.fold(statement.setNull(5, java.sql.Types.VARCHAR))(desc =>
+                        statement.setString(5, desc.toString)
+                    )
+                )
+                _ <- IO.delay(statement.setBoolean(6, organisation.isPrivate))
+                _ <- IO.delay(
+                    organisation.website.fold(statement.setNull(7, java.sql.Types.VARCHAR))(uri =>
+                        statement.setString(7, uri.toString)
+                    )
+                )
+                r <- IO.delay(statement.executeUpdate())
+                _ <- IO.delay(statement.close())
+            } yield r
         }
 
     /** Create the given user session in the database.
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-11 02:55:52.207097712 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-11 02:55:52.211097718 +0000
@@ -11,14 +11,18 @@
 import de.smederee.TestTags.*
 import de.smederee.email.EmailAddress
 import de.smederee.hub.Generators.*
+import de.smederee.hub.Generators.given
 import de.smederee.hub.VcsMetadataRepositoriesOrdering.*
 import de.smederee.security.*
 import doobie.*
 import org.http4s.implicits.*
 
+import org.scalacheck.effect.PropF
+
 import scala.collection.immutable.Queue
 
 final class DoobieVcsMetadataRepositoryTest extends BaseSpec {
+    override def scalaCheckTestParameters = super.scalaCheckTestParameters.withMinSuccessfulTests(1)
 
     /** Find all forks of the original repository with the given ID.
       *
@@ -372,6 +376,63 @@
         }
     }
 
+    test("getPermissions must return existing permissions".tag(NeedsDatabase)) {
+        PropF.forAllF {
+            (account: Account, repository: VcsRepository, org: Organisation, permissions: Set[Permission]) =>
+                val organisation  = org.copy(owner = account.uid)
+                val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+                val dbConfig      = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieVcsMetadataRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    _       <- createOrganisation(organisation)
+                    _       <- repo.createVcsRepository(vcsRepository)
+                    repoId  <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name)
+                    created <- repoId.traverse(id => repo.setPermissions(id)(organisation)(permissions))
+                    found   <- repoId.traverse(id => repo.getPermissions(id)(organisation))
+                } yield (created.getOrElse(0), found.getOrElse(Set.empty))
+                test.start.flatMap(_.joinWithNever).map { result =>
+                    val (created, found) = result
+                    assert(created === 1, "No rows written to database!")
+                    assertEquals(found, permissions)
+                }
+        }
+    }
+
+    test("getPermissions must return empty permissions for non existing entries".tag(NeedsDatabase)) {
+        PropF.forAllF { (account: Account, repository: VcsRepository, org: Organisation) =>
+            val expectedPermissions = Permission.decode(0)
+            val organisation        = org.copy(owner = account.uid)
+            val vcsRepository       = repository.copy(owner = account.toVcsRepositoryOwner)
+            val dbConfig            = configuration.database
+            val tx = Transactor.fromDriverManager[IO](
+                driver = dbConfig.driver,
+                url = dbConfig.url,
+                user = dbConfig.user,
+                password = dbConfig.pass,
+                logHandler = None
+            )
+            val repo = new DoobieVcsMetadataRepository[IO](tx)
+            val test = for {
+                _      <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                _      <- createOrganisation(organisation)
+                _      <- repo.createVcsRepository(vcsRepository)
+                repoId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name)
+                found  <- repoId.traverse(id => repo.getPermissions(id)(organisation))
+            } yield found.getOrElse(Set.empty)
+            test.start.flatMap(_.joinWithNever).map { found =>
+                assertEquals(found, expectedPermissions)
+            }
+        }
+    }
+
     test("listAllRepositories must return only public repositories for guest users".tag(NeedsDatabase)) {
         (genValidAccount.sample, genValidVcsRepositories.sample) match {
             case (Some(account), Some(repositories)) =>
@@ -609,6 +670,73 @@
         }
     }
 
+    test("setPermissions must create permissions correctly".tag(NeedsDatabase)) {
+        PropF.forAllF {
+            (account: Account, repository: VcsRepository, org: Organisation, permissions: Set[Permission]) =>
+                val organisation  = org.copy(owner = account.uid)
+                val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+                val dbConfig      = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieVcsMetadataRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    _       <- createOrganisation(organisation)
+                    _       <- repo.createVcsRepository(vcsRepository)
+                    repoId  <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name)
+                    created <- repoId.traverse(id => repo.setPermissions(id)(organisation)(permissions))
+                    found   <- repoId.traverse(id => repo.getPermissions(id)(organisation))
+                } yield (created.getOrElse(0), found.getOrElse(Set.empty))
+                test.start.flatMap(_.joinWithNever).map { result =>
+                    val (created, found) = result
+                    assert(created === 1, "No rows written to database!")
+                    assertEquals(found, permissions)
+                }
+        }
+    }
+
+    test("setPermissions must update permissions correctly".tag(NeedsDatabase)) {
+        PropF.forAllF {
+            (
+                account: Account,
+                repository: VcsRepository,
+                org: Organisation,
+                permissions: Set[Permission],
+                updatedPermissions: Set[Permission]
+            ) =>
+                val organisation  = org.copy(owner = account.uid)
+                val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+                val dbConfig      = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val repo = new DoobieVcsMetadataRepository[IO](tx)
+                val test = for {
+                    _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                    _       <- createOrganisation(organisation)
+                    _       <- repo.createVcsRepository(vcsRepository)
+                    repoId  <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name)
+                    _       <- repoId.traverse(id => repo.setPermissions(id)(organisation)(permissions))
+                    updated <- repoId.traverse(id => repo.setPermissions(id)(organisation)(updatedPermissions))
+                    found   <- repoId.traverse(id => repo.getPermissions(id)(organisation))
+                } yield (updated.getOrElse(0), found.getOrElse(Set.empty))
+                test.start.flatMap(_.joinWithNever).map { result =>
+                    val (updated, found) = result
+                    assert(updated === 1, "No rows written to database!")
+                    assertEquals(found, updatedPermissions)
+                }
+        }
+    }
+
     test("updateVcsRepository must update all columns correctly".tag(NeedsDatabase)) {
         (genValidAccount.sample, genValidVcsRepositories.sample) match {
             case (Some(account), Some(repository :: repositories)) =>
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-11 02:55:52.207097712 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-11 02:55:52.211097718 +0000
@@ -149,6 +149,8 @@
             website = None
         )
 
+    given Arbitrary[Organisation] = Arbitrary(genOrganisation)
+
     val genOrganisations: Gen[List[Organisation]] = Gen.nonEmptyListOf(genOrganisation)
 
     val genValidSession: Gen[Session] =
@@ -227,6 +229,11 @@
         vcsType        <- genValidVcsType
     } yield VcsRepository(name, owner, isPrivate, description, ticketsEnabled, vcsType, None)
 
+    given Arbitrary[VcsRepository] = Arbitrary(genValidVcsRepository)
+
     val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository)
 
+    val genPermissions: Gen[Set[Permission]] = Gen.choose(0, 7).map(Permission.decode)
+
+    given Arbitrary[Set[Permission]] = Arbitrary(genPermissions)
 }