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 lazy val db = project
.in(file("modules/db")) .in(file("modules/db"))
.dependsOn(model) .dependsOn(model, auth)
.settings(sharedSettings) .settings(sharedSettings)
.settings(testSettings) .settings(testSettings)
.settings(name := s"${gsProjectName.value}-db") .settings(name := s"${gsProjectName.value}-db")
@ -102,7 +102,7 @@ lazy val db = project
lazy val api = project lazy val api = project
.in(file("modules/api")) .in(file("modules/api"))
.dependsOn(model, db) .dependsOn(model, auth, db)
.settings(sharedSettings) .settings(sharedSettings)
.settings(testSettings) .settings(testSettings)
.settings(name := s"${gsProjectName.value}-api") .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 package gs.smolban.db
import gs.slug.v0.Slug import gs.smolban.auth.CredentialId
import gs.smolban.model.Group
import gs.smolban.model.SmolbanError import gs.smolban.model.SmolbanError
import gs.smolban.model.Tag import gs.smolban.model.account.AccountId
import gs.smolban.model.Ticket import gs.smolban.model.account.AccountName
import gs.smolban.model.account.AccountType
/** Parent type of all database errors in Smolban. /** Parent type of all database errors in Smolban.
*/ */
@ -12,23 +12,63 @@ sealed trait DbError extends SmolbanError
object DbError: object DbError:
/** Returned if an attempt is made to insert a [[Ticket]], where the Ticket ID /** Produced when creating any new account fails because an account with the
* already exists for the specified [[gs.smolban.model.Group]]. * same name already exists.
* *
* @param ref * Note: this error exists for internal logging, but the user-facing error
* The Ticket Reference that was in conflict. * 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 AccountAlreadyExists(
candidateAccountName: AccountName,
case class GroupAlreadyExists( accountType: AccountType
id: Group.Id,
slug: Slug
) extends DbError ) extends DbError
case class TagAlreadyExists( /** Produced when performing account operations on an account that does not
value: Tag.Value * exist within the Smolban database.
*
* @param accountId
* The unique identifier for the account.
*/
case class AccountNotFound(
accountId: AccountId
) extends DbError ) 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 end DbError

View file

@ -2,37 +2,32 @@ package gs.smolban.db.doobie
import DoobieTypes.ErrorMessages import DoobieTypes.ErrorMessages
import doobie.* import doobie.*
import gs.slug.v0.Slug import gs.smolban.model.metadata.CreatedAt
import gs.smolban.model.CreatedAt import gs.smolban.model.metadata.Tag
import gs.smolban.model.Group import gs.smolban.model.metadata.TagValue
import gs.smolban.model.Tag import gs.smolban.model.ticket.CommentId
import gs.smolban.model.Ticket import gs.smolban.model.ticket.Ticket
import gs.smolban.model.ticket.TicketStatus
import gs.uuid.v0.UUID import gs.uuid.v0.UUID
import gs.uuid.v0.UUIDFormat import gs.uuid.v0.UUIDFormat
import java.time.Instant import java.time.Instant
trait DoobieTypes: trait DoobieTypes:
implicit val ticketNumberGet: Get[Ticket.Number] = implicit val ticketStatusGet: Get[TicketStatus] =
Get[Long].tmap(Ticket.Number(_))
implicit val ticketNumberPut: Put[Ticket.Number] =
Put[Long].tcontramap(_.toLong())
implicit val ticketStatusGet: Get[Ticket.Status] =
Get[String].temap(dbValue => Get[String].temap(dbValue =>
Ticket.Status.parse(dbValue) match TicketStatus.parse(dbValue) match
case None => Left(ErrorMessages.invalidTicketStatus(dbValue)) case None => Left(ErrorMessages.invalidTicketStatus(dbValue))
case Some(status) => Right(status) case Some(status) => Right(status)
) )
implicit val ticketStatusPut: Put[Ticket.Status] = implicit val ticketStatusPut: Put[TicketStatus] =
Put[String].tcontramap(_.name) Put[String].tcontramap(_.name)
implicit val groupIdGet: Get[Group.Id] = implicit val commentIdGet: Get[CommentId] =
Get[Array[Byte]].tmap(bytes => Group.Id(UUID(UUIDFormat.fromBytes(bytes)))) 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())) Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
implicit val createdAtGet: Get[CreatedAt] = implicit val createdAtGet: Get[CreatedAt] =
@ -47,30 +42,14 @@ trait DoobieTypes:
implicit val instantPut: Put[Instant] = implicit val instantPut: Put[Instant] =
Put[Long].tcontramap(_.toEpochMilli()) Put[Long].tcontramap(_.toEpochMilli())
implicit val slugGet: Get[Slug] = implicit val tagValueGet: Get[TagValue] =
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] =
Get[String].temap { value => Get[String].temap { value =>
Tag.Value.validate(value) match TagValue.validate(value) match
case None => Left(ErrorMessages.invalidTagValue(value)) case None => Left(ErrorMessages.invalidTagValue(value))
case Some(s) => Right(s) case Some(s) => Right(s)
} }
implicit val tagValuePut: Put[Tag.Value] = implicit val tagValuePut: Put[TagValue] =
Put[String].tcontramap(_.toString()) Put[String].tcontramap(_.toString())
object DoobieTypes extends DoobieTypes: object DoobieTypes extends DoobieTypes:
@ -83,9 +62,6 @@ object DoobieTypes extends DoobieTypes:
def invalidTicketStatus(candidate: String): String = def invalidTicketStatus(candidate: String): String =
s"'$candidate' is not a valid ticket status." s"'$candidate' is not a valid ticket status."
def invalidSlug(candidate: String): String =
s"'$candidate' is not a valid Group slug."
end ErrorMessages end ErrorMessages
end DoobieTypes 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]] /** Groups are the basic unit of organization in Smolban. Each [[Ticket]]
* belongs to a single `Group`. * belongs to a single `Group`.
* *
* TODO: Ownership. Members.
*
* @param id * @param id
* The unique identifier for the group. * The unique identifier for the group.
* @param name * @param name