Skip to content

Commit

Permalink
feat: multi-tenancy via team scoped schemas
Browse files Browse the repository at this point in the history
  • Loading branch information
cif committed Oct 4, 2024
1 parent 6a8667b commit 59676f5
Show file tree
Hide file tree
Showing 7 changed files with 34 additions and 19 deletions.
26 changes: 15 additions & 11 deletions src/main/kotlin/com/stabledata/Database.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,39 +26,43 @@ fun convertPath (path: String): String {
return path.replace(".", "_")
}

fun sanitizeTableName(input: String): String {
fun sanitizeString(input: String): String {
val regex = Regex("^[a-zA-Z0-9_]+\$")

// Check if the input matches the allowed pattern
if (!regex.matches(input)) {
throw IllegalArgumentException("Invalid table name: $input")
throw IllegalArgumentException("Dirty SQL string: $input")
}

return input
}

object DatabaseOperations {
fun createTableAtPathSQL(path: String): String {
val tableName = sanitizeTableName(convertPath(path))
fun createTableAtPathSQL(team: String, path: String): String {
val tableName = sanitizeString(convertPath(path))
val cleanTeamName = sanitizeString(team)
return """
CREATE TABLE $tableName (id UUID PRIMARY KEY)
CREATE SCHEMA IF NOT EXISTS $team;
CREATE TABLE $cleanTeamName.$tableName (id UUID PRIMARY KEY)
""".trimIndent()
}

fun dropTableAtPath(path: String): String {
val tableName = sanitizeTableName(convertPath(path))
fun dropTableAtPath(team: String, path: String): String {
val tableName = sanitizeString(convertPath(path))
val cleanTeamName = sanitizeString(team)
return """
DROP TABLE $tableName
DROP TABLE $cleanTeamName.$tableName
""".trimIndent()
}

fun tableExistsAtPath(path: String): Boolean {
val tableName = sanitizeTableName(convertPath(path))
fun tableExistsAtPath(team: String, path: String): Boolean {
val tableName = sanitizeString(convertPath(path))
val cleanTeamName = sanitizeString(team)
val existsQuery = """
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
WHERE table_schema = '$cleanTeamName'
AND table_name = '$tableName'
);
""".trimIndent()
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/com/stabledata/dao/CollectionsDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,18 @@ class CollectionDeleteFailedException(path: String) : Exception("Failed to delet

object CollectionsTable : Table("stable.collections") {
val id = uuid("id")
val team_id = text("team_id")
val path = text("path")
val type = text("type").nullable()
val label = text("label").nullable()
val icon = text("icon").nullable()
val description = text("description").nullable()

fun insertRowFromRequest(insert: CollectionRequest): UUID {
fun insertRowFromRequest(team: String, insert: CollectionRequest): UUID {
CollectionsTable.insert { row ->
row[path] = insert.path
row[id] = UUID.fromString(insert.id)
row[team_id] = team
row[type] = insert.type
row[label] = insert.label
row[icon] = insert.icon
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/com/stabledata/dao/LogsDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import java.util.*
*/
data class LogEntry (
val id: String,
val teamId: String,
val path: String,
val actorId: String,
val eventType: String,
Expand All @@ -23,13 +24,15 @@ data class LogEntry (
*/
class LogEntryBuilder {
var id: String? = null
var teamId: String? = null
var path: String? = null
var actorId: String? = null
var eventType: String? = null
var createdAt: Long? = null


fun id(id: String) = apply { this.id = id }
fun teamId(teamId: String) = apply { this.teamId = teamId }
fun path(path: String) = apply { this.path = path }
fun actorId(actorId: String) = apply { this.actorId = actorId }
fun eventType(eventType: String) = apply { this.eventType = eventType }
Expand All @@ -43,6 +46,7 @@ class LogEntryBuilder {
fun build(): LogEntry {
return LogEntry(
id = requireNotNull(id) { "id $providedExplainer" },
teamId = requireNotNull(teamId) { "team_id $providedExplainer" },
path = requireNotNull(path) { "path $providedExplainer" },
actorId = requireNotNull(actorId) { "actorId $providedExplainer" },
eventType = requireNotNull(eventType) { "eventType $providedExplainer" },
Expand All @@ -54,6 +58,7 @@ class LogEntryBuilder {

object LogsTable : Table("stable.logs") {
val eventId = uuid("id")
val teamId = text("team_id")
val actorId = text("actor_id")
val path = text("path")
val eventType = text("event_type")
Expand All @@ -74,6 +79,7 @@ object LogsTable : Table("stable.logs") {
return row?.let {
LogEntry(
id = it[eventId].toString(),
teamId = it[teamId],
actorId = it[actorId],
path = it[path],
eventType = it[eventType],
Expand All @@ -86,6 +92,7 @@ object LogsTable : Table("stable.logs") {
fun insertLogEntry (entry: LogEntry): InsertStatement<Number> {
return LogsTable.insert { log ->
log[eventId] = UUID.fromString(entry.id)
log[teamId] = entry.teamId
log[createdAt] = entry.createdAt
log[eventType] = entry.eventType
log[actorId] = entry.actorId
Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/com/stabledata/endpoint/RequestContext.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ suspend fun <T>PipelineContext<Unit, ApplicationCall>.contextualize(
val logEntry = LogEntryBuilder().eventType(operation)

logEntry.actorId(userCredentials.id)
logEntry.teamId(userCredentials.team)
logEntry.id(envelope.eventId)
logEntry.createdAt(envelope.createdAt)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fun Application.configureCreateCollectionRoute() {

// check if the table exists already at the path
// but... we should also check for collections that might have that path
if (DatabaseOperations.tableExistsAtPath(collection.path)) {
if (DatabaseOperations.tableExistsAtPath(user.team, collection.path)) {
return@post call.respond(
HttpStatusCode.Conflict,
"path ${collection.path} already exists"
Expand All @@ -47,8 +47,8 @@ fun Application.configureCreateCollectionRoute() {
val finalLogEntry = logEntry.build()

transaction {
exec(DatabaseOperations.createTableAtPathSQL(collection.path))
CollectionsTable.insertRowFromRequest(collection)
exec(DatabaseOperations.createTableAtPathSQL(user.team, collection.path))
CollectionsTable.insertRowFromRequest(user.team, collection)
LogsTable.insertLogEntry(finalLogEntry)
Ably.publish(user.team, "collection/create", finalLogEntry)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ fun Application.configureDeleteCollectionRoute() {
val finalLogEntry = logEntry.build()
transaction {
CollectionsTable.deleteAtPath(collection.path)
exec(DatabaseOperations.dropTableAtPath(collection.path))
exec(DatabaseOperations.dropTableAtPath(user.team, collection.path))
LogsTable.insertLogEntry(finalLogEntry)
Ably.publish(user.team, "collection/delete", finalLogEntry)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CREATE SCHEMA IF NOT EXISTS stable;
/* Default stores */
CREATE TABLE stable.logs (
id uuid PRIMARY KEY,
team_id text NOT NULL,
event_type text NOT NULL,
actor_id text NOT NULL,
path text,
Expand All @@ -19,6 +20,7 @@ CREATE INDEX index_logs_document_id ON stable.logs (document_id);

CREATE TABLE stable.collections (
id uuid PRIMARY KEY,
team_id text NOT NULL,
path text NOT NULL,
type text,
label text,
Expand All @@ -28,19 +30,18 @@ CREATE TABLE stable.collections (

CREATE TABLE stable.fields (
id uuid PRIMARY KEY,
team_id text NOT NULL,
collection_id uuid NOT NULL,
path text NOT NULL,
kind text NOT NULL,
type text NOT NULL,

label text,
placeholder text,
default_value text,

realtime boolean,
help json,

disabled boolean,
hidden boolean
);

CREATE INDEX index_fields_collection_slug ON stable.fields(collection_id);

0 comments on commit 59676f5

Please sign in to comment.