Compare commits

..

17 commits

72 changed files with 4077 additions and 601 deletions

4
.gitignore vendored
View file

@ -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

View file

@ -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")

View 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

View 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

View 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

View 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

View file

@ -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

View 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
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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
)

View 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

View file

@ -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

View 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

View file

@ -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))

View file

@ -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

View file

@ -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))

View 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-----

View 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-----

View file

@ -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)
}

View 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

View 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]

View 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

View 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]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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]

View file

@ -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())

View file

@ -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

View 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]
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
package gs.smolban.model
/** The parent trait for all errors in Smolban.
*/
trait SmolbanError

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]]
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1 +1 @@
sbt.version=1.10.0
sbt.version=1.12.1

View file

@ -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
View 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
View 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
View 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.