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/project/
|
||||
.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 / gsProjectName := "smolban"
|
||||
|
|
@ -18,39 +18,54 @@ lazy val sharedSettings = Seq(
|
|||
|
||||
val Deps = new {
|
||||
val Cats = new {
|
||||
val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.10.0"
|
||||
val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.5.4"
|
||||
val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0"
|
||||
val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.6.3"
|
||||
}
|
||||
|
||||
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 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 Core: ModuleID = "org.http4s" %% "http4s-core" % "1.0.0-M41"
|
||||
val Dsl: ModuleID = "org.http4s" %% "http4s-dsl" % "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-M45"
|
||||
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 Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.2.4"
|
||||
val Slug: ModuleID = "gs" %% "gs-slug-v0" % "0.1.3"
|
||||
val Config: ModuleID = "gs" %% "gs-config-v0" % "0.1.1"
|
||||
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.1.1"
|
||||
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.2"
|
||||
val Config: ModuleID = "gs" %% "gs-config-v0" % "0.2.0"
|
||||
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.4.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(
|
||||
libraryDependencies ++= Seq(
|
||||
Deps.MUnit % Test,
|
||||
Deps.Gs.Datagen % Test
|
||||
Deps.Gs.Datagen % Test,
|
||||
Deps.Slf4j.Nop % Test
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -68,24 +83,42 @@ lazy val model = project
|
|||
.settings(
|
||||
libraryDependencies ++= Seq(
|
||||
Deps.Gs.Uuid,
|
||||
Deps.Gs.Slug,
|
||||
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
|
||||
.in(file("modules/db"))
|
||||
.dependsOn(model, auth)
|
||||
.settings(sharedSettings)
|
||||
.settings(testSettings)
|
||||
.settings(name := s"${gsProjectName.value}-db")
|
||||
.settings(
|
||||
libraryDependencies ++= Seq(
|
||||
Deps.Doobie.Core
|
||||
Deps.Doobie.Core,
|
||||
Deps.Doobie.Hikari,
|
||||
Deps.Doobie.Postgres,
|
||||
Deps.JdbcDriver.Sqlite,
|
||||
Deps.JdbcDriver.PostgreSQL
|
||||
)
|
||||
)
|
||||
|
||||
lazy val api = project
|
||||
.in(file("modules/api"))
|
||||
.dependsOn(model, auth, db)
|
||||
.settings(sharedSettings)
|
||||
.settings(testSettings)
|
||||
.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"
|
||||
)
|
||||
|
||||
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")
|
||||
addSbtPlugin("gs" % "sbt-garrity-software" % "0.3.0")
|
||||
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.4.4")
|
||||
addSbtPlugin("gs" % "sbt-garrity-software" % "0.7.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