Compare commits
17 commits
main
...
smolban-db
| Author | SHA1 | Date | |
|---|---|---|---|
| 42e89fa169 | |||
| 5481686e08 | |||
| 5c7c33d1b5 | |||
| 53a0114cbb | |||
| f10f79ed95 | |||
| 8d0567195a | |||
| f25c9658eb | |||
| a7e2185204 | |||
| a8ba253ea1 | |||
| 447ebfd255 | |||
| 5d85d6e6ad | |||
| a5bea9c7de | |||
| ab16e3fdf0 | |||
| fcc774390d | |||
| 41e9e797c1 | |||
| 1195bab53c | |||
| 456e625e82 |
72 changed files with 4077 additions and 601 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,3 +2,7 @@ target/
|
||||||
project/target/
|
project/target/
|
||||||
project/project/
|
project/project/
|
||||||
.version
|
.version
|
||||||
|
|
||||||
|
# the test directory is used when running tests as an ephemeral dumping ground
|
||||||
|
test/*
|
||||||
|
!test/README.md
|
||||||
|
|
|
||||||
65
build.sbt
65
build.sbt
|
|
@ -1,4 +1,4 @@
|
||||||
val scala3: String = "3.4.2"
|
val scala3: String = "3.8.1"
|
||||||
|
|
||||||
ThisBuild / scalaVersion := scala3
|
ThisBuild / scalaVersion := scala3
|
||||||
ThisBuild / gsProjectName := "smolban"
|
ThisBuild / gsProjectName := "smolban"
|
||||||
|
|
@ -18,39 +18,54 @@ lazy val sharedSettings = Seq(
|
||||||
|
|
||||||
val Deps = new {
|
val Deps = new {
|
||||||
val Cats = new {
|
val Cats = new {
|
||||||
val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.10.0"
|
val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0"
|
||||||
val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.5.4"
|
val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.6.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
val Fs2 = new {
|
val Fs2 = new {
|
||||||
val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.10.2"
|
val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.12.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
val Doobie = new {
|
val Doobie = new {
|
||||||
val Core: ModuleID = "org.tpolecat" %% "doobie-core" % "1.0.0-M5"
|
val Core: ModuleID = "org.tpolecat" %% "doobie-core" % "1.0.0-RC11"
|
||||||
|
val Hikari: ModuleID = "org.tpolecat" %% "doobie-hikari" % "1.0.0-RC11"
|
||||||
|
val Postgres: ModuleID = "org.tpolecat" %% "doobie-postgres" % "1.0.0-RC11"
|
||||||
|
}
|
||||||
|
|
||||||
|
val JdbcDriver = new {
|
||||||
|
val Sqlite: ModuleID = "org.xerial" % "sqlite-jdbc" % "3.51.1.0"
|
||||||
|
val PostgreSQL: ModuleID = "org.postgresql" % "postgresql" % "42.7.9"
|
||||||
}
|
}
|
||||||
|
|
||||||
val Http4s = new {
|
val Http4s = new {
|
||||||
val Core: ModuleID = "org.http4s" %% "http4s-core" % "1.0.0-M41"
|
val Core: ModuleID = "org.http4s" %% "http4s-core" % "1.0.0-M45"
|
||||||
val Dsl: ModuleID = "org.http4s" %% "http4s-dsl" % "1.0.0-M41"
|
val Dsl: ModuleID = "org.http4s" %% "http4s-dsl" % "1.0.0-M45"
|
||||||
val EmberServer: ModuleID =
|
val EmberServer: ModuleID =
|
||||||
"org.http4s" %% "http4s-ember-server" % "1.0.0-M41"
|
"org.http4s" %% "http4s-ember-server" % "1.0.0-M45"
|
||||||
|
}
|
||||||
|
|
||||||
|
val BouncyCastle = new {
|
||||||
|
val Provider: ModuleID = "org.bouncycastle" % "bcprov-jdk18on" % "1.83"
|
||||||
}
|
}
|
||||||
|
|
||||||
val Gs = new {
|
val Gs = new {
|
||||||
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.2.4"
|
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.2"
|
||||||
val Slug: ModuleID = "gs" %% "gs-slug-v0" % "0.1.3"
|
val Config: ModuleID = "gs" %% "gs-config-v0" % "0.2.0"
|
||||||
val Config: ModuleID = "gs" %% "gs-config-v0" % "0.1.1"
|
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.4.1"
|
||||||
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.1.1"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.0.0-RC1"
|
val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.2.1"
|
||||||
|
|
||||||
|
val Slf4j = new {
|
||||||
|
val Nop: ModuleID = "org.slf4j" % "slf4j-nop" % "2.0.17"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy val testSettings = Seq(
|
lazy val testSettings = Seq(
|
||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
Deps.MUnit % Test,
|
Deps.MUnit % Test,
|
||||||
Deps.Gs.Datagen % Test
|
Deps.Gs.Datagen % Test,
|
||||||
|
Deps.Slf4j.Nop % Test
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -68,24 +83,42 @@ lazy val model = project
|
||||||
.settings(
|
.settings(
|
||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
Deps.Gs.Uuid,
|
Deps.Gs.Uuid,
|
||||||
Deps.Gs.Slug,
|
|
||||||
Deps.Cats.Core
|
Deps.Cats.Core
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
lazy val auth = project
|
||||||
|
.in(file("modules/auth"))
|
||||||
|
.dependsOn(model)
|
||||||
|
.settings(sharedSettings)
|
||||||
|
.settings(testSettings)
|
||||||
|
.settings(name := s"${gsProjectName.value}-auth")
|
||||||
|
.settings(
|
||||||
|
libraryDependencies ++= Seq(
|
||||||
|
Deps.BouncyCastle.Provider,
|
||||||
|
Deps.Cats.Effect
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
lazy val db = project
|
lazy val db = project
|
||||||
.in(file("modules/db"))
|
.in(file("modules/db"))
|
||||||
|
.dependsOn(model, auth)
|
||||||
.settings(sharedSettings)
|
.settings(sharedSettings)
|
||||||
.settings(testSettings)
|
.settings(testSettings)
|
||||||
.settings(name := s"${gsProjectName.value}-db")
|
.settings(name := s"${gsProjectName.value}-db")
|
||||||
.settings(
|
.settings(
|
||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
Deps.Doobie.Core
|
Deps.Doobie.Core,
|
||||||
|
Deps.Doobie.Hikari,
|
||||||
|
Deps.Doobie.Postgres,
|
||||||
|
Deps.JdbcDriver.Sqlite,
|
||||||
|
Deps.JdbcDriver.PostgreSQL
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
lazy val api = project
|
lazy val api = project
|
||||||
.in(file("modules/api"))
|
.in(file("modules/api"))
|
||||||
|
.dependsOn(model, auth, db)
|
||||||
.settings(sharedSettings)
|
.settings(sharedSettings)
|
||||||
.settings(testSettings)
|
.settings(testSettings)
|
||||||
.settings(name := s"${gsProjectName.value}-api")
|
.settings(name := s"${gsProjectName.value}-api")
|
||||||
|
|
|
||||||
165
modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala
Normal file
165
modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.effect.Sync
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
|
||||||
|
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||||
|
|
||||||
|
/** Argon2 hashing support based on the Bouncy Castle library.
|
||||||
|
*
|
||||||
|
* @param config
|
||||||
|
* Algorithm and configuration parameters.
|
||||||
|
* @param secret
|
||||||
|
* The secret used to generate hashes.
|
||||||
|
* @param randomByteProvider
|
||||||
|
* Used to generate random bytes.
|
||||||
|
*/
|
||||||
|
final class Argon2[F[_]: Sync](
|
||||||
|
val config: Argon2.Config,
|
||||||
|
val secret: Argon2Secret,
|
||||||
|
val randomByteProvider: RandomByteProvider[F]
|
||||||
|
):
|
||||||
|
|
||||||
|
/** Calculate a new hash for some input.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The input string to hash.
|
||||||
|
* @return
|
||||||
|
* The calculated hash.
|
||||||
|
*/
|
||||||
|
def calculateHash(
|
||||||
|
input: String,
|
||||||
|
charset: Charset = StandardCharsets.UTF_8
|
||||||
|
): F[Argon2Hash] =
|
||||||
|
calculateHash(input.getBytes(charset))
|
||||||
|
|
||||||
|
def calculateHash(input: Array[Byte]): F[Argon2Hash] =
|
||||||
|
randomSalt().map { salt =>
|
||||||
|
val ap = buildAlgorithmParams(salt)
|
||||||
|
val a2 = new Argon2BytesGenerator()
|
||||||
|
val _ = a2.init(ap)
|
||||||
|
val bytes = new Array[Byte](config.hashLengthInBytes)
|
||||||
|
val _ = a2.generateBytes(input, bytes)
|
||||||
|
new Argon2Hash(
|
||||||
|
algorithmVersion = config.algorithmVersion,
|
||||||
|
algorithmType = config.algorithmType,
|
||||||
|
iterations = config.iterations,
|
||||||
|
parallelism = config.parallelism,
|
||||||
|
memoryInKb = config.memoryInKb,
|
||||||
|
salt = salt,
|
||||||
|
hash = bytes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Given some input, determine if that input matches a stored hash.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The input to compare.
|
||||||
|
* @param stored
|
||||||
|
* The stored hash.
|
||||||
|
* @return
|
||||||
|
* True if the input matches the hash, false otherwise.
|
||||||
|
*/
|
||||||
|
def doesInputMatch(
|
||||||
|
input: String,
|
||||||
|
stored: Argon2Hash
|
||||||
|
): F[Boolean] =
|
||||||
|
Sync[F].delay {
|
||||||
|
// Use the stored hash as the parameter source.
|
||||||
|
val ap = buildAlgorithmParams(stored)
|
||||||
|
|
||||||
|
// Hash the input using the stored parameters.
|
||||||
|
val a2 = new Argon2BytesGenerator()
|
||||||
|
val _ = a2.init(ap)
|
||||||
|
val bytes = new Array[Byte](stored.hash.length)
|
||||||
|
val _ = a2.generateBytes(input.getBytes(StandardCharsets.UTF_8), bytes)
|
||||||
|
|
||||||
|
// Compare the bytes to determine if the input matches the stored hash.
|
||||||
|
bytes.sameElements(stored.hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def randomSalt(): F[Array[Byte]] =
|
||||||
|
randomByteProvider.generateBytes(config.saltLengthInBytes)
|
||||||
|
|
||||||
|
private def buildAlgorithmParams(salt: Array[Byte]): Argon2Parameters =
|
||||||
|
new Argon2Parameters.Builder(config.algorithmType)
|
||||||
|
.withIterations(config.iterations)
|
||||||
|
.withMemoryAsKB(config.memoryInKb)
|
||||||
|
.withParallelism(config.parallelism)
|
||||||
|
.withSalt(salt)
|
||||||
|
.withSecret(secret.bytes)
|
||||||
|
.withVersion(config.algorithmVersion)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private def buildAlgorithmParams(hash: Argon2Hash): Argon2Parameters =
|
||||||
|
new Argon2Parameters.Builder(hash.algorithmType)
|
||||||
|
.withVersion(hash.algorithmVersion)
|
||||||
|
.withIterations(hash.iterations)
|
||||||
|
.withParallelism(hash.parallelism)
|
||||||
|
.withMemoryAsKB(hash.memoryInKb)
|
||||||
|
.withSalt(hash.salt)
|
||||||
|
.withSecret(secret.bytes)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
object Argon2:
|
||||||
|
val Algorithm: Int = Argon2Parameters.ARGON2_id
|
||||||
|
|
||||||
|
/** Includes all parameters needed to support Argon2 for Smolban. Includes all
|
||||||
|
* standard algorithm parameters, along with additional information such as
|
||||||
|
* the number of bytes that Smolban uses to produce hashes.
|
||||||
|
*
|
||||||
|
* @param algorithmVersion
|
||||||
|
* The algorithm version used to hash bytes.
|
||||||
|
* @param algorithmType
|
||||||
|
* The Argon2 variant used to hash bytes.
|
||||||
|
* @param iterations
|
||||||
|
* Number of algorithm iterations.
|
||||||
|
* @param parallelism
|
||||||
|
* Amount of algorithm parallelism.
|
||||||
|
* @param memoryInKb
|
||||||
|
* Memory, in KB, used by the algorithm.
|
||||||
|
* @param saltLengthInBytes
|
||||||
|
* Number of bytes Smolban uses for salts.
|
||||||
|
* @param hashLengthInBytes
|
||||||
|
* Number of bytes Smolban uses for output hashes.
|
||||||
|
*/
|
||||||
|
case class Config(
|
||||||
|
algorithmVersion: Int,
|
||||||
|
algorithmType: Int,
|
||||||
|
iterations: Int,
|
||||||
|
parallelism: Int,
|
||||||
|
memoryInKb: Int,
|
||||||
|
saltLengthInBytes: Int,
|
||||||
|
hashLengthInBytes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* [[Argon2.Params]] with default settings. Suitable for most cases.
|
||||||
|
*/
|
||||||
|
def defaultConfig(): Config =
|
||||||
|
Config(
|
||||||
|
algorithmVersion = Defaults.AlgorithmVersion,
|
||||||
|
algorithmType = Defaults.AlgorithmType,
|
||||||
|
iterations = Defaults.Iterations,
|
||||||
|
parallelism = Defaults.Parallelism,
|
||||||
|
memoryInKb = Defaults.MemoryInKB,
|
||||||
|
saltLengthInBytes = Defaults.SaltLengthInBytes,
|
||||||
|
hashLengthInBytes = Defaults.HashLengthInBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
object Defaults:
|
||||||
|
|
||||||
|
val AlgorithmVersion: Int = Argon2Parameters.ARGON2_VERSION_13
|
||||||
|
val AlgorithmType: Int = Argon2Parameters.ARGON2_id
|
||||||
|
val Iterations: Int = 3
|
||||||
|
val Parallelism: Int = 2
|
||||||
|
val SaltLengthInBytes: Int = 16
|
||||||
|
val MemoryInKB: Int = 1024
|
||||||
|
val HashLengthInBytes: Int = 32
|
||||||
|
val KeyLengthInBytes: Int = 32
|
||||||
|
|
||||||
|
end Defaults
|
||||||
|
|
||||||
|
end Argon2
|
||||||
170
modules/auth/src/main/scala/gs/smolban/auth/Argon2Hash.scala
Normal file
170
modules/auth/src/main/scala/gs/smolban/auth/Argon2Hash.scala
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.Objects
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Represents an Argon2 hash packed with the parameters that produced it.
|
||||||
|
*
|
||||||
|
* @param algorithmVersion
|
||||||
|
* The Argon2 algorithm version.
|
||||||
|
* @param algorithmType
|
||||||
|
* The Argon2 type.
|
||||||
|
* @param iterations
|
||||||
|
* The Argon2 number of iterations.
|
||||||
|
* @param parallelism
|
||||||
|
* The Argon2 parallelism factor.
|
||||||
|
* @param memoryInKb
|
||||||
|
* The Argon2 memory, expressed in KB.
|
||||||
|
* @param salt
|
||||||
|
* The unencoded salt.
|
||||||
|
* @param hash
|
||||||
|
* The unencoded hash.
|
||||||
|
*/
|
||||||
|
final class Argon2Hash(
|
||||||
|
val algorithmVersion: Int,
|
||||||
|
val algorithmType: Int,
|
||||||
|
val iterations: Int,
|
||||||
|
val parallelism: Int,
|
||||||
|
val memoryInKb: Int,
|
||||||
|
val salt: Array[Byte],
|
||||||
|
val hash: Array[Byte]
|
||||||
|
):
|
||||||
|
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: Argon2Hash =>
|
||||||
|
(algorithmVersion == other.algorithmVersion)
|
||||||
|
&& (algorithmType == other.algorithmType)
|
||||||
|
&& (iterations == other.iterations)
|
||||||
|
&& (parallelism == other.parallelism)
|
||||||
|
&& (memoryInKb == other.memoryInKb)
|
||||||
|
&& (salt.sameElements(other.salt))
|
||||||
|
&& (hash.sameElements(other.hash))
|
||||||
|
|
||||||
|
override def hashCode(): Int =
|
||||||
|
Objects.hash(
|
||||||
|
algorithmVersion,
|
||||||
|
algorithmType,
|
||||||
|
iterations,
|
||||||
|
parallelism,
|
||||||
|
memoryInKb,
|
||||||
|
salt,
|
||||||
|
hash
|
||||||
|
)
|
||||||
|
|
||||||
|
override def toString(): String = encode()
|
||||||
|
|
||||||
|
/** Encode this hash as a '$' delimited string that includes all parameters.
|
||||||
|
* This string can be parsed by using the `decode` function.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The encoded hash string.
|
||||||
|
*/
|
||||||
|
def encode(): String =
|
||||||
|
s"v=$algorithmVersion$$t=$algorithmType$$i=$iterations$$p=$parallelism$$m=$memoryInKb$$${encodedSalt()}$$${encodedHash()}"
|
||||||
|
|
||||||
|
private def encodedSalt(): String =
|
||||||
|
Base64.getEncoder().encodeToString(salt)
|
||||||
|
|
||||||
|
private def encodedHash(): String =
|
||||||
|
Base64.getEncoder().encodeToString(hash)
|
||||||
|
|
||||||
|
object Argon2Hash:
|
||||||
|
|
||||||
|
/** Decode a string produced by the [[Argon2Hash]] encode function.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The encoded string.
|
||||||
|
* @return
|
||||||
|
* The parsed hash, or `None` if the input is not a valid hash.
|
||||||
|
*/
|
||||||
|
def decode(input: String): Option[Argon2Hash] =
|
||||||
|
val parts = input.split("\\$")
|
||||||
|
if parts.length != 7 then None
|
||||||
|
else
|
||||||
|
for
|
||||||
|
v <- parseInt(parts(0))
|
||||||
|
t <- parseInt(parts(1))
|
||||||
|
i <- parseInt(parts(2))
|
||||||
|
p <- parseInt(parts(3))
|
||||||
|
m <- parseInt(parts(4))
|
||||||
|
s <- parseBytes(parts(5))
|
||||||
|
h <- parseBytes(parts(6))
|
||||||
|
out <- new Builder()
|
||||||
|
.withAlgorithmVersion(v)
|
||||||
|
.withAlgorithmType(t)
|
||||||
|
.withIterations(i)
|
||||||
|
.withParallelism(p)
|
||||||
|
.withMemoryInKb(m)
|
||||||
|
.withSalt(s)
|
||||||
|
.withHash(h)
|
||||||
|
.build()
|
||||||
|
yield out
|
||||||
|
|
||||||
|
private def parseInt(input: String): Option[Int] =
|
||||||
|
val parts = input.split("\\=")
|
||||||
|
if parts.length != 2 then None
|
||||||
|
else parts(1).toIntOption
|
||||||
|
|
||||||
|
private def parseBytes(input: String): Option[Array[Byte]] =
|
||||||
|
Try(Base64.getDecoder().decode(input)).toOption
|
||||||
|
|
||||||
|
private class Builder(
|
||||||
|
var algorithmVersion: Option[Int] = None,
|
||||||
|
var algorithmType: Option[Int] = None,
|
||||||
|
var iterations: Option[Int] = None,
|
||||||
|
var parallelism: Option[Int] = None,
|
||||||
|
var memoryInKb: Option[Int] = None,
|
||||||
|
var salt: Option[Array[Byte]] = None,
|
||||||
|
var hash: Option[Array[Byte]] = None
|
||||||
|
):
|
||||||
|
|
||||||
|
def withAlgorithmVersion(input: Int): Builder =
|
||||||
|
algorithmVersion = Some(input)
|
||||||
|
this
|
||||||
|
|
||||||
|
def withAlgorithmType(input: Int): Builder =
|
||||||
|
algorithmType = Some(input)
|
||||||
|
this
|
||||||
|
|
||||||
|
def withIterations(input: Int): Builder =
|
||||||
|
iterations = Some(input)
|
||||||
|
this
|
||||||
|
|
||||||
|
def withParallelism(input: Int): Builder =
|
||||||
|
parallelism = Some(input)
|
||||||
|
this
|
||||||
|
|
||||||
|
def withMemoryInKb(input: Int): Builder =
|
||||||
|
memoryInKb = Some(input)
|
||||||
|
this
|
||||||
|
|
||||||
|
def withSalt(input: Array[Byte]): Builder =
|
||||||
|
salt = Some(input)
|
||||||
|
this
|
||||||
|
|
||||||
|
def withHash(input: Array[Byte]): Builder =
|
||||||
|
hash = Some(input)
|
||||||
|
this
|
||||||
|
|
||||||
|
def build(): Option[Argon2Hash] =
|
||||||
|
for
|
||||||
|
v <- algorithmVersion
|
||||||
|
t <- algorithmType
|
||||||
|
i <- iterations
|
||||||
|
p <- parallelism
|
||||||
|
m <- memoryInKb
|
||||||
|
s <- salt
|
||||||
|
h <- hash
|
||||||
|
yield new Argon2Hash(
|
||||||
|
algorithmVersion = v,
|
||||||
|
algorithmType = t,
|
||||||
|
iterations = i,
|
||||||
|
parallelism = p,
|
||||||
|
memoryInKb = m,
|
||||||
|
salt = s,
|
||||||
|
hash = h
|
||||||
|
)
|
||||||
|
|
||||||
|
end Argon2Hash
|
||||||
107
modules/auth/src/main/scala/gs/smolban/auth/Argon2Secret.scala
Normal file
107
modules/auth/src/main/scala/gs/smolban/auth/Argon2Secret.scala
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.Applicative
|
||||||
|
import cats.effect.Sync
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
/** Secret intended for use with [[Argon2]]. Random array of bytes.
|
||||||
|
*
|
||||||
|
* @param bytes
|
||||||
|
* The underlying bytes that represent the secret.
|
||||||
|
*/
|
||||||
|
final class Argon2Secret(val bytes: Array[Byte]):
|
||||||
|
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: Argon2Secret => bytes.sameElements(other.bytes)
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
override def hashCode(): Int = bytes.hashCode()
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The value of this secret, encoded using Base64.
|
||||||
|
*/
|
||||||
|
def encodeBase64(): String = Base64.getEncoder().encodeToString(bytes)
|
||||||
|
|
||||||
|
/** Write this secret to a local file.
|
||||||
|
*
|
||||||
|
* @param path
|
||||||
|
* The path to write. Any existing file will be truncated.
|
||||||
|
* @return
|
||||||
|
* An effect which writes the secret.
|
||||||
|
*/
|
||||||
|
def writeToLocalFile[F[_]: Sync](path: Path): F[Unit] =
|
||||||
|
Argon2Secret.writeToLocalFile[F](path, this)
|
||||||
|
|
||||||
|
object Argon2Secret:
|
||||||
|
|
||||||
|
/** Decode the given Base64 string as an [[Argon2Secret]].
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The string that houses encoded bytes.
|
||||||
|
* @return
|
||||||
|
* The new [[Argon2Secret]] instance, backed by the input bytes.
|
||||||
|
*/
|
||||||
|
def decode(input: String): Argon2Secret =
|
||||||
|
new Argon2Secret(Base64.getDecoder().decode(input))
|
||||||
|
|
||||||
|
/** Load a secret from a local file. The following assumptions are made:
|
||||||
|
*
|
||||||
|
* - The file contains nothing but the base64-encoded bytes.
|
||||||
|
* - The file has no newlines.
|
||||||
|
*
|
||||||
|
* @param path
|
||||||
|
* The path to the local file.
|
||||||
|
* @return
|
||||||
|
* The loaded secret.
|
||||||
|
*/
|
||||||
|
def loadFromLocalFile[F[_]: Sync](path: Path): F[Argon2Secret] =
|
||||||
|
Sync[F]
|
||||||
|
.delay(
|
||||||
|
Files.readString(path, StandardCharsets.UTF_8)
|
||||||
|
)
|
||||||
|
.map(decode)
|
||||||
|
|
||||||
|
/** Write a secret to a local file. The resultant file will contain the
|
||||||
|
* base64-encoded bytes of the given secret.
|
||||||
|
*
|
||||||
|
* @param path
|
||||||
|
* The path for the local file. This file will be truncated if it exists.
|
||||||
|
* @param secret
|
||||||
|
* The secret to write.
|
||||||
|
* @return
|
||||||
|
* An effect which writes the secret.
|
||||||
|
*/
|
||||||
|
def writeToLocalFile[F[_]: Sync](
|
||||||
|
path: Path,
|
||||||
|
secret: Argon2Secret
|
||||||
|
): F[Unit] =
|
||||||
|
Sync[F].delay(
|
||||||
|
Files.writeString(
|
||||||
|
path,
|
||||||
|
secret.encodeBase64(),
|
||||||
|
StandardOpenOption.TRUNCATE_EXISTING
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Generate a new random secret.
|
||||||
|
*
|
||||||
|
* @param size
|
||||||
|
* The number of bytes in the secret value.
|
||||||
|
* @param randomByteProvider
|
||||||
|
* The [[RandomByteProvider]].
|
||||||
|
* @return
|
||||||
|
* The new [[Argon2Secret]].
|
||||||
|
*/
|
||||||
|
def generate[F[_]: Applicative](
|
||||||
|
size: Int,
|
||||||
|
randomByteProvider: RandomByteProvider[F]
|
||||||
|
): F[Argon2Secret] =
|
||||||
|
randomByteProvider.generateBytes(size).map(new Argon2Secret(_))
|
||||||
|
|
||||||
|
end Argon2Secret
|
||||||
48
modules/auth/src/main/scala/gs/smolban/auth/Base64.scala
Normal file
48
modules/auth/src/main/scala/gs/smolban/auth/Base64.scala
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Opaque type for a Base64-encoded String.
|
||||||
|
*/
|
||||||
|
opaque type Base64 = String
|
||||||
|
|
||||||
|
object Base64:
|
||||||
|
|
||||||
|
given CanEqual[Base64, Base64] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Base64] = b64 => b64
|
||||||
|
|
||||||
|
given Eq[Base64] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
private lazy val encoder = java.util.Base64.getEncoder()
|
||||||
|
private lazy val decoder = java.util.Base64.getDecoder()
|
||||||
|
|
||||||
|
/** Instantiate a new [[Base64]] instance by encoding the given bytes.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The input bytes.
|
||||||
|
* @return
|
||||||
|
* The encoded string representation of the given bytes.
|
||||||
|
*/
|
||||||
|
def encode(input: Array[Byte]): Base64 =
|
||||||
|
encoder.encodeToString(input)
|
||||||
|
|
||||||
|
def decodeUnsafe(input: String): Array[Byte] =
|
||||||
|
decoder.decode(input)
|
||||||
|
|
||||||
|
extension (b64: Base64)
|
||||||
|
/** @return
|
||||||
|
* The decoded byte array that this string represents.
|
||||||
|
*/
|
||||||
|
def decode(): Array[Byte] = decoder.decode(b64)
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The underlying string value.
|
||||||
|
*/
|
||||||
|
def unwrap(): String = b64
|
||||||
|
|
||||||
|
end Base64
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
/** Client secrets _must_ be passed into Smolban as [[RsaEncryptedBytes]].
|
||||||
|
* Smolban never assumes clear text inputs.
|
||||||
|
*
|
||||||
|
* This is an opaque type for encrypted bytes.
|
||||||
|
*
|
||||||
|
* This type is explicitly intended for use in authentication flows. It is not
|
||||||
|
* intended for any other use (e.g. generating new secrets).
|
||||||
|
*/
|
||||||
|
opaque type ClientSecret = RsaEncryptedBytes
|
||||||
|
|
||||||
|
object ClientSecret:
|
||||||
|
|
||||||
|
given CanEqual[ClientSecret, ClientSecret] = CanEqual.derived
|
||||||
|
|
||||||
|
/** Instantiate a new [[ClientSecret]] from the given encrypted bytes.
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
* The encrypted bytes.
|
||||||
|
* @return
|
||||||
|
* The new [[ClientSecret]] instance.
|
||||||
|
*/
|
||||||
|
def apply(value: RsaEncryptedBytes): ClientSecret = value
|
||||||
|
|
||||||
|
extension (clientSecret: ClientSecret)
|
||||||
|
def unwrap(): RsaEncryptedBytes = clientSecret
|
||||||
|
|
||||||
|
end ClientSecret
|
||||||
33
modules/auth/src/main/scala/gs/smolban/auth/Credential.scala
Normal file
33
modules/auth/src/main/scala/gs/smolban/auth/Credential.scala
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import gs.smolban.model.account.AccountId
|
||||||
|
import gs.smolban.model.account.AccountType
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
|
||||||
|
/** Describes some credential but does not contain the actual credential value.
|
||||||
|
*
|
||||||
|
* @param credentialId
|
||||||
|
* The unique identifier of this credential.
|
||||||
|
* @param accountId
|
||||||
|
* The unique identifier of the account to which this credential is
|
||||||
|
* associated.
|
||||||
|
* @param accountType
|
||||||
|
* The type of account this credential is associated with.
|
||||||
|
* @param credentialType
|
||||||
|
* The type of credential.
|
||||||
|
* @param status
|
||||||
|
* The status of this credential.
|
||||||
|
* @param effectivity
|
||||||
|
* The effectivity of this credential, if defined.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant at which this credential was created.
|
||||||
|
*/
|
||||||
|
case class Credential(
|
||||||
|
credentialId: CredentialId,
|
||||||
|
accountId: AccountId,
|
||||||
|
accountType: AccountType,
|
||||||
|
credentialType: CredentialType,
|
||||||
|
status: CredentialStatus,
|
||||||
|
effectivity: Option[CredentialEffectivity],
|
||||||
|
createdAt: CreatedAt
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.effect.Sync
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** Describes the effectivity for some credential. In Smolban, credentials _may_
|
||||||
|
* be configured such that they are only valid for a certain period of time.
|
||||||
|
*
|
||||||
|
* @param startDate
|
||||||
|
* The date where a credential becomes effective.
|
||||||
|
* @param endDate
|
||||||
|
* The date after which a credential is no longer effective.
|
||||||
|
*/
|
||||||
|
case class CredentialEffectivity(
|
||||||
|
startDate: LocalDate,
|
||||||
|
endDate: Option[LocalDate]
|
||||||
|
)
|
||||||
|
|
||||||
|
object CredentialEffectivity:
|
||||||
|
|
||||||
|
given CanEqual[CredentialEffectivity, CredentialEffectivity] =
|
||||||
|
CanEqual.derived
|
||||||
|
|
||||||
|
/** Produce a new credential effective starting today with no termination
|
||||||
|
* date.
|
||||||
|
*
|
||||||
|
* @param clock
|
||||||
|
* The clock to use for date selection.
|
||||||
|
* @return
|
||||||
|
* The new effectivity.
|
||||||
|
*/
|
||||||
|
def todayOnward[F[_]: Sync](clock: Clock): F[CredentialEffectivity] =
|
||||||
|
Sync[F]
|
||||||
|
.delay(LocalDate.now(clock))
|
||||||
|
.map(start => CredentialEffectivity(start, None))
|
||||||
|
|
||||||
|
end CredentialEffectivity
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
import gs.uuid.v0.UUID
|
||||||
|
|
||||||
|
/** Uniquely identifies a _single credential_ within Smolban.
|
||||||
|
*/
|
||||||
|
opaque type CredentialId = UUID
|
||||||
|
|
||||||
|
object CredentialId:
|
||||||
|
|
||||||
|
/** Instantiate a new [[CredentialId]] from the given UUID.
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
* The UUID.
|
||||||
|
* @return
|
||||||
|
* The new [[CredentialId]].
|
||||||
|
*/
|
||||||
|
def apply(value: UUID): CredentialId = value
|
||||||
|
|
||||||
|
/** Generate a new [[CredentialId]] using the UUIDv7 algorithm.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The new [[CredentialId]].
|
||||||
|
*/
|
||||||
|
def generate(): CredentialId = UUID.v7()
|
||||||
|
|
||||||
|
given CanEqual[CredentialId, CredentialId] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[CredentialId] = _.unwrap().withoutDashes()
|
||||||
|
|
||||||
|
given Eq[CredentialId] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
extension (credentialId: CredentialId)
|
||||||
|
/** @return
|
||||||
|
* The underlying UUID value.
|
||||||
|
*/
|
||||||
|
def unwrap(): UUID = credentialId
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The underlying UUID value.
|
||||||
|
*/
|
||||||
|
def toUUID(): UUID = credentialId
|
||||||
|
|
||||||
|
end CredentialId
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Enumeration that describes the status of an [[Credential]].
|
||||||
|
*
|
||||||
|
* Smolban credentials are either active or revoked. They cannot be deleted -
|
||||||
|
* to preserve lineage of all operations.
|
||||||
|
*/
|
||||||
|
sealed abstract class CredentialStatus(val name: String):
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: CredentialStatus => name == other.name
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def toString(): String = name
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def hashCode(): Int = name.hashCode()
|
||||||
|
|
||||||
|
object CredentialStatus:
|
||||||
|
|
||||||
|
given CanEqual[CredentialStatus, CredentialStatus] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[CredentialStatus] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[CredentialStatus] = _.name
|
||||||
|
|
||||||
|
/** The credential is active and can be used freely.
|
||||||
|
*/
|
||||||
|
case object Active extends CredentialStatus("active")
|
||||||
|
|
||||||
|
/** The credential is revoked and cannot be used.
|
||||||
|
*/
|
||||||
|
case object Revoked extends CredentialStatus("revoked")
|
||||||
|
|
||||||
|
/** List of all valid credential types.
|
||||||
|
*/
|
||||||
|
val All: List[CredentialStatus] =
|
||||||
|
List(Active, Revoked)
|
||||||
|
|
||||||
|
/** Parse the given string as an [[CredentialStatus]].
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The string to parse.
|
||||||
|
* @return
|
||||||
|
* Some credential status value, or `None` if the given string is not a
|
||||||
|
* valid credential status.
|
||||||
|
*/
|
||||||
|
def parse(candidate: String): Option[CredentialStatus] =
|
||||||
|
All.find(_.name.equalsIgnoreCase(candidate))
|
||||||
|
|
||||||
|
end CredentialStatus
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Enumeration that defines all supported credential types in Smolban.
|
||||||
|
*
|
||||||
|
* Credentials are used to authenticate different types of accounts and grant
|
||||||
|
* them _access tokens_.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The name of the credential type.
|
||||||
|
*/
|
||||||
|
sealed abstract class CredentialType(val name: String):
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: CredentialType => name == other.name
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def toString(): String = name
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def hashCode(): Int = name.hashCode()
|
||||||
|
|
||||||
|
object CredentialType:
|
||||||
|
|
||||||
|
given CanEqual[CredentialType, CredentialType] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[CredentialType] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[CredentialType] = _.name
|
||||||
|
|
||||||
|
/** Password - typical for human users.
|
||||||
|
*/
|
||||||
|
case object Password extends CredentialType("password")
|
||||||
|
|
||||||
|
/** Client Secret - typical for service accounts and AI agents.
|
||||||
|
*/
|
||||||
|
case object ClientSecret extends CredentialType("client_secret")
|
||||||
|
|
||||||
|
/** List of all valid credential types.
|
||||||
|
*/
|
||||||
|
val All: List[CredentialType] =
|
||||||
|
List(Password, ClientSecret)
|
||||||
|
|
||||||
|
/** Parse the given string as an [[CredentialType]].
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The string to parse.
|
||||||
|
* @return
|
||||||
|
* Some credential type value, or `None` if the given string is not a valid
|
||||||
|
* credential type.
|
||||||
|
*/
|
||||||
|
def parse(candidate: String): Option[CredentialType] =
|
||||||
|
All.find(_.name.equalsIgnoreCase(candidate))
|
||||||
|
|
||||||
|
end CredentialType
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import gs.smolban.model.account.AiAgent
|
||||||
|
|
||||||
|
/** Represents a [[AiAgent]] that was just created. This is the only time this
|
||||||
|
* [[ClientSecret]] will be exposed.
|
||||||
|
*
|
||||||
|
* @param serviceAccount
|
||||||
|
* The new [[AiAgent]].
|
||||||
|
* @param clientSecret
|
||||||
|
* The new Credential for the account.
|
||||||
|
*/
|
||||||
|
case class NewAiAgentAccount(
|
||||||
|
serviceAccount: AiAgent,
|
||||||
|
clientSecret: Base64
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import gs.smolban.model.account.ServiceAccount
|
||||||
|
|
||||||
|
/** Represents a [[ServiceAccount]] that was just created. This is the only time
|
||||||
|
* this [[ClientSecret]] will be exposed.
|
||||||
|
*
|
||||||
|
* @param serviceAccount
|
||||||
|
* The new [[ServiceAccount]].
|
||||||
|
* @param clientSecret
|
||||||
|
* The new Credential for the account.
|
||||||
|
*/
|
||||||
|
case class NewServiceAccount(
|
||||||
|
serviceAccount: ServiceAccount,
|
||||||
|
clientSecret: Base64
|
||||||
|
)
|
||||||
29
modules/auth/src/main/scala/gs/smolban/auth/Password.scala
Normal file
29
modules/auth/src/main/scala/gs/smolban/auth/Password.scala
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
/** Passwords _must_ be passed into Smolban as [[RsaEncryptedBytes]]. Smolban
|
||||||
|
* never assumes clear text inputs.
|
||||||
|
*
|
||||||
|
* This is an opaque type for encrypted bytes.
|
||||||
|
*
|
||||||
|
* This type is explicitly intended for use in authentication flows and
|
||||||
|
* allowing users to define new passwords -- the only cases where a password
|
||||||
|
* should be provided as input.
|
||||||
|
*/
|
||||||
|
opaque type Password = RsaEncryptedBytes
|
||||||
|
|
||||||
|
object Password:
|
||||||
|
|
||||||
|
given CanEqual[Password, Password] = CanEqual.derived
|
||||||
|
|
||||||
|
/** Instantiate a new [[Password]] from the given encrypted bytes.
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
* The encrypted bytes.
|
||||||
|
* @return
|
||||||
|
* The new [[Password]] instance.
|
||||||
|
*/
|
||||||
|
def apply(value: RsaEncryptedBytes): Password = value
|
||||||
|
|
||||||
|
extension (password: Password) def unwrap(): RsaEncryptedBytes = password
|
||||||
|
|
||||||
|
end Password
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.effect.Sync
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
/** Utility which produces random bytes. Used for cryptographic support.
|
||||||
|
*/
|
||||||
|
trait RandomByteProvider[F[_]]:
|
||||||
|
/** Generate the specified number of random bytes.
|
||||||
|
*
|
||||||
|
* @param numberOfBytes
|
||||||
|
* The number of bytes to generate.
|
||||||
|
* @return
|
||||||
|
* New array with the specified number of random bytes.
|
||||||
|
*/
|
||||||
|
def generateBytes(numberOfBytes: Int): F[Array[Byte]]
|
||||||
|
|
||||||
|
object RandomByteProvider:
|
||||||
|
|
||||||
|
/** Default random number generator.
|
||||||
|
*/
|
||||||
|
lazy val RNG: SecureRandom = new SecureRandom
|
||||||
|
|
||||||
|
/** New [[RandomByteProvider]] that uses the default (secure) random number
|
||||||
|
* generator to produce bytes.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The new provider instance.
|
||||||
|
*/
|
||||||
|
def secureRandom[F[_]: Sync]: RandomByteProvider[F] =
|
||||||
|
new SecureRandomProvider[F](RNG)
|
||||||
|
|
||||||
|
/** New [[RandomByteProvider]] that uses the given `SecureRandom` instance to
|
||||||
|
* produce bytes.
|
||||||
|
*
|
||||||
|
* @param random
|
||||||
|
* The `SecureRandom` instance.
|
||||||
|
* @return
|
||||||
|
* The new provider.
|
||||||
|
*/
|
||||||
|
def secureRandom[F[_]: Sync](random: SecureRandom): RandomByteProvider[F] =
|
||||||
|
new SecureRandomProvider[F](random)
|
||||||
|
|
||||||
|
final class SecureRandomProvider[F[_]: Sync](
|
||||||
|
val random: SecureRandom
|
||||||
|
) extends RandomByteProvider[F]:
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def generateBytes(numberOfBytes: Int): F[Array[Byte]] =
|
||||||
|
Sync[F].delay {
|
||||||
|
val b = new Array[Byte](numberOfBytes)
|
||||||
|
random.nextBytes(b)
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
end RandomByteProvider
|
||||||
180
modules/auth/src/main/scala/gs/smolban/auth/Rsa.scala
Normal file
180
modules/auth/src/main/scala/gs/smolban/auth/Rsa.scala
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.effect.Sync
|
||||||
|
import cats.effect.kernel.Resource
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import java.security.spec.X509EncodedKeySpec
|
||||||
|
import scala.io.Source
|
||||||
|
|
||||||
|
/** Support for the RSA asymmetric encryption algorithm.
|
||||||
|
*
|
||||||
|
* See:
|
||||||
|
*
|
||||||
|
* - [[RsaEncryption]]
|
||||||
|
* - [[RsaDecryption]]
|
||||||
|
* - [[RsaEncryptedBytes]]
|
||||||
|
*
|
||||||
|
* Use the following functions to get encryption and decryption tools:
|
||||||
|
*
|
||||||
|
* - `initializeEncryption`
|
||||||
|
* - `initializeEncryptionFromFile`
|
||||||
|
* - `initializeDecryption`
|
||||||
|
* - `initializeDecryptionFromFile`
|
||||||
|
*/
|
||||||
|
object Rsa:
|
||||||
|
|
||||||
|
val CipherName: String = "RSA/ECB/PKCS1Padding"
|
||||||
|
val Algorithm: String = "RSA"
|
||||||
|
|
||||||
|
/** Initialize a new instance of [[RsaEncryption]] for the given public key.
|
||||||
|
*
|
||||||
|
* This function assumes that the provided bytes do not need any further
|
||||||
|
* processing - they are already decoded from any encoded form.
|
||||||
|
*
|
||||||
|
* This function accepts X509 public keys.
|
||||||
|
*
|
||||||
|
* @param publicKeyRawBytes
|
||||||
|
* The raw bytes that constitute the public key.
|
||||||
|
* @return
|
||||||
|
* The new instance of [[RsaEncryption]].
|
||||||
|
*/
|
||||||
|
def initializeEncryption[F[_]: Sync](
|
||||||
|
publicKeyRawBytes: Array[Byte]
|
||||||
|
): F[RsaEncryption[F]] =
|
||||||
|
loadPublicKey(publicKeyRawBytes).map(new RsaEncryption(_))
|
||||||
|
|
||||||
|
/** Initialize a new instance of [[RsaEncryption]] for the given public key by
|
||||||
|
* loading that key from disk.
|
||||||
|
*
|
||||||
|
* @param publicKeyPath
|
||||||
|
* The path to the public key on local disk.
|
||||||
|
* @return
|
||||||
|
* The new instance of [[RsaDecryption]].
|
||||||
|
*/
|
||||||
|
def initializeEncryptionFromFile[F[_]: Sync](
|
||||||
|
publicKeyPath: Path
|
||||||
|
): F[RsaEncryption[F]] =
|
||||||
|
loadPublicKeyFromFile(publicKeyPath).map(new RsaEncryption(_))
|
||||||
|
|
||||||
|
def initializeEncryptionFromResource[F[_]: Sync](
|
||||||
|
resourceName: String
|
||||||
|
): F[RsaEncryption[F]] =
|
||||||
|
loadPublicKeyFromResource(resourceName).map(new RsaEncryption(_))
|
||||||
|
|
||||||
|
/** Initialize a new instance of [[RsaDecryption]] for the given private key.
|
||||||
|
*
|
||||||
|
* This function assumes that the provided bytes do not need any further
|
||||||
|
* processing - they are already decoded from any encoded form.
|
||||||
|
*
|
||||||
|
* This function accepts PKCS8 private keys.
|
||||||
|
*
|
||||||
|
* @param privateKeyRawBytes
|
||||||
|
* The raw bytes that constitute the private key.
|
||||||
|
* @return
|
||||||
|
* The new instance of [[RsaDecryption]].
|
||||||
|
*/
|
||||||
|
def initializeDecryption[F[_]: Sync](
|
||||||
|
privateKeyRawBytes: Array[Byte]
|
||||||
|
): F[RsaDecryption[F]] =
|
||||||
|
loadPrivateKey(privateKeyRawBytes).map(new RsaDecryption(_))
|
||||||
|
|
||||||
|
/** Initialize a new instance of [[RsaDecryption]] for the given private key
|
||||||
|
* by loading that key from disk.
|
||||||
|
*
|
||||||
|
* @param privateKeyPath
|
||||||
|
* The path to the private key on local disk.
|
||||||
|
* @return
|
||||||
|
* The new instance of [[RsaDecryption]].
|
||||||
|
*/
|
||||||
|
def initializeDecryptionFromFile[F[_]: Sync](
|
||||||
|
privateKeyPath: Path
|
||||||
|
): F[RsaDecryption[F]] =
|
||||||
|
loadPrivateKeyFromFile(privateKeyPath).map(new RsaDecryption(_))
|
||||||
|
|
||||||
|
def initializeDecryptionFromResource[F[_]: Sync](
|
||||||
|
privateKeyResourceName: String
|
||||||
|
): F[RsaDecryption[F]] =
|
||||||
|
loadPrivateKeyFromResource(privateKeyResourceName).map(new RsaDecryption(_))
|
||||||
|
|
||||||
|
def loadPublicKey[F[_]: Sync](
|
||||||
|
publicKeyRawBytes: Array[Byte]
|
||||||
|
): F[PublicKey] =
|
||||||
|
Sync[F].delay {
|
||||||
|
val spec = new X509EncodedKeySpec(publicKeyRawBytes)
|
||||||
|
val keyFactory = KeyFactory.getInstance(Rsa.Algorithm)
|
||||||
|
keyFactory.generatePublic(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
def loadPublicKeyFromFile[F[_]: Sync](
|
||||||
|
publicKeyPath: Path
|
||||||
|
): F[PublicKey] =
|
||||||
|
Sync[F]
|
||||||
|
.delay(Files.readString(publicKeyPath, StandardCharsets.UTF_8))
|
||||||
|
.map(preparePublicKey)
|
||||||
|
.flatMap(loadPublicKey[F])
|
||||||
|
|
||||||
|
def loadPublicKeyFromResource[F[_]: Sync](
|
||||||
|
resourceName: String
|
||||||
|
): F[PublicKey] =
|
||||||
|
Resource
|
||||||
|
.make(Sync[F].delay(Source.fromResource(resourceName)))(source =>
|
||||||
|
Sync[F].delay(source.close())
|
||||||
|
)
|
||||||
|
.use { source =>
|
||||||
|
loadPublicKey(preparePublicKey(source.getLines().mkString))
|
||||||
|
}
|
||||||
|
|
||||||
|
def loadPrivateKey[F[_]: Sync](
|
||||||
|
privateKeyRawBytes: Array[Byte]
|
||||||
|
): F[PrivateKey] =
|
||||||
|
Sync[F].delay {
|
||||||
|
val spec = new PKCS8EncodedKeySpec(privateKeyRawBytes)
|
||||||
|
val keyFactory = KeyFactory.getInstance(Rsa.Algorithm)
|
||||||
|
keyFactory.generatePrivate(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
def loadPrivateKeyFromFile[F[_]: Sync](
|
||||||
|
privateKeyPath: Path
|
||||||
|
): F[PrivateKey] =
|
||||||
|
Sync[F]
|
||||||
|
.delay(Files.readString(privateKeyPath, StandardCharsets.UTF_8))
|
||||||
|
.map(preparePrivateKey)
|
||||||
|
.flatMap(loadPrivateKey[F])
|
||||||
|
|
||||||
|
def loadPrivateKeyFromResource[F[_]: Sync](
|
||||||
|
resourceName: String
|
||||||
|
): F[PrivateKey] =
|
||||||
|
Resource
|
||||||
|
.make(Sync[F].delay(Source.fromResource(resourceName)))(source =>
|
||||||
|
Sync[F].delay(source.close())
|
||||||
|
)
|
||||||
|
.use { source =>
|
||||||
|
loadPrivateKey(preparePrivateKey(source.getLines().mkString))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def preparePublicKey(base: String): Array[Byte] =
|
||||||
|
Base64.decodeUnsafe(
|
||||||
|
base
|
||||||
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
.replace("-----END PUBLIC KEY-----", "")
|
||||||
|
.replace("\n", "")
|
||||||
|
.trim()
|
||||||
|
)
|
||||||
|
|
||||||
|
private def preparePrivateKey(base: String): Array[Byte] =
|
||||||
|
Base64.decodeUnsafe(
|
||||||
|
base
|
||||||
|
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
|
.replace("-----END PRIVATE KEY-----", "")
|
||||||
|
.replace("\n", "")
|
||||||
|
.trim()
|
||||||
|
)
|
||||||
|
|
||||||
|
end Rsa
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.effect.Sync
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
|
/** Utility for private key decryption using RSA.
|
||||||
|
*
|
||||||
|
* Intended to be used in conjunction with [[RsaEncryption]].
|
||||||
|
*
|
||||||
|
* @param privateKey
|
||||||
|
* The private key used to decrypt data.
|
||||||
|
*/
|
||||||
|
final class RsaDecryption[F[_]: Sync](privateKey: PrivateKey):
|
||||||
|
|
||||||
|
/** Decrypt the given bytes. These bytes have no guarantees regarding whether
|
||||||
|
* they were produced using RSA.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The input bytes to decrypt.
|
||||||
|
* @return
|
||||||
|
* The decrypted bytes. Throws an exception if decryption fails.
|
||||||
|
*/
|
||||||
|
def decryptUnsafe(input: Array[Byte]): F[Array[Byte]] =
|
||||||
|
Sync[F].delay {
|
||||||
|
val cipher: Cipher = Cipher.getInstance(Rsa.CipherName)
|
||||||
|
val _ = cipher.init(Cipher.DECRYPT_MODE, privateKey)
|
||||||
|
cipher.doFinal(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt the given bytes.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The encrypted bytes.
|
||||||
|
* @return
|
||||||
|
* The decrypted bytes.
|
||||||
|
*/
|
||||||
|
def decrypt(input: RsaEncryptedBytes): F[Array[Byte]] =
|
||||||
|
decryptUnsafe(input.bytes)
|
||||||
|
|
||||||
|
/** Decrypt the given bytes, expressing the result as a string.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The encrypted bytes.
|
||||||
|
* @param charset
|
||||||
|
* The character set used to express the decrypted bytes. Defaults to
|
||||||
|
* UTF-8.
|
||||||
|
* @return
|
||||||
|
* The decrypted string.
|
||||||
|
*/
|
||||||
|
def decryptToString(
|
||||||
|
input: RsaEncryptedBytes,
|
||||||
|
charset: Charset = StandardCharsets.UTF_8
|
||||||
|
): F[String] =
|
||||||
|
decrypt(input).map(bytes => new String(bytes, charset))
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
/** Represents arbitrary bytes that were encrypted using RSA.
|
||||||
|
*
|
||||||
|
* See:
|
||||||
|
*
|
||||||
|
* - [[RsaEncryption]]
|
||||||
|
* - [[RsaDecryption]]
|
||||||
|
*
|
||||||
|
* @param bytes
|
||||||
|
* The bytes encrypted using an RSA public key.
|
||||||
|
*/
|
||||||
|
final class RsaEncryptedBytes(val bytes: Array[Byte]):
|
||||||
|
/** @return
|
||||||
|
* These encrypted bytes, encoded using Base64.
|
||||||
|
*/
|
||||||
|
def encode(): Base64 = Base64.encode(bytes)
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: RsaEncryptedBytes => bytes.sameElements(other.bytes)
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def hashCode(): Int = bytes.hashCode()
|
||||||
|
|
||||||
|
object RsaEncryptedBytes:
|
||||||
|
|
||||||
|
/** Instantiate a new instance of [[RsaEncryptedBytes]] by base64-decoding the
|
||||||
|
* given input.
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
* The value to decode.
|
||||||
|
* @return
|
||||||
|
* The new [[RsaEncryptedBytes]].
|
||||||
|
*/
|
||||||
|
def decode(value: Base64): RsaEncryptedBytes =
|
||||||
|
new RsaEncryptedBytes(value.decode())
|
||||||
|
|
||||||
|
end RsaEncryptedBytes
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.effect.Sync
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.PublicKey
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
|
/** Utility for public key encryption using RSA.
|
||||||
|
*
|
||||||
|
* Intended to be used in conjunction with [[RsaDecryption]].
|
||||||
|
*
|
||||||
|
* @param publicKey
|
||||||
|
* The public key used to encrypt data.
|
||||||
|
*/
|
||||||
|
final class RsaEncryption[F[_]: Sync](publicKey: PublicKey):
|
||||||
|
|
||||||
|
/** Encrypt the given bytes.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The data to encrypt.
|
||||||
|
* @return
|
||||||
|
* The encrypted representation of the input bytes.
|
||||||
|
*/
|
||||||
|
def encrypt(input: Array[Byte]): F[RsaEncryptedBytes] =
|
||||||
|
Sync[F].delay {
|
||||||
|
val cipher: Cipher = Cipher.getInstance(Rsa.CipherName)
|
||||||
|
val _ = cipher.init(Cipher.ENCRYPT_MODE, publicKey)
|
||||||
|
new RsaEncryptedBytes(cipher.doFinal(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt the given string.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The data to encrypt.
|
||||||
|
* @param charset
|
||||||
|
* The character set used to extract bytes from the strong. Defaults to
|
||||||
|
* UTF-8.
|
||||||
|
* @return
|
||||||
|
* The encrypted representation of the input string.
|
||||||
|
*/
|
||||||
|
def encrypt(
|
||||||
|
input: String,
|
||||||
|
charset: Charset = StandardCharsets.UTF_8
|
||||||
|
): F[RsaEncryptedBytes] =
|
||||||
|
encrypt(input.getBytes(charset))
|
||||||
52
modules/auth/src/test/resources/rsa4096-private-key.key
Normal file
52
modules/auth/src/test/resources/rsa4096-private-key.key
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC0lNWR3ynKwzXw
|
||||||
|
+a85Qj0qpPpd3CSV+bJYGsXEJZujI/WrYvRfXFkuqXSGy2JGU5GyhAAzpD+qUOSS
|
||||||
|
ITt6S3hPzIjysqIeOJiL60rWaW6uSAfzn4s5e66kHaaAoKD0Q8sB0V3MwQ4YqnjF
|
||||||
|
eIR/FtUx2NfaguKD5Jn/BlG24n/qyUgteTUBcLEeXb7Mn8c5QI3zjjSvi471CAKj
|
||||||
|
+SxmgegjG01A5JXM+yJj8526pDmcHH23mivbNhw9m7nweFQbbqvV8THSaMS3DsTH
|
||||||
|
s5aOFea+lAxT3WNfhXVPO7/oRuM9WnRp8TUnKmlJcWeUqgEXI9ZvPNTuP1oHPXJ4
|
||||||
|
YbGFP6thIUEW/ugXoFz74uHh5UGhR1QCcYwJucZWqla/zLmSF/sS29rYizlIxKoR
|
||||||
|
66PRcizOl1o9SYeGr0uQbZkWqoJarVaD+vFSd7Gag8KUT56/HD2szsCzYQ1djqWt
|
||||||
|
3f8jAu5SUfnVI4JCXbjGKs0O1MnmfUMXWEU++5wNAx0ysc/Y+nJXok9PPV4jNBFm
|
||||||
|
7lVi2p6gyFWHuces/kfDLCUthp0iDbw9akkANFkbJgXcCuFZyfecHIMWagiEGaLw
|
||||||
|
8x0cDIeqjzq7zkdA7QGvc9F/bEO2TvNwtC5f1y/m46WAB/N6W6JpIZb77AbTx046
|
||||||
|
e5VtrLqF7xLBykjUePDdxybFCJnYDQIDAQABAoICACmgFDHVjfHf+SdkuqwZdXWG
|
||||||
|
xXKMy/8pKV/PPg6Wd6+NmrPIsdlodWNA6uwmZi8dVNygOlatEgLdti5sDCSG0IMe
|
||||||
|
e+Pr4txSAfHgySWu9HUmg/S3rlVQCgPpFMgaHrfnh5xR6UwJJUlwxDmKrAoKlpaw
|
||||||
|
rCMBoBq0f33udDgSsldJ0gIvahU8p4s/IzvSSc9L7ty5RzI+2nNnhwpKpd40LDEp
|
||||||
|
ei+O8WvoaLc/APj0oZX3aFBB8MGNUcmuw5fneMXBB0mf2TLt9QhYVmpNHpN+f2un
|
||||||
|
P0c2pVEvt4iN1pEBhCCQoPyJvg2a5F2qTyzQ2kL9/xAxpsiLYGKCWshehpfXQxbS
|
||||||
|
0Jano6y4THOfYTeirK59dTLRtaeYRv/uLoyqos7WkBhZTW46yVF5LP6p1xxnu+N1
|
||||||
|
aDglfxpqBJ8zIeCv6tWgCY9mrD2pmpFjawvvFYsLVQyDPvSnxIrZu+ZCMFxeuZ10
|
||||||
|
T5eB3UXRonWGqqlrJOtwdUeW9zyidPedcRxKTTyqpb5svmw+NZn8P+ehMrxqRZcu
|
||||||
|
mLmvbhXsQcVkOaEdAmjWAYk3efYw83uje+ULGGfJ3dHsQxvb9yoCAImZR12NWBrv
|
||||||
|
bCk4F0Q5AteIKE5c3eLmTjreV6R4KPVgGAUYBkt4DGhs9DZODFZ/31vtwHfX/S6F
|
||||||
|
tpJVNpY2rf52iZfJ8XMRAoIBAQD2iz33v6/ewhP1Ni+HmNK28Hc4I1Cr0K8H4zwd
|
||||||
|
kAy4YucR08djDi4Hha7Ab0iM+vgM6gwqaZewwHI+ky/IFQe3FA7Im8wEIYZnpgIF
|
||||||
|
YxbcBZKiQONkgGZkQNpR7XVDp+Z/vsCShgV6qldsYUnpsS05PITvdqvc58rOJKWt
|
||||||
|
DGoQqJ/526JbVsa4+z11S+XufHu06IFY9E3QgPErgJhCgAbs5GRxEDEMhlZtdn6A
|
||||||
|
4xkAUEGYnwYaYkKVdGOKd3sN25Y2NVhpBOg/lz/EorT9tRsQnmsM97uFZmAQmw7m
|
||||||
|
BzIP/9xFwuGbrrUKqXyRnHH7bAvISMnm1gDyUUahIm+/p1R9AoIBAQC7gevZ5upV
|
||||||
|
qx/rytoRbj0N42Jvxtg6XGUy3Jl6W9JT49qLATLrE7XSJKGvg/TxGlBLEupjpZae
|
||||||
|
Vwn4n/dELhj6mmaEcrWI0m1BPMATdrdhrouQPXhjdfR4gbpeNxg42tpclg7x+tdG
|
||||||
|
kUQm6VaPdHoM7RJOQWDb3fucc9Vzqp7Z9BB9UGGRcKxsEv/Wu/Db2R3VHvmbUrlW
|
||||||
|
u2RU9Sw+pNrlSdKx3hKoT0c0yoPiSHY9Tfd1tfFkSyiXEDADfgZJUA2pe8vn+Q/i
|
||||||
|
llJVb1aFpnuvyKnQD88U/Xyn5ds+aUSq22RbD76L5hSorMtrCAaCWCYgUF77x7xc
|
||||||
|
PfQHhG1bMbbRAoIBAAehD23XNK4D+3IfFyFvDTY0Arxt+1UVxBTOZ1HS31HlXZkj
|
||||||
|
oIvkKHB7Jok16FzUd1CO/Ylicxs5GU/uZhAe9non2L1EdO+7ydjzPiTEiDSOx5bV
|
||||||
|
wzOc9Y4so5TdcD+DtpJFaNgf5ZOCKepkqFDe9rNKuCJg3bicQ55Va/sK401Yqnqk
|
||||||
|
3UVOTh/zRleW3aqfl4RlnXsPNEk7dDsQY6XLKGu0NZd6FMp6bbo9bHS4klF8Kkt6
|
||||||
|
wEmYuM6/J0VlpR0sql1LEU1OpZEyMPr4vfkL3aaKAG4KTHc4T8izw6ZCmr38AOj3
|
||||||
|
utuCcH+/9ubanHxXP5YXCohmHulgsnrSAftARlECggEAIqC4tLIfXpjOuVXp9cQd
|
||||||
|
BF6UxD29mvGLQtxYf69LZXCz4G3lQGKQdnGLZoWBC7GnWGXy4VooOa+rSL4KBQ5a
|
||||||
|
UJWJDza77bumr6CPfEi1TxXT8lxXyk5zSnnyuAmGsKFCKE0SD4Aal46mPmVjNfT1
|
||||||
|
wUNa2Rbb017oY5lEtyqwUWHwVaQtkJV1UjQkCT0GGyO6jaw9voCFd839lm78r8j0
|
||||||
|
H9oFThHL8kdJyCcKOhTVuTaX16Y1ISd8JIG5zDtO3+Un0L/rBTkKxPar19lK6j23
|
||||||
|
o9vz+FejD6ZMihk55wm7w63ml6aNsvpXoFrg6jA+O34Z9GfDUs4tK//I/EZph6jj
|
||||||
|
sQKCAQEArpOdpfVIWk9yLCvj1DRm6E4/KxppJf0jduoDPCLqGKtIPFNQVFvpqhnT
|
||||||
|
Q8Safkb3fdtgFMtxemF5iESZrlZapLzeOiWcWDkha29/RmlOS9BlXNdYTe4BIW5L
|
||||||
|
p3+c57CNh+iCe3Y+ZiUbRECqbxfDGcqClydJBjic8RExR2914R9JFC91nfDGfOJC
|
||||||
|
9da9bNZIFM+VC0bARC6Zg6Fdg+DIdrj3U+Zf7qG43ucU876dRHSvE1WiswcPK7c9
|
||||||
|
pW+C9vUoSm2k1rFBzgMl2S5hLEa6wBVGg9um/VRBENLg1SepEiXVa0zoeViwRkZr
|
||||||
|
tXWSfxJaqr5e0HQj1LbBa+HOO4AvOQ==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
14
modules/auth/src/test/resources/rsa4096-public-key.crt
Normal file
14
modules/auth/src/test/resources/rsa4096-public-key.crt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtJTVkd8pysM18PmvOUI9
|
||||||
|
KqT6XdwklfmyWBrFxCWboyP1q2L0X1xZLql0hstiRlORsoQAM6Q/qlDkkiE7ekt4
|
||||||
|
T8yI8rKiHjiYi+tK1mlurkgH85+LOXuupB2mgKCg9EPLAdFdzMEOGKp4xXiEfxbV
|
||||||
|
MdjX2oLig+SZ/wZRtuJ/6slILXk1AXCxHl2+zJ/HOUCN8440r4uO9QgCo/ksZoHo
|
||||||
|
IxtNQOSVzPsiY/OduqQ5nBx9t5or2zYcPZu58HhUG26r1fEx0mjEtw7Ex7OWjhXm
|
||||||
|
vpQMU91jX4V1Tzu/6EbjPVp0afE1JyppSXFnlKoBFyPWbzzU7j9aBz1yeGGxhT+r
|
||||||
|
YSFBFv7oF6Bc++Lh4eVBoUdUAnGMCbnGVqpWv8y5khf7Etva2Is5SMSqEeuj0XIs
|
||||||
|
zpdaPUmHhq9LkG2ZFqqCWq1Wg/rxUnexmoPClE+evxw9rM7As2ENXY6lrd3/IwLu
|
||||||
|
UlH51SOCQl24xirNDtTJ5n1DF1hFPvucDQMdMrHP2PpyV6JPTz1eIzQRZu5VYtqe
|
||||||
|
oMhVh7nHrP5HwywlLYadIg28PWpJADRZGyYF3ArhWcn3nByDFmoIhBmi8PMdHAyH
|
||||||
|
qo86u85HQO0Br3PRf2xDtk7zcLQuX9cv5uOlgAfzeluiaSGW++wG08dOOnuVbay6
|
||||||
|
he8SwcpI1Hjw3ccmxQiZ2A0CAwEAAQ==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.effect.IO
|
||||||
|
import cats.effect.unsafe.IORuntime
|
||||||
|
import munit.Location
|
||||||
|
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||||
|
|
||||||
|
class Argon2Tests extends munit.FunSuite:
|
||||||
|
given IORuntime = IORuntime.global
|
||||||
|
|
||||||
|
def iotest(
|
||||||
|
name: String
|
||||||
|
)(
|
||||||
|
f: => IO[Unit]
|
||||||
|
)(
|
||||||
|
using
|
||||||
|
Location
|
||||||
|
): Unit =
|
||||||
|
test(name)(f.unsafeRunSync())
|
||||||
|
|
||||||
|
val rng: RandomByteProvider[IO] = RandomByteProvider.secureRandom[IO]
|
||||||
|
|
||||||
|
val altConfig: Argon2.Config = Argon2.Config(
|
||||||
|
algorithmVersion = Argon2Parameters.ARGON2_VERSION_10,
|
||||||
|
algorithmType = Argon2Parameters.ARGON2_i,
|
||||||
|
iterations = 2,
|
||||||
|
parallelism = 1,
|
||||||
|
memoryInKb = 1024,
|
||||||
|
saltLengthInBytes = 8,
|
||||||
|
hashLengthInBytes = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
iotest("should calculate a hash and verify against that hash") {
|
||||||
|
val input = "some Complex password!1"
|
||||||
|
for
|
||||||
|
secret <- Argon2Secret.generate(32, rng)
|
||||||
|
argon2 <- IO(new Argon2[IO](Argon2.defaultConfig(), secret, rng))
|
||||||
|
hash <- argon2.calculateHash(input)
|
||||||
|
matched <- argon2.doesInputMatch(input, hash)
|
||||||
|
encoded <- IO(hash.encode())
|
||||||
|
decoded <- IO(Argon2Hash.decode(encoded))
|
||||||
|
yield
|
||||||
|
assertEquals(matched, true)
|
||||||
|
assertEquals(hash.algorithmType, Argon2.Defaults.AlgorithmType)
|
||||||
|
assertEquals(hash.algorithmVersion, Argon2.Defaults.AlgorithmVersion)
|
||||||
|
assertEquals(hash.iterations, Argon2.Defaults.Iterations)
|
||||||
|
assertEquals(hash.parallelism, Argon2.Defaults.Parallelism)
|
||||||
|
assertEquals(hash.memoryInKb, Argon2.Defaults.MemoryInKB)
|
||||||
|
assertEquals(Some(hash), decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
iotest("should match using stored params, not configured params") {
|
||||||
|
val input = "Another super s3cr3t pass@"
|
||||||
|
for
|
||||||
|
secret <- Argon2Secret.generate(32, rng)
|
||||||
|
altArgon2 <- IO(new Argon2[IO](altConfig, secret, rng))
|
||||||
|
defArgon2 <- IO(new Argon2[IO](Argon2.defaultConfig(), secret, rng))
|
||||||
|
altHash <- altArgon2.calculateHash(input)
|
||||||
|
// We're using the default configuration to run the match, and it should
|
||||||
|
// still match because we have the same secret and the params are
|
||||||
|
// extracted from the hash rather than the configuration.
|
||||||
|
matched <- defArgon2.doesInputMatch(input, altHash)
|
||||||
|
yield
|
||||||
|
assertEquals(matched, true)
|
||||||
|
assertEquals(altHash.algorithmType, altConfig.algorithmType)
|
||||||
|
assertEquals(altHash.algorithmVersion, altConfig.algorithmVersion)
|
||||||
|
assertEquals(altHash.iterations, altConfig.iterations)
|
||||||
|
assertEquals(altHash.parallelism, altConfig.parallelism)
|
||||||
|
assertEquals(altHash.memoryInKb, altConfig.memoryInKb)
|
||||||
|
}
|
||||||
63
modules/auth/src/test/scala/gs/smolban/auth/RsaTests.scala
Normal file
63
modules/auth/src/test/scala/gs/smolban/auth/RsaTests.scala
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package gs.smolban.auth
|
||||||
|
|
||||||
|
import cats.effect.IO
|
||||||
|
import cats.effect.unsafe.IORuntime
|
||||||
|
import java.nio.file.Path
|
||||||
|
import munit.Location
|
||||||
|
|
||||||
|
class RsaTests extends munit.FunSuite:
|
||||||
|
import RsaTests.Resources
|
||||||
|
|
||||||
|
given IORuntime = IORuntime.global
|
||||||
|
|
||||||
|
def iotest(
|
||||||
|
name: String
|
||||||
|
)(
|
||||||
|
f: => IO[Unit]
|
||||||
|
)(
|
||||||
|
using
|
||||||
|
Location
|
||||||
|
): Unit =
|
||||||
|
test(name)(f.unsafeRunSync())
|
||||||
|
|
||||||
|
iotest(
|
||||||
|
"should encrypt and decrypt data, using keys sourced from a resource"
|
||||||
|
) {
|
||||||
|
val data = gs.uuid.v0.UUID.v7().withoutDashes()
|
||||||
|
for
|
||||||
|
encryption <- Rsa.initializeEncryptionFromResource[IO](
|
||||||
|
Resources.PublicKey
|
||||||
|
)
|
||||||
|
decryption <- Rsa.initializeDecryptionFromResource[IO](
|
||||||
|
Resources.PrivateKey
|
||||||
|
)
|
||||||
|
encrypted <- encryption.encrypt(data)
|
||||||
|
decrypted <- decryption.decryptToString(encrypted)
|
||||||
|
yield assertEquals(decrypted, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
iotest("should encrypt and decrypt data, using keys sourced from file") {
|
||||||
|
val data = gs.uuid.v0.UUID.v7().withoutDashes()
|
||||||
|
for
|
||||||
|
encryption <- Rsa.initializeEncryptionFromFile[IO](
|
||||||
|
Path.of(Resources.PublicKeyFile)
|
||||||
|
)
|
||||||
|
decryption <- Rsa.initializeDecryptionFromFile[IO](
|
||||||
|
Path.of(Resources.PrivateKeyFile)
|
||||||
|
)
|
||||||
|
encrypted <- encryption.encrypt(data)
|
||||||
|
decrypted <- decryption.decryptToString(encrypted)
|
||||||
|
yield assertEquals(decrypted, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
object RsaTests:
|
||||||
|
|
||||||
|
object Resources:
|
||||||
|
val BasePath: String = "modules/auth/src/test/resources"
|
||||||
|
val PublicKey: String = "rsa4096-public-key.crt"
|
||||||
|
val PublicKeyFile: String = s"$BasePath/$PublicKey"
|
||||||
|
val PrivateKey: String = "rsa4096-private-key.key"
|
||||||
|
val PrivateKeyFile: String = s"$BasePath/$PrivateKey"
|
||||||
|
end Resources
|
||||||
|
|
||||||
|
end RsaTests
|
||||||
205
modules/db/src/main/scala/gs/smolban/db/AuthDb.scala
Normal file
205
modules/db/src/main/scala/gs/smolban/db/AuthDb.scala
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
package gs.smolban.db
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import gs.smolban.auth.Base64
|
||||||
|
import gs.smolban.auth.Credential
|
||||||
|
import gs.smolban.auth.CredentialId
|
||||||
|
import gs.smolban.auth.NewAiAgentAccount
|
||||||
|
import gs.smolban.auth.NewServiceAccount
|
||||||
|
import gs.smolban.auth.Password
|
||||||
|
import gs.smolban.model.account.Account
|
||||||
|
import gs.smolban.model.account.AccountId
|
||||||
|
import gs.smolban.model.account.AccountName
|
||||||
|
import gs.smolban.model.account.AiAgent
|
||||||
|
import gs.smolban.model.account.PermissionSet
|
||||||
|
import gs.smolban.model.account.ServiceAccount
|
||||||
|
import gs.smolban.model.account.User
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
|
||||||
|
/** Database interface for [[Account]] and related management activities.
|
||||||
|
*/
|
||||||
|
trait AuthDb[DBIO[_], F[_]]:
|
||||||
|
|
||||||
|
/** Get the user account with the given name.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The [[AccountName]] for the user.
|
||||||
|
* @return
|
||||||
|
* The [[User]], or nothing if the user does not exist.
|
||||||
|
*/
|
||||||
|
def getUser(
|
||||||
|
name: AccountName
|
||||||
|
): DBIO[Option[User]]
|
||||||
|
|
||||||
|
/** Create a new [[User]].
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The user's unique [[AccountName]].
|
||||||
|
* @param initialPassword
|
||||||
|
* The initial [[Password]] for the [[User]].
|
||||||
|
* @param initialPermissions
|
||||||
|
* The initial permissions for the account.
|
||||||
|
* @return
|
||||||
|
* The new [[User]], or an error if creation fails.
|
||||||
|
*/
|
||||||
|
def createUser(
|
||||||
|
name: AccountName,
|
||||||
|
initialPassword: Password,
|
||||||
|
initialPermissions: PermissionSet,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): F[DBIO[Either[DbError, User]]]
|
||||||
|
|
||||||
|
/** Update the password for an existing [[User]].
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The user's unique identifier.
|
||||||
|
* @param newPassword
|
||||||
|
* The new [[Password]] for the [[User]].
|
||||||
|
* @return
|
||||||
|
* The updated [[User]], or an error if the update fails.
|
||||||
|
*/
|
||||||
|
def setUserPassword(
|
||||||
|
id: AccountId,
|
||||||
|
newPassword: Password
|
||||||
|
): EitherT[DBIO, DbError, User]
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* List of all active [[User]].
|
||||||
|
*/
|
||||||
|
def listActiveUsers(): fs2.Stream[DBIO, User]
|
||||||
|
|
||||||
|
/** Get the [[ServiceAccount]] with the given name.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The [[AccountName]] for the service.
|
||||||
|
* @return
|
||||||
|
* The [[ServiceAccount]], or nothing if the service account does not
|
||||||
|
* exist.
|
||||||
|
*/
|
||||||
|
def getServiceAccount(
|
||||||
|
name: AccountName
|
||||||
|
): DBIO[Option[ServiceAccount]]
|
||||||
|
|
||||||
|
/** Create a new [[ServiceAccount]].
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The service's unique [[AccountName]].
|
||||||
|
* @param owner
|
||||||
|
* The account which owns this [[ServiceAccount]].
|
||||||
|
* @param initialPermissions
|
||||||
|
* The initial permissions for the account.
|
||||||
|
* @return
|
||||||
|
* The new [[ServiceAccount]], or an error if creation fails.
|
||||||
|
*/
|
||||||
|
def createServiceAccount(
|
||||||
|
name: AccountName,
|
||||||
|
owner: AccountId,
|
||||||
|
initialPermissions: PermissionSet
|
||||||
|
): EitherT[DBIO, DbError, NewServiceAccount]
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* List of all active [[ServiceAccount]].
|
||||||
|
*/
|
||||||
|
def listActiveServiceAccounts(): DBIO[List[ServiceAccount]]
|
||||||
|
|
||||||
|
/** Get the [[AiAgent]] with the given name.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The [[AccountName]] for the agent.
|
||||||
|
* @return
|
||||||
|
* The [[AiAgent]], or nothing if the agent account does not exist.
|
||||||
|
*/
|
||||||
|
def getAgentAccount(
|
||||||
|
name: AccountName
|
||||||
|
): DBIO[Option[AiAgent]]
|
||||||
|
|
||||||
|
/** Create a new [[AiAgent]].
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The agent's unique [[AccountName]].
|
||||||
|
* @param owner
|
||||||
|
* The account which owns this [[AiAgent]].
|
||||||
|
* @param initialPermissions
|
||||||
|
* The initial permissions for the account.
|
||||||
|
* @return
|
||||||
|
* The new [[AiAgent]], or an error if creation fails.
|
||||||
|
*/
|
||||||
|
def createAgentAccount(
|
||||||
|
name: AccountName,
|
||||||
|
owner: AccountId,
|
||||||
|
initialPermissions: PermissionSet
|
||||||
|
): EitherT[DBIO, DbError, NewAiAgentAccount]
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* List of all active [[AiAgent]].
|
||||||
|
*/
|
||||||
|
def listActiveAgentAccounts(): DBIO[List[AiAgent]]
|
||||||
|
|
||||||
|
/** Rotate the specified secret and return the new secret. This is the only
|
||||||
|
* time the new value will ever be exposed.
|
||||||
|
*
|
||||||
|
* This function will fail if the target credential is not owned by the
|
||||||
|
* target account, or if the credential is not a client secret.
|
||||||
|
*
|
||||||
|
* @param accountId
|
||||||
|
* The unique identifier of the [[Account]] which owns this secret.
|
||||||
|
* @param credentialId
|
||||||
|
* The unique identifier of the secret to rotate.
|
||||||
|
* @param overlapHours
|
||||||
|
* The number of hours to allow the old credential to continue working. 0
|
||||||
|
* indicates it will expire immediately.
|
||||||
|
* @return
|
||||||
|
* The new secret value.
|
||||||
|
*/
|
||||||
|
def rotateClientSecret(
|
||||||
|
accountId: AccountId,
|
||||||
|
credentialId: CredentialId,
|
||||||
|
overlapHours: Int
|
||||||
|
): EitherT[DBIO, DbError, Base64]
|
||||||
|
|
||||||
|
/** Replace the permissions assigned to some [[Account]].
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The [[AccountId]] that uniquely identifies the [[Account]].
|
||||||
|
* @param newPermissions
|
||||||
|
* The new permissions for the account.
|
||||||
|
* @return
|
||||||
|
* The updated [[Account]].
|
||||||
|
*/
|
||||||
|
def setAccountPermissions(
|
||||||
|
id: AccountId,
|
||||||
|
newPermissions: PermissionSet
|
||||||
|
): EitherT[DBIO, DbError, Account]
|
||||||
|
|
||||||
|
/** List all credentials that exist for the given [[AccountId]].
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The [[AccountId]] that uniquely identifies some [[Account]].
|
||||||
|
* @return
|
||||||
|
* The list of credentials associated with the [[Account]].
|
||||||
|
*/
|
||||||
|
def listAccountCredentials(
|
||||||
|
id: AccountId
|
||||||
|
): DBIO[List[Credential]]
|
||||||
|
|
||||||
|
/** Revoke a credential, making it unusable.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique identifier of the credential to revoke.
|
||||||
|
* @return
|
||||||
|
* Nothing.
|
||||||
|
*/
|
||||||
|
def revokeCredential(
|
||||||
|
id: CredentialId
|
||||||
|
): EitherT[DBIO, DbError, Unit]
|
||||||
|
|
||||||
|
/** Expire a credential, making it unusable.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique identifier of the credential to expire.
|
||||||
|
* @return
|
||||||
|
* Nothing.
|
||||||
|
*/
|
||||||
|
def expireCredential(
|
||||||
|
id: CredentialId
|
||||||
|
): EitherT[DBIO, DbError, Unit]
|
||||||
80
modules/db/src/main/scala/gs/smolban/db/DbError.scala
Normal file
80
modules/db/src/main/scala/gs/smolban/db/DbError.scala
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package gs.smolban.db
|
||||||
|
|
||||||
|
import _root_.doobie.enumerated.SqlState
|
||||||
|
import gs.smolban.auth.CredentialId
|
||||||
|
import gs.smolban.model.SmolbanError
|
||||||
|
import gs.smolban.model.account.AccountId
|
||||||
|
import gs.smolban.model.account.AccountName
|
||||||
|
import gs.smolban.model.account.AccountType
|
||||||
|
import gs.smolban.model.metadata.TagValue
|
||||||
|
|
||||||
|
/** Parent type of all database errors in Smolban.
|
||||||
|
*/
|
||||||
|
sealed trait DbError extends SmolbanError
|
||||||
|
|
||||||
|
object DbError:
|
||||||
|
|
||||||
|
case class GenericDatabaseError(sqlState: SqlState) extends DbError
|
||||||
|
|
||||||
|
case class TagAlreadyExists(value: TagValue) extends DbError
|
||||||
|
|
||||||
|
/** Produced when creating any new account fails because an account with the
|
||||||
|
* same name already exists.
|
||||||
|
*
|
||||||
|
* Note: this error exists for internal logging, but the user-facing error
|
||||||
|
* should be properly sanitized.
|
||||||
|
*
|
||||||
|
* @param candidateAccountName
|
||||||
|
* The account name.
|
||||||
|
* @param accountType
|
||||||
|
* The type of account.
|
||||||
|
*/
|
||||||
|
case class AccountAlreadyExists(
|
||||||
|
candidateAccountName: AccountName,
|
||||||
|
accountType: AccountType
|
||||||
|
) extends DbError
|
||||||
|
|
||||||
|
/** Produced when performing account operations on an account that does not
|
||||||
|
* exist within the Smolban database.
|
||||||
|
*
|
||||||
|
* @param accountId
|
||||||
|
* The unique identifier for the account.
|
||||||
|
*/
|
||||||
|
case class AccountNotFound(
|
||||||
|
accountId: AccountId
|
||||||
|
) extends DbError
|
||||||
|
|
||||||
|
/** Produced when attempting to update the credential (e.g. a password) for
|
||||||
|
* some account. The database transaction is the only place where the new
|
||||||
|
* candidate value lives unencrypted, and therefore is the only place where
|
||||||
|
* credential requirements can be checked.
|
||||||
|
*
|
||||||
|
* This error is produced if some candidate credential does not meet Smolban
|
||||||
|
* requirements.
|
||||||
|
*
|
||||||
|
* @param accountId
|
||||||
|
* The unique identifier for the account.
|
||||||
|
*/
|
||||||
|
case class CredentialDoesNotMeetRequirements(accountId: AccountId)
|
||||||
|
extends DbError
|
||||||
|
|
||||||
|
case class CredentialAlreadyRevoked(credentialId: CredentialId)
|
||||||
|
extends DbError
|
||||||
|
|
||||||
|
case class CredentialAlreadyExpired(credentialId: CredentialId)
|
||||||
|
extends DbError
|
||||||
|
|
||||||
|
case class CredentialNotFound(credentialId: CredentialId) extends DbError
|
||||||
|
|
||||||
|
case class NotAUser(accountId: AccountId) extends DbError
|
||||||
|
|
||||||
|
case class NotAServiceAccount(accountId: AccountId) extends DbError
|
||||||
|
|
||||||
|
case class NotAnAiAgent(accountId: AccountId) extends DbError
|
||||||
|
|
||||||
|
case class InvalidAccountOwner(
|
||||||
|
accountId: AccountId,
|
||||||
|
credentialId: CredentialId
|
||||||
|
) extends DbError
|
||||||
|
|
||||||
|
end DbError
|
||||||
40
modules/db/src/main/scala/gs/smolban/db/TagDb.scala
Normal file
40
modules/db/src/main/scala/gs/smolban/db/TagDb.scala
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package gs.smolban.db
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
import gs.smolban.model.metadata.Tag
|
||||||
|
import gs.smolban.model.metadata.TagValue
|
||||||
|
|
||||||
|
trait TagDb[F[_]]:
|
||||||
|
|
||||||
|
/** Create a new [[Tag]].
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
* The value of the tag to create (some arbitrary string).
|
||||||
|
* @param createdAt
|
||||||
|
* The instant at which this tag was created.
|
||||||
|
* @return
|
||||||
|
* The new [[Tag]], or an error if the value already exists.
|
||||||
|
*/
|
||||||
|
def createTag(
|
||||||
|
tag: TagValue,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): EitherT[F, DbError, Tag]
|
||||||
|
|
||||||
|
/** Get the value of some [[Tag]] by value.
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
* The value of the [[Tag]].
|
||||||
|
* @return
|
||||||
|
* The found [[Tag]], or `None` if the value does not exist.
|
||||||
|
*/
|
||||||
|
def readTag(tag: TagValue): F[Option[Tag]]
|
||||||
|
|
||||||
|
/** Delete a [[Tag]] by value.
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
* The unique value of the [[Tag]] to delete.
|
||||||
|
* @return
|
||||||
|
* `true` if the [[Tag]] was deleted, `false` otherwise.
|
||||||
|
*/
|
||||||
|
def deleteTag(tag: TagValue): F[Boolean]
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import doobie.enumerated.SqlState
|
||||||
|
|
||||||
|
/** Used to support semantic SQL states (for error handling) across database
|
||||||
|
* implementations. Smolban supports both SQLite and PostgreSQL.
|
||||||
|
*/
|
||||||
|
sealed trait CuratedSqlStates:
|
||||||
|
def uniqueViolation: SqlState
|
||||||
|
|
||||||
|
object CuratedSqlStates:
|
||||||
|
|
||||||
|
lazy val postgres: CuratedSqlStates = new PostgreSQL
|
||||||
|
lazy val sqlite: CuratedSqlStates = new Sqlite
|
||||||
|
|
||||||
|
/** Should be injected when Smolban is run using PostgreSQL.
|
||||||
|
*/
|
||||||
|
final class PostgreSQL extends CuratedSqlStates:
|
||||||
|
|
||||||
|
override val uniqueViolation: SqlState =
|
||||||
|
doobie.postgres.sqlstate.class23.UNIQUE_VIOLATION
|
||||||
|
|
||||||
|
/** Should be injected when Smolban is run using SQLite.
|
||||||
|
*/
|
||||||
|
final class Sqlite extends CuratedSqlStates:
|
||||||
|
|
||||||
|
override val uniqueViolation: SqlState =
|
||||||
|
SqlState("2067")
|
||||||
|
|
||||||
|
end CuratedSqlStates
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import cats.effect.Sync
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import doobie.*
|
||||||
|
import doobie.implicits.*
|
||||||
|
import gs.smolban.auth.Argon2
|
||||||
|
import gs.smolban.auth.Argon2Hash
|
||||||
|
import gs.smolban.auth.Base64
|
||||||
|
import gs.smolban.auth.Credential
|
||||||
|
import gs.smolban.auth.CredentialEffectivity
|
||||||
|
import gs.smolban.auth.CredentialId
|
||||||
|
import gs.smolban.auth.CredentialStatus
|
||||||
|
import gs.smolban.auth.CredentialType
|
||||||
|
import gs.smolban.auth.NewAiAgentAccount
|
||||||
|
import gs.smolban.auth.NewServiceAccount
|
||||||
|
import gs.smolban.auth.Password
|
||||||
|
import gs.smolban.auth.RsaDecryption
|
||||||
|
import gs.smolban.auth.RsaEncryptedBytes
|
||||||
|
import gs.smolban.db.AuthDb
|
||||||
|
import gs.smolban.db.DbError
|
||||||
|
import gs.smolban.db.doobie.DoobieTypes.*
|
||||||
|
import gs.smolban.model.account.Account
|
||||||
|
import gs.smolban.model.account.AccountId
|
||||||
|
import gs.smolban.model.account.AccountName
|
||||||
|
import gs.smolban.model.account.AccountStatus
|
||||||
|
import gs.smolban.model.account.AccountType
|
||||||
|
import gs.smolban.model.account.AiAgent
|
||||||
|
import gs.smolban.model.account.Permission
|
||||||
|
import gs.smolban.model.account.PermissionSet
|
||||||
|
import gs.smolban.model.account.ServiceAccount
|
||||||
|
import gs.smolban.model.account.User
|
||||||
|
import gs.smolban.model.group.GroupName
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
|
||||||
|
final class DoobieAuthDb[F[_]: Sync](
|
||||||
|
val argon2: Argon2[F],
|
||||||
|
val rsaDecryption: RsaDecryption[F],
|
||||||
|
val sqlStates: CuratedSqlStates,
|
||||||
|
val implTypes: ImplTypes
|
||||||
|
) extends AuthDb[ConnectionIO, F]:
|
||||||
|
private val Sql = new DoobieAuthDb.Sql(implTypes)
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def getUser(name: AccountName): ConnectionIO[Option[User]] =
|
||||||
|
Sql.getUser(name).option.flatMap {
|
||||||
|
case Some((id, status, createdAt)) =>
|
||||||
|
getUserPermissions(id).map { permissions =>
|
||||||
|
Some(
|
||||||
|
new User(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
status = status,
|
||||||
|
permissions = permissions,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case None => None.pure[ConnectionIO]
|
||||||
|
}
|
||||||
|
|
||||||
|
private def getUserPermissions(id: AccountId): ConnectionIO[PermissionSet] =
|
||||||
|
for
|
||||||
|
global <- Sql.getUserGlobalPermissions(id).to[Set]
|
||||||
|
group <- Sql.getUserGroupPermissions(id).to[Set]
|
||||||
|
yield PermissionSet(
|
||||||
|
global = global,
|
||||||
|
group = group.groupMap(_._1)(_._2)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def createUser(
|
||||||
|
name: AccountName,
|
||||||
|
initialPassword: Password,
|
||||||
|
initialPermissions: PermissionSet,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): F[ConnectionIO[Either[DbError, User]]] =
|
||||||
|
// Prepare Password -- exchange the encrypted version for a hashed version.
|
||||||
|
exchangeEncryptedForHash(initialPassword.unwrap()).map { passwordHash =>
|
||||||
|
// Insert the base user record.
|
||||||
|
val accountId = AccountId.generate()
|
||||||
|
Sql
|
||||||
|
.insertUser(accountId, name, AccountStatus.Active, createdAt)
|
||||||
|
.run
|
||||||
|
.attemptSqlState
|
||||||
|
.flatMap {
|
||||||
|
case Left(sqlState) =>
|
||||||
|
if sqlState.value == sqlStates.uniqueViolation.value then
|
||||||
|
Left(DbError.AccountAlreadyExists(name, AccountType.User))
|
||||||
|
.pure[ConnectionIO]
|
||||||
|
else Left(DbError.GenericDatabaseError(sqlState)).pure[ConnectionIO]
|
||||||
|
case Right(_) =>
|
||||||
|
for
|
||||||
|
// Insert the user's credential.
|
||||||
|
_ <- Sql
|
||||||
|
.insertCredential(
|
||||||
|
id = CredentialId.generate(),
|
||||||
|
hash = passwordHash,
|
||||||
|
accountId = accountId,
|
||||||
|
accountType = AccountType.User,
|
||||||
|
credentialType = CredentialType.Password,
|
||||||
|
status = CredentialStatus.Active,
|
||||||
|
effective = None,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
.run
|
||||||
|
// Insert the user's global permissions.
|
||||||
|
_ <- Sql.insertUserGlobalPermissions(
|
||||||
|
accountId,
|
||||||
|
initialPermissions.global
|
||||||
|
)
|
||||||
|
// Insert the user's group permissions.
|
||||||
|
_ <- Sql.insertUserGroupPermissions(
|
||||||
|
accountId,
|
||||||
|
initialPermissions.group
|
||||||
|
)
|
||||||
|
yield Right(
|
||||||
|
new User(
|
||||||
|
id = accountId,
|
||||||
|
name = name,
|
||||||
|
status = AccountStatus.Active,
|
||||||
|
permissions = initialPermissions,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exchange an encrypted set of bytes for a hashed set of bytes. This is used
|
||||||
|
* for credential updates. Performs a decrypt followed by a hash.
|
||||||
|
*
|
||||||
|
* This function is the _only time_ an incoming credential exists in clear
|
||||||
|
* text. Note that _generated credentials_ for service accounts are returned
|
||||||
|
* in clear text (once) so that the user can record them for integration into
|
||||||
|
* other applications.
|
||||||
|
*
|
||||||
|
* @param rsa
|
||||||
|
* The RSA encrypted bytes.
|
||||||
|
* @return
|
||||||
|
* The hashed version of the previously-encrypted data.
|
||||||
|
*/
|
||||||
|
private def exchangeEncryptedForHash(rsa: RsaEncryptedBytes): F[Argon2Hash] =
|
||||||
|
rsaDecryption.decrypt(rsa).flatMap(argon2.calculateHash)
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def setUserPassword(
|
||||||
|
id: AccountId,
|
||||||
|
newPassword: Password
|
||||||
|
): EitherT[ConnectionIO, DbError, User] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def listActiveUsers(): fs2.Stream[ConnectionIO, User] =
|
||||||
|
Sql
|
||||||
|
.listUsersForStatus(AccountStatus.Active)
|
||||||
|
.stream
|
||||||
|
.evalMap { case (id, name, createdAt) =>
|
||||||
|
getUserPermissions(id).map { permissions =>
|
||||||
|
new User(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
status = AccountStatus.Active,
|
||||||
|
permissions = permissions,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def getServiceAccount(name: AccountName)
|
||||||
|
: ConnectionIO[Option[ServiceAccount]] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def createServiceAccount(
|
||||||
|
name: AccountName,
|
||||||
|
owner: AccountId,
|
||||||
|
initialPermissions: PermissionSet
|
||||||
|
): EitherT[ConnectionIO, DbError, NewServiceAccount] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def listActiveServiceAccounts(): ConnectionIO[List[ServiceAccount]] =
|
||||||
|
???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def getAgentAccount(name: AccountName)
|
||||||
|
: ConnectionIO[Option[AiAgent]] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def createAgentAccount(
|
||||||
|
name: AccountName,
|
||||||
|
owner: AccountId,
|
||||||
|
initialPermissions: PermissionSet
|
||||||
|
): EitherT[[A] =>> ConnectionIO[A], DbError, NewAiAgentAccount] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def listActiveAgentAccounts(): ConnectionIO[List[AiAgent]] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def rotateClientSecret(
|
||||||
|
accountId: AccountId,
|
||||||
|
credentialId: CredentialId,
|
||||||
|
overlapHours: Int
|
||||||
|
): EitherT[[A] =>> ConnectionIO[A], DbError, Base64] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def setAccountPermissions(
|
||||||
|
id: AccountId,
|
||||||
|
newPermissions: PermissionSet
|
||||||
|
): EitherT[[A] =>> ConnectionIO[A], DbError, Account] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def listAccountCredentials(id: AccountId)
|
||||||
|
: ConnectionIO[List[Credential]] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def revokeCredential(id: CredentialId)
|
||||||
|
: EitherT[[A] =>> ConnectionIO[A], DbError, Unit] = ???
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def expireCredential(id: CredentialId)
|
||||||
|
: EitherT[[A] =>> ConnectionIO[A], DbError, Unit] = ???
|
||||||
|
|
||||||
|
object DoobieAuthDb:
|
||||||
|
|
||||||
|
class Sql(implTypes: ImplTypes):
|
||||||
|
import implTypes.given
|
||||||
|
|
||||||
|
def getUser(name: AccountName)
|
||||||
|
: Query0[(AccountId, AccountStatus, CreatedAt)] =
|
||||||
|
sql"""
|
||||||
|
SELECT id, status, created_at
|
||||||
|
FROM user
|
||||||
|
WHERE name = $name
|
||||||
|
""".query[(AccountId, AccountStatus, CreatedAt)]
|
||||||
|
|
||||||
|
def getUserGlobalPermissions(id: AccountId): Query0[Permission] =
|
||||||
|
sql"""
|
||||||
|
SELECT permission
|
||||||
|
FROM user_permission_global
|
||||||
|
WHERE user_id = $id
|
||||||
|
""".query[Permission]
|
||||||
|
|
||||||
|
def getUserGroupPermissions(id: AccountId)
|
||||||
|
: Query0[(GroupName, Permission)] =
|
||||||
|
sql"""
|
||||||
|
SELECT group_name, permission
|
||||||
|
FROM user_permission_group
|
||||||
|
WHERE user_id = $id
|
||||||
|
""".query[(GroupName, Permission)]
|
||||||
|
|
||||||
|
def listUsersForStatus(status: AccountStatus)
|
||||||
|
: Query0[(AccountId, AccountName, CreatedAt)] =
|
||||||
|
sql"""
|
||||||
|
SELECT id, name, created_at
|
||||||
|
FROM user
|
||||||
|
WHERE status = $status
|
||||||
|
""".query[(AccountId, AccountName, CreatedAt)]
|
||||||
|
|
||||||
|
def insertUser(
|
||||||
|
id: AccountId,
|
||||||
|
name: AccountName,
|
||||||
|
status: AccountStatus,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): Update0 =
|
||||||
|
sql"""
|
||||||
|
INSERT INTO user(id, name, status, created_at)
|
||||||
|
VALUES ($id, $name, $status, $createdAt)
|
||||||
|
""".update
|
||||||
|
|
||||||
|
def insertCredential(
|
||||||
|
id: CredentialId,
|
||||||
|
hash: Argon2Hash,
|
||||||
|
accountId: AccountId,
|
||||||
|
accountType: AccountType,
|
||||||
|
credentialType: CredentialType,
|
||||||
|
status: CredentialStatus,
|
||||||
|
effective: Option[CredentialEffectivity],
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): Update0 =
|
||||||
|
sql"""
|
||||||
|
INSERT INTO credential(credential_id, credential_hash, account_id,
|
||||||
|
account_type, credential_type, status, effective_at, effective_through,
|
||||||
|
created_at)
|
||||||
|
VALUES ($id, $hash, $accountId, $accountType, $credentialType, $status,
|
||||||
|
${effective.map(_.startDate)}, ${effective.flatMap(_.endDate)},
|
||||||
|
$createdAt)
|
||||||
|
""".update
|
||||||
|
|
||||||
|
def insertUserGlobalPermissions(
|
||||||
|
id: AccountId,
|
||||||
|
permissions: Set[Permission]
|
||||||
|
): ConnectionIO[Int] =
|
||||||
|
val command = """
|
||||||
|
INSERT INTO user_permission_global (user_id, permission) VALUES (?, ?)
|
||||||
|
""".stripMargin
|
||||||
|
Update[(AccountId, Permission)](command)
|
||||||
|
.updateMany(permissions.map(p => id -> p).toList)
|
||||||
|
|
||||||
|
def insertUserGroupPermissions(
|
||||||
|
id: AccountId,
|
||||||
|
permissions: Map[GroupName, Set[Permission]]
|
||||||
|
): ConnectionIO[Int] =
|
||||||
|
val command = """
|
||||||
|
INSERT INTO user_permission_group (user_id, group_name, permission) VALUES (?, ?, ?)
|
||||||
|
""".stripMargin
|
||||||
|
Update[(AccountId, GroupName, Permission)](command)
|
||||||
|
.updateMany(permissions.flatMap { case (gn, ps) =>
|
||||||
|
ps.map(p => (id, gn, p))
|
||||||
|
}.toList)
|
||||||
|
|
||||||
|
end Sql
|
||||||
|
|
||||||
|
end DoobieAuthDb
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import cats.effect.Sync
|
||||||
|
import cats.effect.syntax.all.*
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import doobie.*
|
||||||
|
import doobie.implicits.*
|
||||||
|
|
||||||
|
/** Provisions the database for SQLite. This class reflects the current state of
|
||||||
|
* Smolban.
|
||||||
|
*/
|
||||||
|
final class DoobieSqliteDb[F[_]: Sync]:
|
||||||
|
|
||||||
|
def initializeDatabase(xa: Transactor[F]): F[Unit] =
|
||||||
|
(for
|
||||||
|
_ <- setupUser()
|
||||||
|
_ <- setupServiceAccount()
|
||||||
|
_ <- setupAgentAccount()
|
||||||
|
_ <- setupCredential()
|
||||||
|
_ <- setupTag()
|
||||||
|
yield ()).transact(xa)
|
||||||
|
|
||||||
|
def tearDownDatabase(xa: Transactor[F]): F[Unit] =
|
||||||
|
(for _ <- tearDownUser()
|
||||||
|
yield ()).transact(xa) *> withoutTransaction(vacuum()).transact(xa).as(())
|
||||||
|
|
||||||
|
private def withoutTransaction[A](p: ConnectionIO[A]): ConnectionIO[A] =
|
||||||
|
FC.setAutoCommit(true).bracket(_ => p)(_ => FC.setAutoCommit(false))
|
||||||
|
|
||||||
|
private def vacuum(): ConnectionIO[Int] =
|
||||||
|
sql"VACUUM;".update.run
|
||||||
|
|
||||||
|
def setupUser(): ConnectionIO[Unit] =
|
||||||
|
for
|
||||||
|
_ <- userTable()
|
||||||
|
_ <- userIndexStatusCreatedAt()
|
||||||
|
_ <- userPermissionGlobal()
|
||||||
|
_ <- userPermissionGroup()
|
||||||
|
yield ()
|
||||||
|
|
||||||
|
def tearDownUser(): ConnectionIO[Unit] =
|
||||||
|
for
|
||||||
|
_ <- dropUserPermissionGlobal()
|
||||||
|
_ <- dropUserPermissionGroup()
|
||||||
|
_ <- dropUserIndexStatusCreatedAt()
|
||||||
|
_ <- dropUserTable()
|
||||||
|
yield ()
|
||||||
|
|
||||||
|
def userTable(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user(
|
||||||
|
id BLOB NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def dropUserTable(): ConnectionIO[Int] =
|
||||||
|
sql"DROP TABLE IF EXISTS user;".update.run
|
||||||
|
|
||||||
|
def userIndexStatusCreatedAt(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_status_created_at ON user(status, created_at);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def dropUserIndexStatusCreatedAt(): ConnectionIO[Int] =
|
||||||
|
sql"""DROP INDEX IF EXISTS idx_user_status_created_at;""".update.run
|
||||||
|
|
||||||
|
def userPermissionGlobal(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permission_global(
|
||||||
|
user_id BLOB NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, permission)
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def dropUserPermissionGlobal(): ConnectionIO[Int] =
|
||||||
|
sql"""DROP TABLE IF EXISTS user_permission_global;""".update.run
|
||||||
|
|
||||||
|
def userPermissionGroup(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permission_group(
|
||||||
|
user_id BLOB NOT NULL,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, group_name, permission)
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def dropUserPermissionGroup(): ConnectionIO[Int] =
|
||||||
|
sql"""DROP TABLE IF EXISTS user_permission_group;""".update.run
|
||||||
|
|
||||||
|
def setupServiceAccount(): ConnectionIO[Unit] =
|
||||||
|
for
|
||||||
|
_ <- serviceAccountTable()
|
||||||
|
_ <- serviceAccountIndexStatusCreatedAt()
|
||||||
|
_ <- serviceAccountIndexOwner()
|
||||||
|
_ <- serviceAccountPermissionGlobal()
|
||||||
|
_ <- serviceAccountPermissionGroup()
|
||||||
|
yield ()
|
||||||
|
|
||||||
|
def serviceAccountTable(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS service_account(
|
||||||
|
id BLOB NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
owner BLOB NOT NULL
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def serviceAccountIndexStatusCreatedAt(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_account_status_created_at ON service_account(status, created_at);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def serviceAccountIndexOwner(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_account_owner ON service_account(owner);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def serviceAccountPermissionGlobal(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS service_account_permission_global(
|
||||||
|
service_account_id BLOB NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (service_account_id, permission)
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def serviceAccountPermissionGroup(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS service_account_permission_group(
|
||||||
|
service_account_id BLOB NOT NULL,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (service_account_id, group_name, permission)
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def setupAgentAccount(): ConnectionIO[Unit] =
|
||||||
|
for
|
||||||
|
_ <- agentAccountTable()
|
||||||
|
_ <- agentAccountIndexStatusCreatedAt()
|
||||||
|
_ <- agentAccountIndexOwner()
|
||||||
|
_ <- agentAccountPermissionGlobal()
|
||||||
|
_ <- agentAccountPermissionGroup()
|
||||||
|
yield ()
|
||||||
|
|
||||||
|
def agentAccountTable(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_account(
|
||||||
|
id BLOB NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
owner BLOB NOT NULL
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def agentAccountIndexStatusCreatedAt(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_account_status_created_at ON agent_account(status, created_at);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def agentAccountIndexOwner(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_account_owner ON agent_account(owner);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def agentAccountPermissionGlobal(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_account_permission_global(
|
||||||
|
agent_account_id BLOB NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (agent_account_id, permission)
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def agentAccountPermissionGroup(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_account_permission_group(
|
||||||
|
agent_account_id BLOB NOT NULL,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (agent_account_id, group_name, permission)
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def setupCredential(): ConnectionIO[Unit] =
|
||||||
|
for
|
||||||
|
_ <- credentialTable()
|
||||||
|
_ <- credentialIndexAccountId()
|
||||||
|
_ <- credentialIndexAccountType()
|
||||||
|
_ <- credentialIndexCredentialType()
|
||||||
|
_ <- credentialIndexCredentialStatus()
|
||||||
|
yield ()
|
||||||
|
|
||||||
|
def credentialTable(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS credential(
|
||||||
|
credential_id BLOB NOT NULL PRIMARY KEY,
|
||||||
|
credential_hash TEXT NOT NULL,
|
||||||
|
account_id BLOB NOT NULL,
|
||||||
|
account_type TEXT NOT NULL,
|
||||||
|
credential_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
effective_at DATE NULL,
|
||||||
|
effective_through DATE NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def credentialIndexAccountId(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_account_id ON credential(account_id, created_at);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def credentialIndexAccountType(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_account_type ON credential(account_type, created_at);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def credentialIndexCredentialType(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_credential_type ON credential(credential_type, created_at);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def credentialIndexCredentialStatus(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_credential_status ON credential(status, created_at);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def setupTag(): ConnectionIO[Unit] =
|
||||||
|
for
|
||||||
|
_ <- tagTable()
|
||||||
|
_ <- tagIndexCreatedAt()
|
||||||
|
yield ()
|
||||||
|
|
||||||
|
def tagTable(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS tag(
|
||||||
|
tag_value TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
""".update.run
|
||||||
|
|
||||||
|
def tagIndexCreatedAt(): ConnectionIO[Int] =
|
||||||
|
sql"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tag_created_at ON tag(created_at);
|
||||||
|
""".update.run
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import doobie.*
|
||||||
|
import doobie.implicits.*
|
||||||
|
import gs.smolban.db.DbError
|
||||||
|
import gs.smolban.db.TagDb
|
||||||
|
import gs.smolban.db.doobie.DoobieTypes.*
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
import gs.smolban.model.metadata.Tag
|
||||||
|
import gs.smolban.model.metadata.TagValue
|
||||||
|
|
||||||
|
final class DoobieTagDb(
|
||||||
|
sqlStates: CuratedSqlStates,
|
||||||
|
implTypes: ImplTypes
|
||||||
|
) extends TagDb[ConnectionIO]:
|
||||||
|
private val Sql = new DoobieTagDb.Sql(implTypes)
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def createTag(
|
||||||
|
value: TagValue,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): EitherT[ConnectionIO, DbError, Tag] =
|
||||||
|
EitherT(
|
||||||
|
Sql.createTag(value, createdAt).run.attemptSqlState.map {
|
||||||
|
case Left(sqlState) =>
|
||||||
|
if sqlState.value == sqlStates.uniqueViolation.value then
|
||||||
|
Left(DbError.TagAlreadyExists(value))
|
||||||
|
else Left(DbError.GenericDatabaseError(sqlState))
|
||||||
|
case Right(_) =>
|
||||||
|
Right(Tag(value, createdAt))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def readTag(tag: TagValue): ConnectionIO[Option[Tag]] =
|
||||||
|
Sql.readTag(tag).option
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def deleteTag(tag: TagValue): ConnectionIO[Boolean] =
|
||||||
|
for rows <- Sql.deleteTag(tag).run
|
||||||
|
yield rows > 0
|
||||||
|
|
||||||
|
object DoobieTagDb:
|
||||||
|
|
||||||
|
private class Sql(implTypes: ImplTypes):
|
||||||
|
import implTypes.given
|
||||||
|
|
||||||
|
def createTag(
|
||||||
|
tag: TagValue,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): Update0 = sql"""
|
||||||
|
INSERT INTO tag (tag_value, created_at)
|
||||||
|
VALUES ($tag, $createdAt)
|
||||||
|
""".update
|
||||||
|
|
||||||
|
def readTag(tag: TagValue): Query0[Tag] = sql"""
|
||||||
|
SELECT tag_value, created_at
|
||||||
|
FROM tag
|
||||||
|
WHERE tag_value = $tag
|
||||||
|
""".query[Tag]
|
||||||
|
|
||||||
|
def deleteTag(tag: TagValue): Update0 = sql"""
|
||||||
|
DELETE FROM tag
|
||||||
|
WHERE tag_value = $tag
|
||||||
|
""".update
|
||||||
|
|
||||||
|
end Sql
|
||||||
|
|
||||||
|
end DoobieTagDb
|
||||||
179
modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala
Normal file
179
modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import DoobieTypes.ErrorMessages
|
||||||
|
import doobie.*
|
||||||
|
import gs.smolban.auth.Argon2Hash
|
||||||
|
import gs.smolban.auth.CredentialId
|
||||||
|
import gs.smolban.auth.CredentialStatus
|
||||||
|
import gs.smolban.auth.CredentialType
|
||||||
|
import gs.smolban.model.account.AccountId
|
||||||
|
import gs.smolban.model.account.AccountName
|
||||||
|
import gs.smolban.model.account.AccountStatus
|
||||||
|
import gs.smolban.model.account.AccountType
|
||||||
|
import gs.smolban.model.account.Permission
|
||||||
|
import gs.smolban.model.group.GroupName
|
||||||
|
import gs.smolban.model.metadata.TagValue
|
||||||
|
import gs.smolban.model.ticket.CommentId
|
||||||
|
import gs.smolban.model.ticket.TicketStatus
|
||||||
|
import gs.uuid.v0.UUID
|
||||||
|
import gs.uuid.v0.UUIDFormat
|
||||||
|
|
||||||
|
trait DoobieTypes:
|
||||||
|
|
||||||
|
implicit val accountIdGet: Get[AccountId] =
|
||||||
|
Get[Array[Byte]].tmap(bytes => AccountId(UUID(UUIDFormat.fromBytes(bytes))))
|
||||||
|
|
||||||
|
implicit val accountIdPut: Put[AccountId] =
|
||||||
|
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
|
||||||
|
|
||||||
|
implicit val accountNameGet: Get[AccountName] =
|
||||||
|
Get[String].temap { value =>
|
||||||
|
AccountName.validate(value) match
|
||||||
|
case None => Left(ErrorMessages.invalidAccountName(value))
|
||||||
|
case Some(s) => Right(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
implicit val accountNamePut: Put[AccountName] =
|
||||||
|
Put[String].tcontramap(_.toString())
|
||||||
|
|
||||||
|
implicit val accountStatusGet: Get[AccountStatus] =
|
||||||
|
Get[String].temap(dbValue =>
|
||||||
|
AccountStatus.parse(dbValue) match
|
||||||
|
case None => Left(ErrorMessages.invalidAccountStatus(dbValue))
|
||||||
|
case Some(status) => Right(status)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val accountStatusPut: Put[AccountStatus] =
|
||||||
|
Put[String].tcontramap(_.name)
|
||||||
|
|
||||||
|
implicit val accountTypeGet: Get[AccountType] =
|
||||||
|
Get[String].temap(dbValue =>
|
||||||
|
AccountType.parse(dbValue) match
|
||||||
|
case None => Left(ErrorMessages.invalidAccountType(dbValue))
|
||||||
|
case Some(status) => Right(status)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val accountTypePut: Put[AccountType] =
|
||||||
|
Put[String].tcontramap(_.name)
|
||||||
|
|
||||||
|
implicit val permissionGet: Get[Permission] =
|
||||||
|
Get[String].temap(dbValue =>
|
||||||
|
Permission.parse(dbValue) match
|
||||||
|
case None => Left(ErrorMessages.invalidPermission(dbValue))
|
||||||
|
case Some(status) => Right(status)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val permissionPut: Put[Permission] =
|
||||||
|
Put[String].tcontramap(_.name)
|
||||||
|
|
||||||
|
implicit val groupNameGet: Get[GroupName] =
|
||||||
|
Get[String].temap { value =>
|
||||||
|
GroupName.validate(value) match
|
||||||
|
case None => Left(ErrorMessages.invalidGroupName(value))
|
||||||
|
case Some(s) => Right(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
implicit val groupNamePut: Put[GroupName] =
|
||||||
|
Put[String].tcontramap(_.toString())
|
||||||
|
|
||||||
|
implicit val ticketStatusGet: Get[TicketStatus] =
|
||||||
|
Get[String].temap(dbValue =>
|
||||||
|
TicketStatus.parse(dbValue) match
|
||||||
|
case None => Left(ErrorMessages.invalidTicketStatus(dbValue))
|
||||||
|
case Some(status) => Right(status)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val ticketStatusPut: Put[TicketStatus] =
|
||||||
|
Put[String].tcontramap(_.name)
|
||||||
|
|
||||||
|
implicit val commentIdGet: Get[CommentId] =
|
||||||
|
Get[Array[Byte]].tmap(bytes => CommentId(UUID(UUIDFormat.fromBytes(bytes))))
|
||||||
|
|
||||||
|
implicit val commentIdPut: Put[CommentId] =
|
||||||
|
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
|
||||||
|
|
||||||
|
implicit val tagValueGet: Get[TagValue] =
|
||||||
|
Get[String].temap { value =>
|
||||||
|
TagValue.validate(value) match
|
||||||
|
case None => Left(ErrorMessages.invalidTagValue(value))
|
||||||
|
case Some(s) => Right(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
implicit val tagValuePut: Put[TagValue] =
|
||||||
|
Put[String].tcontramap(_.toString())
|
||||||
|
|
||||||
|
implicit val credentialIdGet: Get[CredentialId] =
|
||||||
|
Get[Array[Byte]].tmap(bytes =>
|
||||||
|
CredentialId(UUID(UUIDFormat.fromBytes(bytes)))
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val credentialIdPut: Put[CredentialId] =
|
||||||
|
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
|
||||||
|
|
||||||
|
implicit val argon2HashPut: Put[Argon2Hash] =
|
||||||
|
Put[String].tcontramap(_.encode())
|
||||||
|
|
||||||
|
implicit val argon2HashGet: Get[Argon2Hash] =
|
||||||
|
Get[String].temap(dbValue =>
|
||||||
|
Argon2Hash.decode(dbValue) match
|
||||||
|
case None => Left(ErrorMessages.invalidAccountStatus(dbValue))
|
||||||
|
case Some(hash) => Right(hash)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val credentialTypeGet: Get[CredentialType] =
|
||||||
|
Get[String].temap(dbValue =>
|
||||||
|
CredentialType.parse(dbValue) match
|
||||||
|
case None => Left(ErrorMessages.invalidCredentialType(dbValue))
|
||||||
|
case Some(status) => Right(status)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val credentialTypePut: Put[CredentialType] =
|
||||||
|
Put[String].tcontramap(_.name)
|
||||||
|
|
||||||
|
implicit val credentialStatusGet: Get[CredentialStatus] =
|
||||||
|
Get[String].temap(dbValue =>
|
||||||
|
CredentialStatus.parse(dbValue) match
|
||||||
|
case None => Left(ErrorMessages.invalidCredentialStatus(dbValue))
|
||||||
|
case Some(status) => Right(status)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val credentialStatusPut: Put[CredentialStatus] =
|
||||||
|
Put[String].tcontramap(_.name)
|
||||||
|
|
||||||
|
object DoobieTypes extends DoobieTypes:
|
||||||
|
|
||||||
|
object ErrorMessages:
|
||||||
|
|
||||||
|
def invalidAccountName(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid account name."
|
||||||
|
|
||||||
|
def invalidAccountStatus(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid account status."
|
||||||
|
|
||||||
|
def invalidAccountType(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid account type."
|
||||||
|
|
||||||
|
def invalidPermission(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid permission."
|
||||||
|
|
||||||
|
def invalidGroupName(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid group name."
|
||||||
|
|
||||||
|
def invalidTagValue(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid tag value."
|
||||||
|
|
||||||
|
def invalidTicketStatus(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid ticket status."
|
||||||
|
|
||||||
|
def invalidArgon2Hash(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid Argon2 hash."
|
||||||
|
|
||||||
|
def invalidCredentialType(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid credential type."
|
||||||
|
|
||||||
|
def invalidCredentialStatus(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid credential status."
|
||||||
|
|
||||||
|
end ErrorMessages
|
||||||
|
|
||||||
|
end DoobieTypes
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import doobie.*
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** Abstraction for supporting implementation-specific types.
|
||||||
|
*/
|
||||||
|
trait ImplTypes:
|
||||||
|
given createdAtGet: Get[CreatedAt]
|
||||||
|
given createdAtPut: Put[CreatedAt]
|
||||||
|
given instantGet: Get[Instant]
|
||||||
|
given instantPut: Put[Instant]
|
||||||
|
given localDateGet: Get[LocalDate]
|
||||||
|
given localDatePut: Put[LocalDate]
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import doobie.*
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** SQLite implementation of specific types.
|
||||||
|
*/
|
||||||
|
final class SqliteImplTypes extends ImplTypes:
|
||||||
|
|
||||||
|
given createdAtGet: Get[CreatedAt] =
|
||||||
|
Get[Long].tmap(CreatedAt.fromMilliseconds)
|
||||||
|
|
||||||
|
given createdAtPut: Put[CreatedAt] =
|
||||||
|
Put[Long].tcontramap(_.toMilliseconds())
|
||||||
|
|
||||||
|
given instantGet: Get[Instant] =
|
||||||
|
Get[Long].tmap(Instant.ofEpochMilli)
|
||||||
|
|
||||||
|
given instantPut: Put[Instant] =
|
||||||
|
Put[Long].tcontramap(_.toEpochMilli())
|
||||||
|
|
||||||
|
given localDateGet: Get[LocalDate] =
|
||||||
|
Get[String].tmap(LocalDate.parse)
|
||||||
|
|
||||||
|
given localDatePut: Put[LocalDate] =
|
||||||
|
Put[String].tcontramap(_.toString())
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import cats.effect.IO
|
||||||
|
import cats.effect.Resource
|
||||||
|
import cats.effect.unsafe.IORuntime
|
||||||
|
import doobie.*
|
||||||
|
import doobie.hikari.Config
|
||||||
|
import doobie.hikari.HikariTransactor
|
||||||
|
import doobie.implicits.*
|
||||||
|
import doobie.util.transactor.Transactor
|
||||||
|
import gs.smolban.db.TagDb
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
import gs.smolban.model.metadata.Tag
|
||||||
|
import gs.smolban.model.metadata.TagValue
|
||||||
|
import java.time.Clock
|
||||||
|
import munit.Location
|
||||||
|
|
||||||
|
class DoobieTagDbTests extends munit.FunSuite:
|
||||||
|
given IORuntime = IORuntime.global
|
||||||
|
|
||||||
|
def iotest(
|
||||||
|
name: String
|
||||||
|
)(
|
||||||
|
f: => IO[Unit]
|
||||||
|
)(
|
||||||
|
using
|
||||||
|
Location
|
||||||
|
): Unit =
|
||||||
|
test(name)(f.unsafeRunSync())
|
||||||
|
|
||||||
|
private val tagDb: TagDb[ConnectionIO] = new DoobieTagDb(
|
||||||
|
CuratedSqlStates.sqlite,
|
||||||
|
new SqliteImplTypes
|
||||||
|
)
|
||||||
|
|
||||||
|
private val clock = Clock.systemDefaultZone()
|
||||||
|
|
||||||
|
private def dbConfig(dbName: String): Config =
|
||||||
|
Config(
|
||||||
|
jdbcUrl = s"jdbc:sqlite:test/$dbName",
|
||||||
|
driverClassName = Some("org.sqlite.JDBC")
|
||||||
|
)
|
||||||
|
|
||||||
|
private def dbTransactor(dbName: String): Resource[IO, Transactor[IO]] =
|
||||||
|
HikariTransactor.fromConfig[IO](dbConfig(dbName))
|
||||||
|
|
||||||
|
private def inDb(dbName: String): Resource[IO, TestDb] =
|
||||||
|
for
|
||||||
|
xa <- dbTransactor(dbName)
|
||||||
|
db <- provision(xa)
|
||||||
|
yield new TestDb(xa, db)
|
||||||
|
|
||||||
|
private def provision(xa: Transactor[IO]): Resource[IO, DoobieSqliteDb[IO]] =
|
||||||
|
val out = new DoobieSqliteDb[IO]
|
||||||
|
Resource.make(
|
||||||
|
out.tearDownDatabase(xa) *> out.initializeDatabase(xa).as(out)
|
||||||
|
)(db => db.tearDownDatabase(xa))
|
||||||
|
|
||||||
|
iotest("(db_0001) should create, read, and delete a tag") {
|
||||||
|
val tagValue = TagValue.unsafe("x")
|
||||||
|
val createdAt = CreatedAt.now(clock)
|
||||||
|
inDb("db_0001").use { testDb =>
|
||||||
|
(for
|
||||||
|
tag <- tagDb.createTag(tagValue, createdAt).value
|
||||||
|
t2 <- tagDb.readTag(tagValue)
|
||||||
|
result <- tagDb.deleteTag(tagValue)
|
||||||
|
yield
|
||||||
|
assertEquals(tag, Right(Tag(tagValue, createdAt)))
|
||||||
|
assertEquals(t2, tag.toOption)
|
||||||
|
assert(result)
|
||||||
|
).transact(testDb.xa)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def dropTable(): Update0 =
|
||||||
|
sql"""
|
||||||
|
DROP TABLE IF EXISTS tag
|
||||||
|
""".update
|
||||||
|
|
||||||
|
def createTable(): Update0 =
|
||||||
|
sql"""
|
||||||
|
CREATE TABLE IF NOT EXISTS tag(
|
||||||
|
tag_value TEXT NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
""".update
|
||||||
10
modules/db/src/test/scala/gs/smolban/db/doobie/TestDb.scala
Normal file
10
modules/db/src/test/scala/gs/smolban/db/doobie/TestDb.scala
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import cats.effect.IO
|
||||||
|
import doobie.util.transactor.Transactor
|
||||||
|
import gs.smolban.db.doobie.DoobieSqliteDb
|
||||||
|
|
||||||
|
final class TestDb(
|
||||||
|
val xa: Transactor[IO],
|
||||||
|
val sqlite: DoobieSqliteDb[IO]
|
||||||
|
)
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package gs.smolban.model
|
|
||||||
|
|
||||||
import cats.Show
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
/** Describes an instant at which something was created. Opaque type for
|
|
||||||
* `java.time.Instant`.
|
|
||||||
*/
|
|
||||||
opaque type CreatedAt = Instant
|
|
||||||
|
|
||||||
object CreatedAt:
|
|
||||||
|
|
||||||
/** Instantiate a new [[CreatedAt]].
|
|
||||||
*
|
|
||||||
* @param timestamp
|
|
||||||
* The underlying timestamp.
|
|
||||||
* @return
|
|
||||||
* The new instance.
|
|
||||||
*/
|
|
||||||
def apply(timestamp: Instant): CreatedAt = timestamp
|
|
||||||
|
|
||||||
given CanEqual[CreatedAt, CreatedAt] = CanEqual.derived
|
|
||||||
|
|
||||||
given Show[CreatedAt] = _.toInstant().toString()
|
|
||||||
|
|
||||||
extension (createdAt: CreatedAt)
|
|
||||||
/** Unwrap this value.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The underlying `Instant` value.
|
|
||||||
*/
|
|
||||||
def toInstant(): Instant = createdAt
|
|
||||||
|
|
||||||
end CreatedAt
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
package gs.smolban.model
|
|
||||||
|
|
||||||
import cats.Show
|
|
||||||
import gs.smolban.model.users.User
|
|
||||||
|
|
||||||
opaque type CreatedBy = User.Id
|
|
||||||
|
|
||||||
object CreatedBy:
|
|
||||||
|
|
||||||
def apply(timestamp: User.Id): CreatedBy = timestamp
|
|
||||||
|
|
||||||
given CanEqual[CreatedBy, CreatedBy] = CanEqual.derived
|
|
||||||
|
|
||||||
given Show[CreatedBy] = _.toUserId().toUUID().withoutDashes()
|
|
||||||
|
|
||||||
extension (createdAt: CreatedBy)
|
|
||||||
/** Unwrap this value.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The underlying `User.Id` value.
|
|
||||||
*/
|
|
||||||
def toUserId(): User.Id = createdAt
|
|
||||||
|
|
||||||
end CreatedBy
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
package gs.smolban.model
|
|
||||||
|
|
||||||
import cats.Show
|
|
||||||
import gs.slug.v0.Slug
|
|
||||||
import gs.uuid.v0.UUID
|
|
||||||
|
|
||||||
/** Groups are the basic unit of organization in Smolban. Each [[Ticket]]
|
|
||||||
* belongs to a single `Group`.
|
|
||||||
*
|
|
||||||
* @param id
|
|
||||||
* The unique identifier for the group.
|
|
||||||
* @param slug
|
|
||||||
* The unique slug for the group.
|
|
||||||
* @param createdAt
|
|
||||||
* The instant at which this group was created.
|
|
||||||
* @param createdBy
|
|
||||||
* The unique identifier of the user who created this group.
|
|
||||||
*/
|
|
||||||
case class Group(
|
|
||||||
id: Group.Id,
|
|
||||||
slug: Slug,
|
|
||||||
createdAt: CreatedAt,
|
|
||||||
createdBy: CreatedBy
|
|
||||||
)
|
|
||||||
|
|
||||||
object Group:
|
|
||||||
|
|
||||||
given CanEqual[Group, Group] = CanEqual.derived
|
|
||||||
|
|
||||||
/** Unique identifier for a [[Group]]. This is an opaque type for a UUID.
|
|
||||||
*/
|
|
||||||
opaque type Id = UUID
|
|
||||||
|
|
||||||
object Id:
|
|
||||||
|
|
||||||
/** Instantiate a new [[Group.Id]].
|
|
||||||
*
|
|
||||||
* @param id
|
|
||||||
* The underlying UUID.
|
|
||||||
* @return
|
|
||||||
* The new [[Group.Id]] instance.
|
|
||||||
*/
|
|
||||||
def apply(id: UUID): Id = id
|
|
||||||
|
|
||||||
given CanEqual[Id, Id] = CanEqual.derived
|
|
||||||
|
|
||||||
given Show[Id] = _.toUUID().withoutDashes()
|
|
||||||
|
|
||||||
extension (id: Id)
|
|
||||||
/** Unwrap this Group ID.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The underlying UUID value.
|
|
||||||
*/
|
|
||||||
def toUUID(): UUID = id
|
|
||||||
|
|
||||||
end Id
|
|
||||||
|
|
||||||
end Group
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package gs.smolban.model
|
||||||
|
|
||||||
|
/** The parent trait for all errors in Smolban.
|
||||||
|
*/
|
||||||
|
trait SmolbanError
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package gs.smolban.model
|
|
||||||
|
|
||||||
import cats.Show
|
|
||||||
|
|
||||||
/** Opaque type for a String that represents a unique `Tag` in Smolban. Tags are
|
|
||||||
* just arbitrary non-empty strings which can be used to annotate [[Ticket]].
|
|
||||||
*
|
|
||||||
* Tags are defined _globally_ in Smolban.
|
|
||||||
*/
|
|
||||||
opaque type Tag = String
|
|
||||||
|
|
||||||
object Tag:
|
|
||||||
|
|
||||||
def validate(candidate: String): Option[Tag] =
|
|
||||||
if candidate.isEmpty() then None else Some(candidate)
|
|
||||||
|
|
||||||
given CanEqual[Tag, Tag] = CanEqual.derived
|
|
||||||
|
|
||||||
given Show[Tag] = t => t
|
|
||||||
|
|
||||||
end Tag
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
package gs.smolban.model
|
|
||||||
|
|
||||||
import cats.Show
|
|
||||||
import gs.smolban.model.users.User
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
/** Tickets represent some tracked work.
|
|
||||||
*
|
|
||||||
* @param id
|
|
||||||
* Unique identifier _within_ the [[Group]].
|
|
||||||
* @param group
|
|
||||||
* Unique identifier of the [[Group]] that owns this ticket.
|
|
||||||
* @param createdAt
|
|
||||||
* The instant at which this ticket was created.
|
|
||||||
* @param createdBy
|
|
||||||
* The unique identifier of the [[User]] who created this ticket.
|
|
||||||
* @param title
|
|
||||||
* Arbitrary string title of the ticket.
|
|
||||||
* @param description
|
|
||||||
* Markdown contents of the ticket.
|
|
||||||
* @param tags
|
|
||||||
* List of [[Tag]] applied to this ticket.
|
|
||||||
* @param status
|
|
||||||
* Current [[Ticket.Status]] of this ticket.
|
|
||||||
* @param statusHistory
|
|
||||||
* Linear history of this ticket in terms of status changes.
|
|
||||||
* @param assignee
|
|
||||||
* If set, this ticket is assigned to a specific user.
|
|
||||||
*/
|
|
||||||
case class Ticket(
|
|
||||||
id: Ticket.Id,
|
|
||||||
group: Group.Id,
|
|
||||||
createdAt: CreatedAt,
|
|
||||||
createdBy: CreatedBy,
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
tags: List[Tag],
|
|
||||||
status: Ticket.Status,
|
|
||||||
statusHistory: List[(Ticket.Status, Instant)],
|
|
||||||
assignee: Option[User.Id]
|
|
||||||
)
|
|
||||||
|
|
||||||
object Ticket:
|
|
||||||
|
|
||||||
/** Unique identifier - relative to some [[Group]] - for a [[Ticket]]. This is
|
|
||||||
* an opaque type for a Long. In general, [[Ticket]] identifiers are
|
|
||||||
* sequences within a group.
|
|
||||||
*/
|
|
||||||
opaque type Id = Long
|
|
||||||
|
|
||||||
object Id:
|
|
||||||
|
|
||||||
/** Instantiate a new [[Ticket.Id]].
|
|
||||||
*
|
|
||||||
* @param id
|
|
||||||
* The underlying Long.
|
|
||||||
* @return
|
|
||||||
* The new [[Ticket.Id]] instance.
|
|
||||||
*/
|
|
||||||
def apply(id: Long): Id = id
|
|
||||||
|
|
||||||
given CanEqual[Id, Id] = CanEqual.derived
|
|
||||||
|
|
||||||
given Show[Id] = _.toLong().toString()
|
|
||||||
|
|
||||||
extension (id: Id)
|
|
||||||
/** Unwrap this Ticket ID.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The underlying Long value.
|
|
||||||
*/
|
|
||||||
def toLong(): Long = id
|
|
||||||
|
|
||||||
end Id
|
|
||||||
|
|
||||||
/** Enumeration that describes the status of a [[Ticket]] in Smolban. Smolban
|
|
||||||
* does not yet support custom status/workflow.
|
|
||||||
*/
|
|
||||||
sealed abstract class Status(val name: String)
|
|
||||||
|
|
||||||
object Status:
|
|
||||||
|
|
||||||
given CanEqual[Status, Status] = CanEqual.derived
|
|
||||||
|
|
||||||
given Show[Status] = _.name
|
|
||||||
|
|
||||||
/** This ticket is new, and ready to be started. New tickets may be put into
|
|
||||||
* progress or canceled.
|
|
||||||
*/
|
|
||||||
case object Ready extends Status("ready")
|
|
||||||
|
|
||||||
/** This ticket is being worked on actively. In progress tickets can be
|
|
||||||
* paused, completed, or canceled.
|
|
||||||
*/
|
|
||||||
case object InProgress extends Status("in_progress")
|
|
||||||
|
|
||||||
/** This ticket was being worked on, but was temporarily stopped. Paused
|
|
||||||
* tickets may be put into progress or canceled.
|
|
||||||
*/
|
|
||||||
case object Paused extends Status("paused")
|
|
||||||
|
|
||||||
/** This ticket was driven to completion. The work is done. Once in this
|
|
||||||
* state, the status may no longer change.
|
|
||||||
*/
|
|
||||||
case object Complete extends Status("complete")
|
|
||||||
|
|
||||||
/** This ticket was canceled for some reason. Once in this state, the status
|
|
||||||
* may no longer change.
|
|
||||||
*/
|
|
||||||
case object Canceled extends Status("canceled")
|
|
||||||
|
|
||||||
val All: List[Status] = List(Ready, InProgress, Paused, Complete, Canceled)
|
|
||||||
|
|
||||||
def parse(candidate: String): Option[Status] =
|
|
||||||
All.find(_.name.equalsIgnoreCase(candidate))
|
|
||||||
|
|
||||||
end Status
|
|
||||||
|
|
||||||
end Ticket
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package gs.smolban.model.account
|
||||||
|
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
import gs.smolban.model.metadata.Description
|
||||||
|
|
||||||
|
/** Represents some Account - any entity that can be represented in Smolban and
|
||||||
|
* take some form of action in Smolban.
|
||||||
|
*
|
||||||
|
* @param accountType
|
||||||
|
* The [[AccountType]].
|
||||||
|
*/
|
||||||
|
sealed abstract class Account(val accountType: AccountType):
|
||||||
|
/** @return
|
||||||
|
* The unique identifier for this [[Account]].
|
||||||
|
*/
|
||||||
|
def id: AccountId
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The unique name for this [[Account]].
|
||||||
|
*/
|
||||||
|
def name: AccountName
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The current status of this [[Account]].
|
||||||
|
*/
|
||||||
|
def status: AccountStatus
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The complete set of [[Permission]] assigned to this account.
|
||||||
|
*/
|
||||||
|
def permissions: PermissionSet
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The instant this [[Account]] was created.
|
||||||
|
*/
|
||||||
|
def createdAt: CreatedAt
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: Account => id == other.id
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def hashCode(): Int = id.unwrap().hashCode()
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def toString(): String =
|
||||||
|
s"${accountType.name}:${id.unwrap().withoutDashes()}"
|
||||||
|
|
||||||
|
/** Account for regular human users.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique identifier for this [[Account]].
|
||||||
|
* @param name
|
||||||
|
* The unique name for this [[Account]].
|
||||||
|
* @param status
|
||||||
|
* The current status of this [[Account]].
|
||||||
|
* @param permissions
|
||||||
|
* The complete set of [[Permission]] assigned to this account.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant this [[Account]] was created.
|
||||||
|
*/
|
||||||
|
final class User(
|
||||||
|
val id: AccountId,
|
||||||
|
val name: AccountName,
|
||||||
|
val status: AccountStatus,
|
||||||
|
val permissions: PermissionSet,
|
||||||
|
val createdAt: CreatedAt
|
||||||
|
) extends Account(AccountType.User)
|
||||||
|
|
||||||
|
/** Account for arbitrary services and integrations.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique identifier for this [[Account]].
|
||||||
|
* @param name
|
||||||
|
* The unique name for this [[Account]].
|
||||||
|
* @param status
|
||||||
|
* The current status of this [[Account]].
|
||||||
|
* @param permissions
|
||||||
|
* The complete set of [[Permission]] assigned to this account.
|
||||||
|
* @param description
|
||||||
|
* The purpose of this service account.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant this [[Account]] was created.
|
||||||
|
* @param owner
|
||||||
|
* The account which owns this service account. This will always be a
|
||||||
|
* [[User]].
|
||||||
|
*/
|
||||||
|
final class ServiceAccount(
|
||||||
|
val id: AccountId,
|
||||||
|
val name: AccountName,
|
||||||
|
val status: AccountStatus,
|
||||||
|
val permissions: PermissionSet,
|
||||||
|
val description: Description,
|
||||||
|
val createdAt: CreatedAt,
|
||||||
|
val owner: AccountId
|
||||||
|
) extends Account(AccountType.Service)
|
||||||
|
|
||||||
|
/** Account to specifically represent AI Agents.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique identifier for this [[Account]].
|
||||||
|
* @param name
|
||||||
|
* The unique name for this [[Account]].
|
||||||
|
* @param status
|
||||||
|
* The current status of this [[Account]].
|
||||||
|
* @param permissions
|
||||||
|
* The complete set of [[Permission]] assigned to this account.
|
||||||
|
* @param description
|
||||||
|
* The purpose of this AI Agent integration.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant this [[Account]] was created.
|
||||||
|
* @param owner
|
||||||
|
* The account which owns this agent account. This will always be a [[User]].
|
||||||
|
*/
|
||||||
|
final class AiAgent(
|
||||||
|
val id: AccountId,
|
||||||
|
val name: AccountName,
|
||||||
|
val status: AccountStatus,
|
||||||
|
val permissions: PermissionSet,
|
||||||
|
val description: Description,
|
||||||
|
val createdAt: CreatedAt,
|
||||||
|
val owner: AccountId
|
||||||
|
) extends Account(AccountType.Agent)
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package gs.smolban.model.account
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
import gs.uuid.v0.UUID
|
||||||
|
|
||||||
|
/** Uniquely identifies a _single account_ within Smolban.
|
||||||
|
*/
|
||||||
|
opaque type AccountId = UUID
|
||||||
|
|
||||||
|
object AccountId:
|
||||||
|
|
||||||
|
/** Instantiate a new [[AccountId]] from the given UUID.
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
* The UUID.
|
||||||
|
* @return
|
||||||
|
* The new [[AccountId]].
|
||||||
|
*/
|
||||||
|
def apply(value: UUID): AccountId = value
|
||||||
|
|
||||||
|
/** Generate a new [[AccountId]] using the UUIDv7 algorithm.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The new [[AccountId]].
|
||||||
|
*/
|
||||||
|
def generate(): AccountId = UUID.v7()
|
||||||
|
|
||||||
|
given CanEqual[AccountId, AccountId] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[AccountId] = _.unwrap().withoutDashes()
|
||||||
|
|
||||||
|
given Eq[AccountId] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
extension (accountId: AccountId)
|
||||||
|
/** @return
|
||||||
|
* The underlying UUID value.
|
||||||
|
*/
|
||||||
|
def unwrap(): UUID = accountId
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The underlying UUID value.
|
||||||
|
*/
|
||||||
|
def toUUID(): UUID = accountId
|
||||||
|
|
||||||
|
end AccountId
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package gs.smolban.model.account
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
import scala.util.matching.Regex
|
||||||
|
|
||||||
|
/** Opaque type which represents the name of some account. These values are
|
||||||
|
* non-empty, arbitrary strings, with at most 80 characters. These are
|
||||||
|
* descriptive values, but _may_ be used as part of credentials (e.g. for a
|
||||||
|
* "user" account, this can be the username).
|
||||||
|
*/
|
||||||
|
opaque type AccountName = String
|
||||||
|
|
||||||
|
object AccountName:
|
||||||
|
private val regex: Regex = """^.{1,80}$""".r
|
||||||
|
|
||||||
|
/** Validate the candidate string. Account names may be a maximum of 80
|
||||||
|
* characters. Name characters are not restricted.
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The candidate string.
|
||||||
|
* @return
|
||||||
|
* Some new [[AccountName]] instance, or `None` if the candidate is not
|
||||||
|
* valid.
|
||||||
|
*/
|
||||||
|
def validate(candidate: String): Option[AccountName] =
|
||||||
|
if regex.matches(candidate) then Some(candidate) else None
|
||||||
|
|
||||||
|
given CanEqual[AccountName, AccountName] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[AccountName] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[AccountName] = an => an
|
||||||
|
|
||||||
|
extension (accountName: AccountName)
|
||||||
|
/** @return
|
||||||
|
* The underlying string value.
|
||||||
|
*/
|
||||||
|
def unwrap(): String = accountName
|
||||||
|
|
||||||
|
end AccountName
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package gs.smolban.model.account
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Enumeration that describes the status of an [[Account]].
|
||||||
|
*
|
||||||
|
* Smolban accounts are either active or not. They cannot be deleted - to
|
||||||
|
* preserve lineage of all operations.
|
||||||
|
*/
|
||||||
|
sealed abstract class AccountStatus(val name: String):
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: AccountStatus => name == other.name
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def toString(): String = name
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def hashCode(): Int = name.hashCode()
|
||||||
|
|
||||||
|
object AccountStatus:
|
||||||
|
|
||||||
|
given CanEqual[AccountStatus, AccountStatus] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[AccountStatus] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[AccountStatus] = _.name
|
||||||
|
|
||||||
|
/** The account is active and can be used freely.
|
||||||
|
*/
|
||||||
|
case object Active extends AccountStatus("active")
|
||||||
|
|
||||||
|
/** The account is not active and cannot be used unless reactivated.
|
||||||
|
*/
|
||||||
|
case object Inactive extends AccountStatus("inactive")
|
||||||
|
|
||||||
|
/** List of all valid account types.
|
||||||
|
*/
|
||||||
|
val All: List[AccountStatus] =
|
||||||
|
List(Active, Inactive)
|
||||||
|
|
||||||
|
/** Parse the given string as an [[AccountStatus]].
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The string to parse.
|
||||||
|
* @return
|
||||||
|
* Some account status value, or `None` if the given string is not a valid
|
||||||
|
* account status.
|
||||||
|
*/
|
||||||
|
def parse(candidate: String): Option[AccountStatus] =
|
||||||
|
All.find(_.name.equalsIgnoreCase(candidate))
|
||||||
|
|
||||||
|
end AccountStatus
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package gs.smolban.model.account
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Enumeration that describes the supported account types in Smolban.
|
||||||
|
*/
|
||||||
|
sealed abstract class AccountType(val name: String):
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: AccountType => name == other.name
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def toString(): String = name
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def hashCode(): Int = name.hashCode()
|
||||||
|
|
||||||
|
object AccountType:
|
||||||
|
|
||||||
|
given CanEqual[AccountType, AccountType] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[AccountType] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[AccountType] = _.name
|
||||||
|
|
||||||
|
/** Regular human users. Typically access Smolban via some UI.
|
||||||
|
*/
|
||||||
|
case object User extends AccountType("user")
|
||||||
|
|
||||||
|
/** Service accounts.
|
||||||
|
*/
|
||||||
|
case object Service extends AccountType("service_account")
|
||||||
|
|
||||||
|
/** AI agents.
|
||||||
|
*/
|
||||||
|
case object Agent extends AccountType("ai_agent")
|
||||||
|
|
||||||
|
/** List of all valid account types.
|
||||||
|
*/
|
||||||
|
val All: List[AccountType] =
|
||||||
|
List(User, Service, Agent)
|
||||||
|
|
||||||
|
/** Parse the given string as an [[AccountType]].
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The string to parse.
|
||||||
|
* @return
|
||||||
|
* Some account type value, or `None` if the given string is not a valid
|
||||||
|
* account type.
|
||||||
|
*/
|
||||||
|
def parse(candidate: String): Option[AccountType] =
|
||||||
|
All.find(_.name.equalsIgnoreCase(candidate))
|
||||||
|
|
||||||
|
end AccountType
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
package gs.smolban.model.account
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Enumeration that describes all permissions supported by Smolban.
|
||||||
|
*
|
||||||
|
* In Smolban: all users may read all tickets. Smolban does not have the notion
|
||||||
|
* of private tickets.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The unique name of the permission.
|
||||||
|
*/
|
||||||
|
sealed abstract class Permission(val name: String):
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: Permission => name == other.name
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def toString(): String = name
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def hashCode(): Int = name.hashCode()
|
||||||
|
|
||||||
|
object Permission:
|
||||||
|
|
||||||
|
given CanEqual[Permission, Permission] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[Permission] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[Permission] = _.name
|
||||||
|
|
||||||
|
/** Permission to manage tickets within groups.
|
||||||
|
*
|
||||||
|
* ## Scope
|
||||||
|
*
|
||||||
|
* - Global
|
||||||
|
* - Group
|
||||||
|
*
|
||||||
|
* ## Capabilities
|
||||||
|
*
|
||||||
|
* - Create tickets within the given groups.
|
||||||
|
* - Modify tickets within the given groups.
|
||||||
|
*/
|
||||||
|
case object GroupMember extends Permission("group_member")
|
||||||
|
|
||||||
|
/** Permission to manage groups. Superset of `GroupMember`.
|
||||||
|
*
|
||||||
|
* ## Scope
|
||||||
|
*
|
||||||
|
* - Global
|
||||||
|
* - Group
|
||||||
|
*
|
||||||
|
* ## Capabilities
|
||||||
|
*
|
||||||
|
* - Create tickets within the given groups.
|
||||||
|
* - Modify tickets within the given groups.
|
||||||
|
* - Modify group attributes.
|
||||||
|
*/
|
||||||
|
case object ManageGroup extends Permission("manage_group")
|
||||||
|
|
||||||
|
/** Global permission to create groups.
|
||||||
|
*/
|
||||||
|
case object CreateGroup extends Permission("create_group")
|
||||||
|
|
||||||
|
/** Permission to manage user accounts.
|
||||||
|
*
|
||||||
|
* ## Scope
|
||||||
|
*
|
||||||
|
* - Global
|
||||||
|
*
|
||||||
|
* ## Capabilities
|
||||||
|
*
|
||||||
|
* - Deactivate user accounts.
|
||||||
|
* - Revoke user credentials.
|
||||||
|
* - Assign permissions to user accounts.
|
||||||
|
*
|
||||||
|
* Note: Only administrators can reactivate accounts.
|
||||||
|
*/
|
||||||
|
case object ManageUserAccount extends Permission("manage_user_account")
|
||||||
|
|
||||||
|
/** Global permission to create users.
|
||||||
|
*/
|
||||||
|
case object CreateUserAccount extends Permission("create_user_account")
|
||||||
|
|
||||||
|
/** Permission to manage service accounts.
|
||||||
|
*
|
||||||
|
* ## Scope
|
||||||
|
*
|
||||||
|
* - Global
|
||||||
|
*
|
||||||
|
* Users who create service accounts always have permission to manage their
|
||||||
|
* own service accounts.
|
||||||
|
*
|
||||||
|
* ## Capabilities
|
||||||
|
*
|
||||||
|
* - Deactivate service accounts.
|
||||||
|
* - Revoke service account credentials.
|
||||||
|
* - Provision new service account credentials.
|
||||||
|
* - Assign permissions to service accounts.
|
||||||
|
*
|
||||||
|
* Note: Only administrators can reactivate accounts.
|
||||||
|
*/
|
||||||
|
case object ManageServiceAccount extends Permission("manage_service_account")
|
||||||
|
|
||||||
|
/** Global permission to create service accounts.
|
||||||
|
*/
|
||||||
|
case object CreateServiceAccount extends Permission("create_service_account")
|
||||||
|
|
||||||
|
/** Permission to manage AI agent accounts.
|
||||||
|
*
|
||||||
|
* ## Scope
|
||||||
|
*
|
||||||
|
* - Global
|
||||||
|
*
|
||||||
|
* Users who create AI agent accounts always have permission to manage their
|
||||||
|
* own AI agent accounts.
|
||||||
|
*
|
||||||
|
* ## Capabilities
|
||||||
|
*
|
||||||
|
* - Deactivate agent accounts.
|
||||||
|
* - Revoke agent account credentials.
|
||||||
|
* - Provision new agent account credentials.
|
||||||
|
* - Assign permissions to agent accounts.
|
||||||
|
*
|
||||||
|
* Note: Only administrators can reactivate accounts.
|
||||||
|
*/
|
||||||
|
case object ManageAgentAccount extends Permission("manage_agent_account")
|
||||||
|
|
||||||
|
/** Global permission to create AI agent accounts.
|
||||||
|
*/
|
||||||
|
case object CreateAgentAccount extends Permission("create_agent_account")
|
||||||
|
|
||||||
|
/** Global permission to perform any action.
|
||||||
|
*/
|
||||||
|
case object Admin extends Permission("admin")
|
||||||
|
|
||||||
|
/** List of all valid permissions.
|
||||||
|
*/
|
||||||
|
val All: List[Permission] =
|
||||||
|
List(
|
||||||
|
GroupMember,
|
||||||
|
ManageGroup,
|
||||||
|
CreateGroup,
|
||||||
|
ManageUserAccount,
|
||||||
|
CreateUserAccount,
|
||||||
|
ManageServiceAccount,
|
||||||
|
CreateServiceAccount,
|
||||||
|
ManageAgentAccount,
|
||||||
|
CreateAgentAccount,
|
||||||
|
Admin
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Parse the given string as an [[Permission]].
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The string to parse.
|
||||||
|
* @return
|
||||||
|
* Some account status value, or `None` if the given string is not a valid
|
||||||
|
* account status.
|
||||||
|
*/
|
||||||
|
def parse(candidate: String): Option[Permission] =
|
||||||
|
All.find(_.name.equalsIgnoreCase(candidate))
|
||||||
|
|
||||||
|
end Permission
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package gs.smolban.model.account
|
||||||
|
|
||||||
|
import gs.smolban.model.group.GroupName
|
||||||
|
|
||||||
|
/** Defines the complete set of scoped [[Permission]] for some [[Account]].
|
||||||
|
*
|
||||||
|
* @param global
|
||||||
|
* The set of global permissions.
|
||||||
|
* @param group
|
||||||
|
* The set of permissions for each assigned group.
|
||||||
|
*/
|
||||||
|
case class PermissionSet(
|
||||||
|
global: Set[Permission],
|
||||||
|
group: Map[GroupName, Set[Permission]]
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package gs.smolban.model.group
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
import gs.smolban.model.account.AccountId
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
import gs.smolban.model.metadata.Title
|
||||||
|
|
||||||
|
/** Groups are the basic unit of organization in Smolban. Each [[Ticket]]
|
||||||
|
* belongs to a single `Group`.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique identifier for the group.
|
||||||
|
* @param name
|
||||||
|
* The unique name for the group. Used in URLs.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant at which this group was created.
|
||||||
|
* @param creator
|
||||||
|
* Uniquely identifies the [[Account]] which created this Group.
|
||||||
|
*/
|
||||||
|
case class Group(
|
||||||
|
name: GroupName,
|
||||||
|
title: Title,
|
||||||
|
description: String,
|
||||||
|
createdAt: CreatedAt,
|
||||||
|
creator: AccountId
|
||||||
|
)
|
||||||
|
|
||||||
|
object Group:
|
||||||
|
|
||||||
|
given CanEqual[Group, Group] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Group] = _.name.unwrap()
|
||||||
|
|
||||||
|
end Group
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package gs.smolban.model.group
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
import scala.util.matching.Regex
|
||||||
|
|
||||||
|
/** Unique name for a [[Group]]. This is an opaque type for a String.
|
||||||
|
*
|
||||||
|
* ## Requirements
|
||||||
|
*
|
||||||
|
* - Starts with a character `A-Z`
|
||||||
|
* - Only contains uppercase letters, numbers, and underscores.
|
||||||
|
* - At least 2 characters.
|
||||||
|
* - At most 80 characters.
|
||||||
|
*
|
||||||
|
* Regular Expression: `^[A-Z][A-Z0-9\_]{1,79}$`
|
||||||
|
*/
|
||||||
|
opaque type GroupName = String
|
||||||
|
|
||||||
|
object GroupName:
|
||||||
|
|
||||||
|
private val regex: Regex = """^[A-Z][A-Z0-9\_]{1,79}$""".r
|
||||||
|
|
||||||
|
/** Instantiate a new [[GroupName]].
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The underlying String.
|
||||||
|
* @return
|
||||||
|
* The new [[GroupName]] instance.
|
||||||
|
*/
|
||||||
|
def validate(name: String): Option[GroupName] =
|
||||||
|
if regex.matches(name) then Some(name) else None
|
||||||
|
|
||||||
|
given CanEqual[GroupName, GroupName] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[GroupName] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[GroupName] = gn => gn
|
||||||
|
|
||||||
|
extension (name: GroupName)
|
||||||
|
/** Unwrap this Group name.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The underlying String value.
|
||||||
|
*/
|
||||||
|
def unwrap(): String = name
|
||||||
|
|
||||||
|
end GroupName
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package gs.smolban.model.metadata
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
/** Describes an instant at which something was created. Opaque type for
|
||||||
|
* `java.time.Instant`.
|
||||||
|
*
|
||||||
|
* This type always truncates to milliseconds. This is the lowest common
|
||||||
|
* denominator for database representations (SQLite).
|
||||||
|
*/
|
||||||
|
opaque type CreatedAt = Instant
|
||||||
|
|
||||||
|
object CreatedAt:
|
||||||
|
|
||||||
|
/** Instantiate a new [[CreatedAt]].
|
||||||
|
*
|
||||||
|
* @param timestamp
|
||||||
|
* The underlying timestamp.
|
||||||
|
* @return
|
||||||
|
* The new instance.
|
||||||
|
*/
|
||||||
|
def apply(timestamp: Instant): CreatedAt =
|
||||||
|
timestamp.truncatedTo(ChronoUnit.MILLIS)
|
||||||
|
|
||||||
|
/** Instantiate a new [[CreatedAt]] representing the current instant.
|
||||||
|
*
|
||||||
|
* @param clock
|
||||||
|
* The clock used to calculate instants.
|
||||||
|
* @return
|
||||||
|
* The new [[CreatedAt]] instance.
|
||||||
|
*/
|
||||||
|
def now(clock: Clock): CreatedAt = apply(clock.instant())
|
||||||
|
|
||||||
|
/** Instantiate a new [[CreatedAt]].
|
||||||
|
*
|
||||||
|
* @param millis
|
||||||
|
* The epoch milliseconds describing the instant of this timestamp.
|
||||||
|
* @return
|
||||||
|
* The new instance.
|
||||||
|
*/
|
||||||
|
def fromMilliseconds(millis: Long): CreatedAt = Instant.ofEpochMilli(millis)
|
||||||
|
|
||||||
|
given CanEqual[CreatedAt, CreatedAt] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[CreatedAt] = _.toInstant().toString()
|
||||||
|
|
||||||
|
extension (createdAt: CreatedAt)
|
||||||
|
/** Unwrap this value.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The underlying `Instant` value.
|
||||||
|
*/
|
||||||
|
def unwrap(): Instant = createdAt
|
||||||
|
|
||||||
|
/** Unwrap this value.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The underlying `Instant` value.
|
||||||
|
*/
|
||||||
|
def toInstant(): Instant = createdAt
|
||||||
|
|
||||||
|
/** Express this instant as epoch milliseconds.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The epoch milliseconds.
|
||||||
|
*/
|
||||||
|
def toMilliseconds(): Long = createdAt.toEpochMilli()
|
||||||
|
|
||||||
|
end CreatedAt
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package gs.smolban.model.metadata
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Opaque type which represents any description. Descriptions are arbitrary
|
||||||
|
* strings.
|
||||||
|
*/
|
||||||
|
opaque type Description = String
|
||||||
|
|
||||||
|
object Description:
|
||||||
|
|
||||||
|
/** Instantiate a new description.
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
* The description text.
|
||||||
|
* @return
|
||||||
|
* The new [[Description]] instance.
|
||||||
|
*/
|
||||||
|
def apply(value: String): Description = value
|
||||||
|
|
||||||
|
given CanEqual[Description, Description] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[Description] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[Description] = t => t
|
||||||
|
|
||||||
|
extension (tag: Description)
|
||||||
|
/** @return
|
||||||
|
* The underlying string value.
|
||||||
|
*/
|
||||||
|
def unwrap(): String = tag
|
||||||
|
|
||||||
|
end Description
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package gs.smolban.model.metadata
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Represents a smolban "tag", or arbitrary string descriptor. This object
|
||||||
|
* captures additional information about the tag from the system perspective.
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
* The unique value of this tag.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant this tag was created.
|
||||||
|
*/
|
||||||
|
case class Tag(
|
||||||
|
value: TagValue,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
):
|
||||||
|
override def toString(): String = value.toString()
|
||||||
|
|
||||||
|
object Tag:
|
||||||
|
|
||||||
|
given CanEqual[Tag, Tag] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[TagValue] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[Tag] = _.value.unwrap()
|
||||||
|
|
||||||
|
end Tag
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package gs.smolban.model.metadata
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Opaque type which represents a [[Tag]] value. These values are non-empty,
|
||||||
|
* arbitrary strings.
|
||||||
|
*/
|
||||||
|
opaque type TagValue = String
|
||||||
|
|
||||||
|
object TagValue:
|
||||||
|
|
||||||
|
/** Validate the candidate string - TagValues must be non-empty.
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The candidate string.
|
||||||
|
* @return
|
||||||
|
* The valid value, or `None` if an empty string was given.
|
||||||
|
*/
|
||||||
|
def validate(candidate: String): Option[TagValue] =
|
||||||
|
if candidate.isEmpty() then None else Some(candidate)
|
||||||
|
|
||||||
|
def unsafe(candidate: String): TagValue =
|
||||||
|
if candidate.isEmpty() then
|
||||||
|
throw new IllegalArgumentException("Tag values must be non-empty.")
|
||||||
|
else candidate
|
||||||
|
|
||||||
|
given CanEqual[TagValue, TagValue] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[TagValue] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[TagValue] = t => t
|
||||||
|
|
||||||
|
extension (tag: TagValue)
|
||||||
|
/** @return
|
||||||
|
* The underlying string value.
|
||||||
|
*/
|
||||||
|
def unwrap(): String = tag
|
||||||
|
|
||||||
|
end TagValue
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package gs.smolban.model.metadata
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
import scala.util.matching.Regex
|
||||||
|
|
||||||
|
/** Opaque type which represents the title of some object. These values are
|
||||||
|
* non-empty, arbitrary strings, with at most 120 characters. They are intended
|
||||||
|
* for providing brief summaries of intent to users.
|
||||||
|
*/
|
||||||
|
opaque type Title = String
|
||||||
|
|
||||||
|
object Title:
|
||||||
|
private val regex: Regex = """^.{1,120}$""".r
|
||||||
|
|
||||||
|
/** Validate the candidate string. Titles may be a maximum of 120 characters.
|
||||||
|
* Title characters are not restricted.
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The candidate string.
|
||||||
|
* @return
|
||||||
|
* Some new [[Title]] instance, or `None` if the candidate is not valid.
|
||||||
|
*/
|
||||||
|
def validate(candidate: String): Option[Title] =
|
||||||
|
if regex.matches(candidate) then Some(candidate) else None
|
||||||
|
|
||||||
|
given CanEqual[Title, Title] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[Title] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[Title] = t => t
|
||||||
|
|
||||||
|
extension (tag: Title)
|
||||||
|
/** @return
|
||||||
|
* The underlying string value.
|
||||||
|
*/
|
||||||
|
def unwrap(): String = tag
|
||||||
|
|
||||||
|
end Title
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package gs.smolban.model.ticket
|
||||||
|
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
|
||||||
|
/** Some textual comment on a [[Ticket]]. Comments are arbitrary text that
|
||||||
|
* communicate information about a ticket from some actor.
|
||||||
|
*
|
||||||
|
* - Comments are ordered by when they were created.
|
||||||
|
* - Comments _may_ be edited.
|
||||||
|
* - Comments _may_ be deleted.
|
||||||
|
* - Comments have limited size.
|
||||||
|
* - Comments do not have reply / relationship capabilities.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* Globally unique identifier for this comment.
|
||||||
|
* @param content
|
||||||
|
* The content of the comment.
|
||||||
|
* @param createdAt
|
||||||
|
* Instant the comment was created.
|
||||||
|
*/
|
||||||
|
case class Comment(
|
||||||
|
id: CommentId,
|
||||||
|
content: CommentContent,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package gs.smolban.model.ticket
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
import scala.util.matching.Regex
|
||||||
|
|
||||||
|
/** Opaque type which represents the content of some [[Comment]]. These values
|
||||||
|
* are non-empty, arbitrary strings, with at most `8192` characters.
|
||||||
|
*
|
||||||
|
* This limitation may be customizable in the future.
|
||||||
|
*/
|
||||||
|
opaque type CommentContent = String
|
||||||
|
|
||||||
|
object CommentContent:
|
||||||
|
private val regex: Regex = """^.{1,8192}$""".r
|
||||||
|
|
||||||
|
/** Validate the candidate string. Comments may be a maximum of 8192
|
||||||
|
* characters. Characters are not restricted.
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The candidate string.
|
||||||
|
* @return
|
||||||
|
* Some new [[CommentContent]] instance, or `None` if the candidate is not
|
||||||
|
* valid.
|
||||||
|
*/
|
||||||
|
def validate(candidate: String): Option[CommentContent] =
|
||||||
|
if regex.matches(candidate) then Some(candidate) else None
|
||||||
|
|
||||||
|
given CanEqual[CommentContent, CommentContent] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[CommentContent] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[CommentContent] = cc => cc
|
||||||
|
|
||||||
|
extension (content: CommentContent)
|
||||||
|
/** @return
|
||||||
|
* The underlying string value.
|
||||||
|
*/
|
||||||
|
def unwrap(): String = content
|
||||||
|
|
||||||
|
end CommentContent
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package gs.smolban.model.ticket
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
import gs.uuid.v0.UUID
|
||||||
|
|
||||||
|
/** Uniquely identifies a _single semantic comment_ on a ticket.
|
||||||
|
*/
|
||||||
|
opaque type CommentId = UUID
|
||||||
|
|
||||||
|
object CommentId:
|
||||||
|
|
||||||
|
/** Instantiate a new [[CommentId]] from the given UUID.
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
* The UUID.
|
||||||
|
* @return
|
||||||
|
* The new [[CommentId]].
|
||||||
|
*/
|
||||||
|
def apply(value: UUID): CommentId = value
|
||||||
|
|
||||||
|
/** Generate a new [[CommentId]] using the UUIDv7 algorithm.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The new [[CommentId]].
|
||||||
|
*/
|
||||||
|
def generate(): CommentId = UUID.v7()
|
||||||
|
|
||||||
|
given CanEqual[CommentId, CommentId] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[CommentId] = _.unwrap().withoutDashes()
|
||||||
|
|
||||||
|
given Eq[CommentId] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
extension (commentId: CommentId)
|
||||||
|
/** @return
|
||||||
|
* The underlying UUID value.
|
||||||
|
*/
|
||||||
|
def unwrap(): UUID = commentId
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The underlying UUID value.
|
||||||
|
*/
|
||||||
|
def toUUID(): UUID = commentId
|
||||||
|
|
||||||
|
end CommentId
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package gs.smolban.model.ticket
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import gs.smolban.model.account.AccountId
|
||||||
|
import gs.smolban.model.group.GroupName
|
||||||
|
import gs.smolban.model.metadata.CreatedAt
|
||||||
|
import gs.smolban.model.metadata.Description
|
||||||
|
import gs.smolban.model.metadata.TagValue
|
||||||
|
import gs.smolban.model.metadata.Title
|
||||||
|
import java.util.Objects
|
||||||
|
|
||||||
|
/** Tickets represent some tracked work.
|
||||||
|
*
|
||||||
|
* TODO: Comments, Attachments, Watching
|
||||||
|
*
|
||||||
|
* @param number
|
||||||
|
* Unique number _within_ the [[Group]] Typically ascending integers.
|
||||||
|
* @param group
|
||||||
|
* Unique identifier of the [[Group]] that owns this ticket.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant at which this ticket was created.
|
||||||
|
* @param title
|
||||||
|
* Arbitrary string title of the ticket.
|
||||||
|
* @param description
|
||||||
|
* Markdown contents of the ticket.
|
||||||
|
* @param tags
|
||||||
|
* List of [[Tag]] applied to this ticket.
|
||||||
|
* @param status
|
||||||
|
* Current [[Ticket.Status]] of this ticket.
|
||||||
|
* @param creator
|
||||||
|
* Uniquely identifes the [[Account]] which created this ticket.
|
||||||
|
* @param creator
|
||||||
|
* Uniquely identifes the [[Account]] to which this ticket is assigned.
|
||||||
|
*/
|
||||||
|
case class Ticket(
|
||||||
|
number: TicketNumber,
|
||||||
|
group: GroupName,
|
||||||
|
createdAt: CreatedAt,
|
||||||
|
title: Title,
|
||||||
|
description: Description,
|
||||||
|
tags: List[TagValue],
|
||||||
|
status: TicketStatus,
|
||||||
|
creator: AccountId,
|
||||||
|
assignee: Option[AccountId]
|
||||||
|
):
|
||||||
|
lazy val reference: TicketReference = TicketReference(group, number)
|
||||||
|
|
||||||
|
override def toString(): String = s"${group.show}-${number.show}"
|
||||||
|
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: Ticket => (group == other.group) && (number == other.number)
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
override def hashCode(): Int = Objects.hash(group, number)
|
||||||
|
|
||||||
|
object Ticket:
|
||||||
|
|
||||||
|
given CanEqual[Ticket, Ticket] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Ticket] = _.toString()
|
||||||
|
|
||||||
|
given Eq[Ticket] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
end Ticket
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package gs.smolban.model.ticket
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Unique identifier - relative to some [[gs.smolban.model.group.Group]] - for
|
||||||
|
* a [[Ticket]]. This is an opaque type for a `Long`. In general, [[Ticket]]
|
||||||
|
* identifiers are sequences within a group.
|
||||||
|
*/
|
||||||
|
opaque type TicketNumber = Long
|
||||||
|
|
||||||
|
object TicketNumber:
|
||||||
|
|
||||||
|
/** Instantiate a new [[TicketNumber]].
|
||||||
|
*
|
||||||
|
* @param number
|
||||||
|
* The underlying Long.
|
||||||
|
* @return
|
||||||
|
* The new [[TicketNumber]] instance.
|
||||||
|
*/
|
||||||
|
def apply(number: Long): TicketNumber = number
|
||||||
|
|
||||||
|
given CanEqual[TicketNumber, TicketNumber] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[TicketNumber] = _.toString()
|
||||||
|
|
||||||
|
extension (number: TicketNumber)
|
||||||
|
/** Unwrap this Ticket ID.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The underlying Long value.
|
||||||
|
*/
|
||||||
|
def unwrap(): Long = number
|
||||||
|
|
||||||
|
/** Unwrap this Ticket ID.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The underlying Long value.
|
||||||
|
*/
|
||||||
|
def toLong(): Long = number
|
||||||
|
|
||||||
|
end TicketNumber
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package gs.smolban.model.ticket
|
||||||
|
|
||||||
|
import gs.smolban.model.group.GroupName
|
||||||
|
|
||||||
|
/** Composite reference that uniquely addresses any [[Ticket]].
|
||||||
|
*
|
||||||
|
* @param number
|
||||||
|
* The ticket's unique identifier within the [[Group]].
|
||||||
|
* @param group
|
||||||
|
* The unique identifier of the [[Group]].
|
||||||
|
*/
|
||||||
|
case class TicketReference(
|
||||||
|
group: GroupName,
|
||||||
|
number: TicketNumber
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package gs.smolban.model.ticket
|
||||||
|
|
||||||
|
import cats.Eq
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Enumeration that describes the status of a [[Ticket]] in Smolban.
|
||||||
|
*/
|
||||||
|
sealed abstract class TicketStatus(val name: String):
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def equals(obj: Any): Boolean =
|
||||||
|
obj match
|
||||||
|
case other: TicketStatus => name == other.name
|
||||||
|
case _ => false
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def toString(): String = name
|
||||||
|
|
||||||
|
/** @inheritDocs
|
||||||
|
*/
|
||||||
|
override def hashCode(): Int = name.hashCode()
|
||||||
|
|
||||||
|
object TicketStatus:
|
||||||
|
|
||||||
|
given CanEqual[TicketStatus, TicketStatus] = CanEqual.derived
|
||||||
|
|
||||||
|
given Eq[TicketStatus] = (
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
) => x == y
|
||||||
|
|
||||||
|
given Show[TicketStatus] = _.name
|
||||||
|
|
||||||
|
/** This ticket is not currently active.
|
||||||
|
*/
|
||||||
|
case object Inactive extends TicketStatus("inactive")
|
||||||
|
|
||||||
|
/** This ticket is being worked on actively. In progress tickets can be move
|
||||||
|
* to inactive, completed, or canceled.
|
||||||
|
*/
|
||||||
|
case object InProgress extends TicketStatus("in_progress")
|
||||||
|
|
||||||
|
/** This ticket was driven to completion. The work is done. Once in this
|
||||||
|
* state, the status may no longer change.
|
||||||
|
*/
|
||||||
|
case object Complete extends TicketStatus("complete")
|
||||||
|
|
||||||
|
/** This ticket was canceled for some reason. Once in this state, the status
|
||||||
|
* may no longer change.
|
||||||
|
*/
|
||||||
|
case object Canceled extends TicketStatus("canceled")
|
||||||
|
|
||||||
|
/** List of all valid status values.
|
||||||
|
*/
|
||||||
|
val All: List[TicketStatus] =
|
||||||
|
List(Inactive, InProgress, Complete, Canceled)
|
||||||
|
|
||||||
|
/** Parse the given string as a status.
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The string to parse.
|
||||||
|
* @return
|
||||||
|
* Some status value, or `None` if the given string is not a valid status.
|
||||||
|
*/
|
||||||
|
def parse(candidate: String): Option[TicketStatus] =
|
||||||
|
All.find(_.name.equalsIgnoreCase(candidate))
|
||||||
|
|
||||||
|
end TicketStatus
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
package gs.smolban.model.users
|
|
||||||
|
|
||||||
/** Roles define what each [[User]] is allowed to do.
|
|
||||||
*
|
|
||||||
* @param name
|
|
||||||
* The name of the role.
|
|
||||||
* @param scope
|
|
||||||
* The [[Scope]] to which the role applies.
|
|
||||||
*/
|
|
||||||
sealed abstract class Role(
|
|
||||||
val name: String,
|
|
||||||
val scope: Scope
|
|
||||||
):
|
|
||||||
|
|
||||||
override def toString(): String =
|
|
||||||
s"[$name]${Role.Delimiter}$scope"
|
|
||||||
|
|
||||||
object Role:
|
|
||||||
|
|
||||||
val Delimiter: String = ":"
|
|
||||||
|
|
||||||
/** Administrator for the API. Can perform any operation through the API.
|
|
||||||
*/
|
|
||||||
case object ApiAdmin extends Role(s"api${Delimiter}admin", Scope.ApiGlobal)
|
|
||||||
|
|
||||||
/** Global read access to the API.
|
|
||||||
*/
|
|
||||||
case object ApiGlobalReader
|
|
||||||
extends Role(s"api${Delimiter}reader", Scope.ApiGlobal)
|
|
||||||
|
|
||||||
/** Global write access to the API.
|
|
||||||
*/
|
|
||||||
case object ApiGlobalWriter
|
|
||||||
extends Role(s"api${Delimiter}writer", Scope.ApiGlobal)
|
|
||||||
|
|
||||||
/** Administrator for a specific [[gs.smolban.model.Group]] via the API. Can
|
|
||||||
* perform any operation through the API for the indicated group.
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* The unique identifier for the group to which this role is scoped.
|
|
||||||
*/
|
|
||||||
case class ApiGroupAdmin(
|
|
||||||
groupId: gs.smolban.model.Group.Id
|
|
||||||
) extends Role(s"api${Delimiter}group_admin", Scope.ApiGroup(groupId))
|
|
||||||
|
|
||||||
/** Grants API read access for a specific [[gs.smolban.model.Group]].
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* The unique identifier for the group to which this role is scoped.
|
|
||||||
*/
|
|
||||||
case class ApiGroupReader(
|
|
||||||
groupId: gs.smolban.model.Group.Id
|
|
||||||
) extends Role(s"api${Delimiter}group_reader", Scope.ApiGroup(groupId))
|
|
||||||
|
|
||||||
/** Grants API write access for a specific [[gs.smolban.model.Group]].
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* The unique identifier for the group to which this role is scoped.
|
|
||||||
*/
|
|
||||||
case class ApiGroupWriter(
|
|
||||||
groupId: gs.smolban.model.Group.Id
|
|
||||||
) extends Role(s"api${Delimiter}group_writer", Scope.ApiGroup(groupId))
|
|
||||||
|
|
||||||
/** Administrator for the UI. Can perform any operation through the UI.
|
|
||||||
*/
|
|
||||||
case object UiAdmin extends Role(s"ui${Delimiter}admin", Scope.UiGlobal)
|
|
||||||
|
|
||||||
/** Global read access to the UI.
|
|
||||||
*/
|
|
||||||
case object UiGlobalReader extends Role("ui:reader", Scope.UiGlobal)
|
|
||||||
|
|
||||||
/** Global write access to the UI.
|
|
||||||
*/
|
|
||||||
case object UiGlobalWriter extends Role("ui:writer", Scope.UiGlobal)
|
|
||||||
|
|
||||||
/** Administrator for a specific [[gs.smolban.model.Group]] via the UI. Can
|
|
||||||
* perform any operation through the UI for the indicated group.
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* The unique identifier for the group to which this role is scoped.
|
|
||||||
*/
|
|
||||||
case class UiGroupAdmin(
|
|
||||||
groupId: gs.smolban.model.Group.Id
|
|
||||||
) extends Role(s"ui${Delimiter}group_admin", Scope.UiGroup(groupId))
|
|
||||||
|
|
||||||
/** Grants UI read access for a specific [[gs.smolban.model.Group]].
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* The unique identifier for the group to which this role is scoped.
|
|
||||||
*/
|
|
||||||
case class UiGroupReader(
|
|
||||||
groupId: gs.smolban.model.Group.Id
|
|
||||||
) extends Role(s"ui${Delimiter}group_reader", Scope.UiGroup(groupId))
|
|
||||||
|
|
||||||
/** Grants UI write access for a specific [[gs.smolban.model.Group]].
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* The unique identifier for the group to which this role is scoped.
|
|
||||||
*/
|
|
||||||
case class UiGroupWriter(
|
|
||||||
groupId: gs.smolban.model.Group.Id
|
|
||||||
) extends Role(s"ui${Delimiter}group_writer", Scope.UiGroup(groupId))
|
|
||||||
|
|
||||||
end Role
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
package gs.smolban.model.users
|
|
||||||
|
|
||||||
import cats.syntax.all.*
|
|
||||||
|
|
||||||
/** Describes a _Scope_, or service of Smolban, to which a user can have access.
|
|
||||||
*
|
|
||||||
* Scopes consist of two parts:
|
|
||||||
*
|
|
||||||
* - `service`: The name of a service Smolban offers.
|
|
||||||
* - `name`: The name of the scope within the given service.
|
|
||||||
*
|
|
||||||
* @param service
|
|
||||||
* The name of a service Smolban offers (e.g. `api`, `ui`)
|
|
||||||
* @param name
|
|
||||||
* The name of the scope within the service.
|
|
||||||
*/
|
|
||||||
sealed abstract class Scope(
|
|
||||||
val service: String,
|
|
||||||
val name: String
|
|
||||||
)
|
|
||||||
|
|
||||||
object Scope:
|
|
||||||
|
|
||||||
given CanEqual[Scope, Scope] = CanEqual.derived
|
|
||||||
|
|
||||||
object Services:
|
|
||||||
|
|
||||||
val Api: String = "api"
|
|
||||||
val Ui: String = "ui"
|
|
||||||
|
|
||||||
end Services
|
|
||||||
|
|
||||||
object Names:
|
|
||||||
|
|
||||||
val Global: String = "global"
|
|
||||||
val Group: String = "group"
|
|
||||||
|
|
||||||
end Names
|
|
||||||
|
|
||||||
val Delimiter: String = ":"
|
|
||||||
|
|
||||||
/** The `api:global` scope covers the Smolban API across all groups.
|
|
||||||
*/
|
|
||||||
case object ApiGlobal extends Scope(Services.Api, Names.Global):
|
|
||||||
override def toString(): String = s"[$service$Delimiter$name]"
|
|
||||||
|
|
||||||
/** The `api:group:<id>` scope refers the the Smolban API for a specific
|
|
||||||
* [[Group]].
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* The unique identifier of the [[Group]].
|
|
||||||
*/
|
|
||||||
case class ApiGroup(
|
|
||||||
groupId: gs.smolban.model.Group.Id
|
|
||||||
) extends Scope(Services.Api, Names.Group):
|
|
||||||
|
|
||||||
override def toString(): String =
|
|
||||||
s"[$service$Delimiter$name$Delimiter${groupId.show}]"
|
|
||||||
|
|
||||||
/** The `ui:global` scope covers the Smolban UI across all groups.
|
|
||||||
*/
|
|
||||||
case object UiGlobal extends Scope(Services.Ui, Names.Global):
|
|
||||||
override def toString(): String = s"[$service$Delimiter$name]"
|
|
||||||
|
|
||||||
/** The `ui:group:<id>` scope refers the the Smolban UI for a specific
|
|
||||||
* [[Group]].
|
|
||||||
*
|
|
||||||
* @param groupId
|
|
||||||
* The unique identifier of the [[Group]].
|
|
||||||
*/
|
|
||||||
case class UiGroup(
|
|
||||||
groupId: gs.smolban.model.Group.Id
|
|
||||||
) extends Scope(Services.Ui, Names.Group):
|
|
||||||
|
|
||||||
override def toString(): String =
|
|
||||||
s"[$service$Delimiter$name$Delimiter${groupId.show}]"
|
|
||||||
|
|
||||||
end Scope
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
package gs.smolban.model.users
|
|
||||||
|
|
||||||
import cats.Show
|
|
||||||
import gs.smolban.model.CreatedAt
|
|
||||||
import gs.uuid.v0.UUID
|
|
||||||
|
|
||||||
/** Represents a user that can interact with Smolban.
|
|
||||||
*
|
|
||||||
* @param id
|
|
||||||
* Unique identifier of this user.
|
|
||||||
* @param createdAt
|
|
||||||
* The instant at which this user was created.
|
|
||||||
* @param username
|
|
||||||
* The (unique) [[Username]] of this user.
|
|
||||||
* @param designation
|
|
||||||
* The [[User.Designation]] of this user.
|
|
||||||
* @param roles
|
|
||||||
* List of [[Role]] assigned to this user.
|
|
||||||
* @param status
|
|
||||||
* The current [[User.Status]] of this user.
|
|
||||||
*/
|
|
||||||
case class User(
|
|
||||||
id: User.Id,
|
|
||||||
createdAt: CreatedAt,
|
|
||||||
username: Username,
|
|
||||||
designation: User.Designation,
|
|
||||||
roles: List[Role],
|
|
||||||
status: User.Status
|
|
||||||
)
|
|
||||||
|
|
||||||
object User:
|
|
||||||
|
|
||||||
given CanEqual[User, User] = CanEqual.derived
|
|
||||||
|
|
||||||
/** Unique identifier for a [[User]]. This is an opaque type for a UUID.
|
|
||||||
*/
|
|
||||||
opaque type Id = UUID
|
|
||||||
|
|
||||||
object Id:
|
|
||||||
|
|
||||||
/** Instantiate a new [[User.Id]].
|
|
||||||
*
|
|
||||||
* @param id
|
|
||||||
* The underlying UUID.
|
|
||||||
* @return
|
|
||||||
* The new [[User.Id]] instance.
|
|
||||||
*/
|
|
||||||
def apply(id: UUID): Id = id
|
|
||||||
|
|
||||||
given CanEqual[Id, Id] = CanEqual.derived
|
|
||||||
|
|
||||||
given Show[Id] = _.toUUID().withoutDashes()
|
|
||||||
|
|
||||||
extension (id: Id)
|
|
||||||
/** Unwrap this User ID.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The underlying UUID value.
|
|
||||||
*/
|
|
||||||
def toUUID(): UUID = id
|
|
||||||
|
|
||||||
end Id
|
|
||||||
|
|
||||||
/** Enumeration that describes the designation of a [[User]].
|
|
||||||
*/
|
|
||||||
sealed abstract class Designation(val name: String)
|
|
||||||
|
|
||||||
object Designation:
|
|
||||||
|
|
||||||
/** Regular users are typically human, and are expected to be using the Web
|
|
||||||
* UI and _possibly_ APIs.
|
|
||||||
*/
|
|
||||||
case object Regular extends Designation("regular")
|
|
||||||
|
|
||||||
/** Service users are intended for use by API-consuming services. They are
|
|
||||||
* not suitable for human/UI interactive use.
|
|
||||||
*/
|
|
||||||
case object Service extends Designation("service")
|
|
||||||
|
|
||||||
end Designation
|
|
||||||
|
|
||||||
/** Enumeration that describes the status of a [[User]] in Smolban.
|
|
||||||
*/
|
|
||||||
sealed abstract class Status(val name: String)
|
|
||||||
|
|
||||||
object Status:
|
|
||||||
|
|
||||||
given CanEqual[Status, Status] = CanEqual.derived
|
|
||||||
|
|
||||||
given Show[Status] = _.name
|
|
||||||
|
|
||||||
case object Active extends Status("active")
|
|
||||||
case object Suspended extends Status("suspended")
|
|
||||||
case object Off extends Status("off")
|
|
||||||
|
|
||||||
end User
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
package gs.smolban.model.users
|
|
||||||
|
|
||||||
import cats.Show
|
|
||||||
|
|
||||||
/** Opaque type for String that represents a unique username in Smolban.
|
|
||||||
*/
|
|
||||||
opaque type Username = String
|
|
||||||
|
|
||||||
object Username:
|
|
||||||
|
|
||||||
/** In Smolban, a [[Username]] must be at most 32 characters long.
|
|
||||||
*/
|
|
||||||
val MaximumLength: Int = 32
|
|
||||||
|
|
||||||
/** In Smolban, a [[Username]] must be at least 3 characters long.
|
|
||||||
*/
|
|
||||||
val MinimumLength: Int = 3
|
|
||||||
|
|
||||||
given CanEqual[Username, Username] = CanEqual.derived
|
|
||||||
|
|
||||||
/** Validate some candidate string, producing a [[Username]] if valid. Smolban
|
|
||||||
* usernames must be:
|
|
||||||
*
|
|
||||||
* - At least 3 characters long.
|
|
||||||
* - At most 32 characters long.
|
|
||||||
* - Non-blank -- non-whitespace characters must be used.
|
|
||||||
*
|
|
||||||
* @param candidate
|
|
||||||
* The candidate string to evaluate.
|
|
||||||
* @return
|
|
||||||
* The [[Username]], or `None` if the candidate was invalid.
|
|
||||||
*/
|
|
||||||
def validate(candidate: String): Option[Username] =
|
|
||||||
if isValid(candidate) then Some(candidate) else None
|
|
||||||
|
|
||||||
private def isValid(candidate: String): Boolean =
|
|
||||||
isValidSize(candidate) && isNonBlank(candidate)
|
|
||||||
|
|
||||||
private def isValidSize(candidate: String): Boolean =
|
|
||||||
candidate.length() >= MinimumLength && candidate.length() <= MaximumLength
|
|
||||||
|
|
||||||
private def isNonBlank(candidate: String): Boolean =
|
|
||||||
!candidate.isBlank()
|
|
||||||
|
|
||||||
given Show[Username] = u => u
|
|
||||||
|
|
||||||
end Username
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
sbt.version=1.10.0
|
sbt.version=1.12.1
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,6 @@ externalResolvers := Seq(
|
||||||
"Garrity Software Releases" at "https://maven.garrity.co/gs"
|
"Garrity Software Releases" at "https://maven.garrity.co/gs"
|
||||||
)
|
)
|
||||||
|
|
||||||
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")
|
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.4.4")
|
||||||
addSbtPlugin("gs" % "sbt-garrity-software" % "0.3.0")
|
addSbtPlugin("gs" % "sbt-garrity-software" % "0.7.0")
|
||||||
addSbtPlugin("gs" % "sbt-gs-calver" % "0.2.0")
|
addSbtPlugin("gs" % "sbt-gs-calver" % "0.2.0")
|
||||||
|
|
|
||||||
8
sql/README.md
Normal file
8
sql/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Smolban SQL
|
||||||
|
|
||||||
|
This directory contains the _reference SQL_ for Smolban. Smolban supports both
|
||||||
|
SQLite and PostgreSQL.
|
||||||
|
|
||||||
|
For implementations that provision and migrate the database:
|
||||||
|
|
||||||
|
- [DoobieSqliteDb](../modules/db/src/main/scala/gs/smolban/db/doobie/DoobieSqliteDb.scala)
|
||||||
97
sql/sqlite/1.sql
Normal file
97
sql/sqlite/1.sql
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS user(
|
||||||
|
id BLOB NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_status_created_at ON user(status, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permission_global(
|
||||||
|
user_id BLOB NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permission_group(
|
||||||
|
user_id BLOB NOT NULL,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, group_name, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS service_account(
|
||||||
|
id BLOB NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
owner BLOB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_service_account_status_created_at ON service_account(status, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX idx_service_account_owner ON service_account(owner);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS service_account_permission_global(
|
||||||
|
service_account_id BLOB NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (service_account_id, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS service_account_permission_group(
|
||||||
|
service_account_id BLOB NOT NULL,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (service_account_id, group_name, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_account(
|
||||||
|
id BLOB NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
owner BLOB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_agent_account_status_created_at ON agent_account(status, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX idx_agent_account_owner ON agent_account(owner);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_account_permission_global(
|
||||||
|
agent_account_id BLOB NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (agent_account_id, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_account_permission_group(
|
||||||
|
agent_account_id BLOB NOT NULL,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (agent_account_id, group_name, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS credential(
|
||||||
|
credential_id BLOB NOT NULL PRIMARY KEY,
|
||||||
|
credential_hash TEXT NOT NULL,
|
||||||
|
account_id BLOB NOT NULL,
|
||||||
|
account_type TEXT NOT NULL,
|
||||||
|
credential_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
effective_at DATE NULL,
|
||||||
|
effective_through DATE NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_credential_account_id ON credential(account_id, created_at);
|
||||||
|
CREATE INDEX idx_credential_account_type ON credential(account_type, created_at);
|
||||||
|
CREATE INDEX idx_credential_credential_type ON credential(credential_type, created_at);
|
||||||
|
CREATE INDEX idx_credential_credential_status ON credential(credential_status, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tag(
|
||||||
|
tag_value TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tag_created_at ON tag(created_at);
|
||||||
6
test/README.md
Normal file
6
test/README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Test Data
|
||||||
|
|
||||||
|
This directory provides a dumping ground for test data that is produced by tests
|
||||||
|
while running -- for example, SQLite databases.
|
||||||
|
|
||||||
|
All files (aside from this file) in this directory are ignored.
|
||||||
Loading…
Add table
Reference in a new issue