WIP: Starting on DB for real

This commit is contained in:
Pat Garrity 2026-01-31 18:11:27 -06:00
parent a7e2185204
commit f25c9658eb
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
15 changed files with 612 additions and 59 deletions

View file

@ -90,7 +90,7 @@ lazy val auth = project
lazy val db = project
.in(file("modules/db"))
.dependsOn(model)
.dependsOn(model, auth)
.settings(sharedSettings)
.settings(testSettings)
.settings(name := s"${gsProjectName.value}-db")
@ -102,7 +102,7 @@ lazy val db = project
lazy val api = project
.in(file("modules/api"))
.dependsOn(model, db)
.dependsOn(model, auth, db)
.settings(sharedSettings)
.settings(testSettings)
.settings(name := s"${gsProjectName.value}-api")

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,29 @@
package gs.smolban.auth
import gs.smolban.model.account.AccountId
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 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,
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,203 @@
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
/** Database interface for [[Account]] and related management activities.
*/
trait AuthDb[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
): F[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
): EitherT[F, 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[F, DbError, User]
/** @return
* List of all active [[User]].
*/
def listActiveUsers(): F[List[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
): F[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[F, DbError, NewServiceAccount]
/** @return
* List of all active [[ServiceAccount]].
*/
def listActiveServiceAccounts(): F[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
): F[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[F, DbError, NewAiAgentAccount]
/** @return
* List of all active [[AiAgent]].
*/
def listActiveAgentAccounts(): F[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[F, 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[F, 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
): F[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[F, 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[F, DbError, Unit]

View file

@ -1,10 +1,10 @@
package gs.smolban.db
import gs.slug.v0.Slug
import gs.smolban.model.Group
import gs.smolban.auth.CredentialId
import gs.smolban.model.SmolbanError
import gs.smolban.model.Tag
import gs.smolban.model.Ticket
import gs.smolban.model.account.AccountId
import gs.smolban.model.account.AccountName
import gs.smolban.model.account.AccountType
/** Parent type of all database errors in Smolban.
*/
@ -12,23 +12,63 @@ sealed trait DbError extends SmolbanError
object DbError:
/** Returned if an attempt is made to insert a [[Ticket]], where the Ticket ID
* already exists for the specified [[gs.smolban.model.Group]].
/** Produced when creating any new account fails because an account with the
* same name already exists.
*
* @param ref
* The Ticket Reference that was in conflict.
* 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 TicketAlreadyExists(ref: Ticket.Reference) extends DbError
case class GroupAlreadyExists(
id: Group.Id,
slug: Slug
case class AccountAlreadyExists(
candidateAccountName: AccountName,
accountType: AccountType
) extends DbError
case class TagAlreadyExists(
value: Tag.Value
/** 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
case class SilentInsertFailure(context: String) 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

@ -2,37 +2,32 @@ package gs.smolban.db.doobie
import DoobieTypes.ErrorMessages
import doobie.*
import gs.slug.v0.Slug
import gs.smolban.model.CreatedAt
import gs.smolban.model.Group
import gs.smolban.model.Tag
import gs.smolban.model.Ticket
import gs.smolban.model.metadata.CreatedAt
import gs.smolban.model.metadata.Tag
import gs.smolban.model.metadata.TagValue
import gs.smolban.model.ticket.CommentId
import gs.smolban.model.ticket.Ticket
import gs.smolban.model.ticket.TicketStatus
import gs.uuid.v0.UUID
import gs.uuid.v0.UUIDFormat
import java.time.Instant
trait DoobieTypes:
implicit val ticketNumberGet: Get[Ticket.Number] =
Get[Long].tmap(Ticket.Number(_))
implicit val ticketNumberPut: Put[Ticket.Number] =
Put[Long].tcontramap(_.toLong())
implicit val ticketStatusGet: Get[Ticket.Status] =
implicit val ticketStatusGet: Get[TicketStatus] =
Get[String].temap(dbValue =>
Ticket.Status.parse(dbValue) match
TicketStatus.parse(dbValue) match
case None => Left(ErrorMessages.invalidTicketStatus(dbValue))
case Some(status) => Right(status)
)
implicit val ticketStatusPut: Put[Ticket.Status] =
implicit val ticketStatusPut: Put[TicketStatus] =
Put[String].tcontramap(_.name)
implicit val groupIdGet: Get[Group.Id] =
Get[Array[Byte]].tmap(bytes => Group.Id(UUID(UUIDFormat.fromBytes(bytes))))
implicit val commentIdGet: Get[CommentId] =
Get[Array[Byte]].tmap(bytes => CommentId(UUID(UUIDFormat.fromBytes(bytes))))
implicit val groupIdPut: Put[Group.Id] =
implicit val commentIdPut: Put[CommentId] =
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
implicit val createdAtGet: Get[CreatedAt] =
@ -47,30 +42,14 @@ trait DoobieTypes:
implicit val instantPut: Put[Instant] =
Put[Long].tcontramap(_.toEpochMilli())
implicit val slugGet: Get[Slug] =
Get[String].temap { slug =>
Slug.validate(slug) match
case None => Left(ErrorMessages.invalidSlug(slug))
case Some(s) => Right(s)
}
implicit val slugPut: Put[Slug] =
Put[String].tcontramap(_.str())
implicit val tagIdGet: Get[Tag.Id] =
Get[Long].tmap(Tag.Id(_))
implicit val tagIdPut: Put[Tag.Id] =
Put[Long].tcontramap(_.toLong())
implicit val tagValueGet: Get[Tag.Value] =
implicit val tagValueGet: Get[TagValue] =
Get[String].temap { value =>
Tag.Value.validate(value) match
TagValue.validate(value) match
case None => Left(ErrorMessages.invalidTagValue(value))
case Some(s) => Right(s)
}
implicit val tagValuePut: Put[Tag.Value] =
implicit val tagValuePut: Put[TagValue] =
Put[String].tcontramap(_.toString())
object DoobieTypes extends DoobieTypes:
@ -83,9 +62,6 @@ object DoobieTypes extends DoobieTypes:
def invalidTicketStatus(candidate: String): String =
s"'$candidate' is not a valid ticket status."
def invalidSlug(candidate: String): String =
s"'$candidate' is not a valid Group slug."
end ErrorMessages
end DoobieTypes

View file

@ -8,8 +8,6 @@ import gs.smolban.model.metadata.Title
/** Groups are the basic unit of organization in Smolban. Each [[Ticket]]
* belongs to a single `Group`.
*
* TODO: Ownership. Members.
*
* @param id
* The unique identifier for the group.
* @param name