diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Credential.scala b/modules/auth/src/main/scala/gs/smolban/auth/Credential.scala index 240903d..2e56679 100644 --- a/modules/auth/src/main/scala/gs/smolban/auth/Credential.scala +++ b/modules/auth/src/main/scala/gs/smolban/auth/Credential.scala @@ -1,6 +1,7 @@ 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. @@ -10,6 +11,8 @@ import gs.smolban.model.metadata.CreatedAt * @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 @@ -22,6 +25,7 @@ import gs.smolban.model.metadata.CreatedAt case class Credential( credentialId: CredentialId, accountId: AccountId, + accountType: AccountType, credentialType: CredentialType, status: CredentialStatus, effectivity: Option[CredentialEffectivity], diff --git a/modules/db/src/main/resources/sql/sqlite/1.sql b/modules/db/src/main/resources/sql/sqlite/1.sql deleted file mode 100644 index d765247..0000000 --- a/modules/db/src/main/resources/sql/sqlite/1.sql +++ /dev/null @@ -1,50 +0,0 @@ --- sqlite3 - -CREATE TABLE IF NOT EXISTS groups( - id BIGINT PRIMARY KEY, - group_id BLOB NOT NULL, - slug TEXT NOT NULL, - created_at DATETIME NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id); - -CREATE TABLE IF NOT EXISTS tags( - id BIGINT PRIMARY KEY, - tag_value TEXT NOT NULL, - created_at DATETIME NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_value ON tags(tag_value); - -CREATE TABLE IF NOT EXISTS tickets( - id BIGINT PRIMARY KEY, - ticket_number INTEGER NOT NULL, - group_id BLOB NOT NULL, - created_at DATETIME NOT NULL, - title TEXT NOT NULL, - description TEXT NOT NULL, - status TEXT NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_number_group -ON tickets(group_id, ticket_number); - -CREATE TABLE IF NOT EXISTS ticket_tags( - id BIGINT PRIMARY KEY, - ticket_id BIGINT NOT NULL, - tag_id BIGINT NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_tags_ticket_tag -ON ticket_tags(ticket_id, tag_id); - -CREATE TABLE IF NOT EXISTS ticket_history( - id BIGINT PRIMARY KEY, - ticket_id BIGINT NOT NULL, - status TEXT NOT NULL, - set_at DATETIME NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_ticket_history_order_ticket_set_at -ON ticket_history(ticket_id, set_at); diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieAuthDb.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieAuthDb.scala new file mode 100644 index 0000000..3defac1 --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieAuthDb.scala @@ -0,0 +1,111 @@ +package gs.smolban.db.doobie + +import cats.data.EitherT +import doobie.* +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.db.AuthDb +import gs.smolban.db.DbError +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 + +final class DoobieAuthDb extends AuthDb[ConnectionIO] { + + /** @inheritDocs + */ + override def getUser(name: AccountName): ConnectionIO[Option[User]] = ??? + + /** @inheritDocs + */ + override def createUser( + name: AccountName, + initialPassword: Password, + initialPermissions: PermissionSet + ): EitherT[[A] =>> ConnectionIO[A], DbError, User] = ??? + + /** @inheritDocs + */ + override def setUserPassword( + id: AccountId, + newPassword: Password + ): EitherT[[A] =>> ConnectionIO[A], DbError, User] = ??? + + /** @inheritDocs + */ + override def listActiveUsers(): ConnectionIO[List[User]] = ??? + + /** @inheritDocs + */ + override def getServiceAccount(name: AccountName) + : ConnectionIO[Option[ServiceAccount]] = ??? + + /** @inheritDocs + */ + override def createServiceAccount( + name: AccountName, + owner: AccountId, + initialPermissions: PermissionSet + ): EitherT[[A] =>> ConnectionIO[A], 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] = ??? + +} diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieSqliteDb.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieSqliteDb.scala new file mode 100644 index 0000000..ea400c7 --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieSqliteDb.scala @@ -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 diff --git a/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala b/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala index 617b3c6..d63acaf 100644 --- a/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala +++ b/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala @@ -28,27 +28,38 @@ class DoobieTagDbTests extends munit.FunSuite: ): Unit = test(name)(f.unsafeRunSync()) - private val dbConfig: Config = Config( - jdbcUrl = "jdbc:sqlite:test/doobie_tag_db_tests", - driverClassName = Some("org.sqlite.JDBC") - ) - - private val transactor: Resource[IO, Transactor[IO]] = - HikariTransactor.fromConfig[IO](dbConfig) - private val tagDb: TagDb[ConnectionIO] = new DoobieTagDb( CuratedSqlStates.sqlite ) private val clock = Clock.systemDefaultZone() - iotest("should create, read, and delete a tag") { + 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) - transactor.use { xa => + inDb("db_0001").use { testDb => (for - _ <- dropTable().run - _ <- createTable().run tag <- tagDb.createTag(tagValue, createdAt).value t2 <- tagDb.readTag(tagValue) result <- tagDb.deleteTag(tagValue) @@ -56,7 +67,7 @@ class DoobieTagDbTests extends munit.FunSuite: assertEquals(tag, Right(Tag(tagValue, createdAt))) assertEquals(t2, tag.toOption) assert(result) - ).transact(xa) + ).transact(testDb.xa) } } diff --git a/modules/db/src/test/scala/gs/smolban/db/doobie/TestDb.scala b/modules/db/src/test/scala/gs/smolban/db/doobie/TestDb.scala new file mode 100644 index 0000000..49c18a8 --- /dev/null +++ b/modules/db/src/test/scala/gs/smolban/db/doobie/TestDb.scala @@ -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] +) diff --git a/sql/README.md b/sql/README.md new file mode 100644 index 0000000..531fefa --- /dev/null +++ b/sql/README.md @@ -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) diff --git a/sql/sqlite/1.sql b/sql/sqlite/1.sql new file mode 100644 index 0000000..5bc7d3c --- /dev/null +++ b/sql/sqlite/1.sql @@ -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);