Skip to content

Commit bdc697f

Browse files
committed
feat(model-server): isolated storage of repository data
The versions of models where previously stored in the same storage without knowing to which repository they belong. To allow proper permission checks there is now an additional `repository` field in the database. Existing repositories remain in the global storage and don't benefit from this new security feature. A migration is not yet implemented. BREAKING CHANGE: New repositories are not accessible via the deprecated `RestWebModelClient`. Use IModelClientV2.initRepositoryWithLegacyStorage to create a new repository that is also accessible via the deprecated client. If you directly use the REST API to create repositories then add the query parameter `?legacyGlobalStorage=true`.
1 parent 3179ebf commit bdc697f

File tree

47 files changed

+1358
-823
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1358
-823
lines changed

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" }
2222
intellij = { id = "org.jetbrains.intellij", version = "1.17.3" }
2323
openapi-generator = {id = "org.openapi.generator", version.ref = "openapi"}
2424
kotlinx-kover = { id = "org.jetbrains.kotlinx.kover", version = "0.8.0" }
25+
docker-compose = { id = "com.avast.gradle.docker-compose" , version = "0.17.6" }
2526

2627
[versions]
2728
kotlin = "1.9.24"

kotlin-utils/src/jsMain/kotlin/org/modelix/kotlin/utils/ContextValue.js.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ actual class ContextValue<E> {
3636
}
3737

3838
actual fun getValue(): E {
39-
return getAllValues().last()
39+
val stack = getAllValues()
40+
check(stack.isNotEmpty()) { "No value provided for ContextValue" }
41+
return stack.last()
4042
}
4143

4244
actual fun getValueOrNull(): E? {

kotlin-utils/src/jvmMain/kotlin/org/modelix/kotlin/utils/ContextValue.jvm.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ actual class ContextValue<E>(private val initialStack: List<E>) {
4141
}
4242

4343
actual fun getValue(): E {
44-
return valueStack.get().last()
44+
val stack = valueStack.get()
45+
check(stack.isNotEmpty()) { "No value provided for ContextValue" }
46+
return stack.last()
4547
}
4648

4749
actual fun getValueOrNull(): E? {

light-model-client/src/jvmTest/kotlin/org/modelix/client/light/LightModelClientTest.kt

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ package org.modelix.client.light
1515

1616
import io.ktor.client.HttpClient
1717
import io.ktor.client.plugins.websocket.WebSockets
18-
import io.ktor.client.request.post
1918
import io.ktor.server.application.install
2019
import io.ktor.server.testing.testApplication
2120
import kotlinx.coroutines.coroutineScope
@@ -28,8 +27,8 @@ import org.modelix.incremental.incrementalFunction
2827
import org.modelix.model.api.IProperty
2928
import org.modelix.model.api.addNewChild
3029
import org.modelix.model.api.getDescendants
31-
import org.modelix.model.server.handlers.DeprecatedLightModelServer
3230
import org.modelix.model.server.handlers.LightModelServer
31+
import org.modelix.model.server.handlers.RepositoriesManager
3332
import org.modelix.model.server.store.InMemoryStoreClient
3433
import org.modelix.model.server.store.LocalModelClient
3534
import org.modelix.model.test.RandomModelChangeGenerator
@@ -45,14 +44,16 @@ class LightModelClientTest {
4544
var localModelClient: LocalModelClient? = null
4645

4746
private fun runTest(block: suspend (HttpClient) -> Unit) = testApplication {
47+
val modelClient = LocalModelClient(InMemoryStoreClient())
48+
val repositoryManager = RepositoriesManager(modelClient)
49+
repositoryManager.createRepository(org.modelix.model.lazy.RepositoryId("test-repo"), userName = "unit-test")
50+
4851
application {
4952
installAuthentication(unitTestMode = true)
5053
install(io.ktor.server.websocket.WebSockets)
5154
install(io.ktor.server.resources.Resources)
52-
val modelClient = LocalModelClient(InMemoryStoreClient())
5355
localModelClient = modelClient
54-
DeprecatedLightModelServer(modelClient).init(this)
55-
LightModelServer(modelClient).init(this)
56+
LightModelServer(modelClient, repositoryManager).init(this)
5657
}
5758
val client = createClient {
5859
install(WebSockets)
@@ -62,9 +63,6 @@ class LightModelClientTest {
6263

6364
fun runClientTest(block: suspend (suspend (debugName: String) -> LightModelClient) -> Unit) = runTest { httpClient ->
6465
withTimeout(2.minutes) {
65-
val response = httpClient.post("http://localhost/json/test-repo/init").status
66-
// println("init: $response")
67-
6866
val createClient: suspend (debugName: String) -> LightModelClient = { debugName ->
6967
val client = LightModelClient.builder()
7068
.httpClient(httpClient)

model-client/src/commonMain/kotlin/org/modelix/model/client2/IModelClientV2.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface IModelClientV2 {
4040
fun getUserId(): String?
4141

4242
suspend fun initRepository(repository: RepositoryId, useRoleIds: Boolean = true): IVersion
43+
suspend fun initRepositoryWithLegacyStorage(repository: RepositoryId): IVersion
4344
suspend fun listRepositories(): List<RepositoryId>
4445
suspend fun deleteRepository(repository: RepositoryId): Boolean
4546
suspend fun listBranches(repository: RepositoryId): List<BranchReference>

model-client/src/commonMain/kotlin/org/modelix/model/client2/ModelClientV2.kt

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package org.modelix.model.client2
1616
import io.ktor.client.HttpClient
1717
import io.ktor.client.HttpClientConfig
1818
import io.ktor.client.call.body
19+
import io.ktor.client.plugins.ClientRequestException
1920
import io.ktor.client.plugins.HttpRequestRetry
2021
import io.ktor.client.plugins.HttpTimeout
2122
import io.ktor.client.plugins.ResponseException
@@ -55,10 +56,12 @@ import org.modelix.model.api.INode
5556
import org.modelix.model.api.IdGeneratorDummy
5657
import org.modelix.model.api.TreePointer
5758
import org.modelix.model.api.getRootNode
59+
import org.modelix.model.api.runSynchronized
5860
import org.modelix.model.client.IdGenerator
5961
import org.modelix.model.lazy.BranchReference
6062
import org.modelix.model.lazy.CLTree
6163
import org.modelix.model.lazy.CLVersion
64+
import org.modelix.model.lazy.IDeserializingKeyValueStore
6265
import org.modelix.model.lazy.ObjectStoreCache
6366
import org.modelix.model.lazy.RepositoryId
6467
import org.modelix.model.lazy.computeDelta
@@ -74,6 +77,8 @@ import org.modelix.modelql.core.IMonoStep
7477
import kotlin.time.Duration
7578
import kotlin.time.Duration.Companion.seconds
7679

80+
class VersionNotFoundException(val versionHash: String) : Exception("Version $versionHash not found")
81+
7782
class ModelClientV2(
7883
private val httpClient: HttpClient,
7984
val baseUrl: String,
@@ -82,14 +87,20 @@ class ModelClientV2(
8287
private var clientId: Int = 0
8388
private var idGenerator: IIdGenerator = IdGeneratorDummy()
8489
private var serverProvidedUserId: String? = null
85-
private val kvStore = MapBasedStore()
86-
val store = ObjectStoreCache(kvStore) // TODO the store will accumulate garbage
90+
91+
// TODO the store will accumulate garbage
92+
private val storeForRepository: MutableMap<RepositoryId?, IDeserializingKeyValueStore> = HashMap()
8793

8894
suspend fun init() {
8995
updateClientId()
9096
updateUserId()
9197
}
9298

99+
private fun getStore(repository: RepositoryId?) = runSynchronized(storeForRepository) { storeForRepository.getOrPut(repository) { ObjectStoreCache(MapBasedStore()) } }
100+
private fun getRepository(store: IDeserializingKeyValueStore): RepositoryId? {
101+
return storeForRepository.asSequence().first { it.value == store }.key
102+
}
103+
93104
private suspend fun updateClientId() {
94105
this.clientId = httpClient.post {
95106
url {
@@ -127,15 +138,24 @@ class ModelClientV2(
127138
override fun getUserId(): String? = clientProvidedUserId ?: serverProvidedUserId
128139

129140
override suspend fun initRepository(repository: RepositoryId, useRoleIds: Boolean): IVersion {
141+
return initRepository(repository, useRoleIds = useRoleIds, legacyGlobalStorage = false)
142+
}
143+
144+
override suspend fun initRepositoryWithLegacyStorage(repository: RepositoryId): IVersion {
145+
return initRepository(repository, useRoleIds = false, legacyGlobalStorage = true)
146+
}
147+
148+
suspend fun initRepository(repository: RepositoryId, useRoleIds: Boolean = false, legacyGlobalStorage: Boolean = false): IVersion {
130149
return httpClient.preparePost {
131150
url {
132151
parameter("useRoleIds", useRoleIds)
133152
takeFrom(baseUrl)
134153
appendPathSegmentsEncodingSlash("repositories", repository.id, "init")
154+
if (legacyGlobalStorage) parameters["legacyGlobalStorage"] = legacyGlobalStorage.toString()
135155
}
136156
useVersionStreamFormat()
137157
}.execute { response ->
138-
createVersion(null, response.readVersionDelta())
158+
createVersion(getStore(repository), null, response.readVersionDelta())
139159
}
140160
}
141161

@@ -193,36 +213,55 @@ class ModelClientV2(
193213
@Deprecated("repository ID is required for permission checks")
194214
@DeprecationInfo("3.7.0", "May be removed with the next major release. Also remove the endpoint from the model-server.")
195215
override suspend fun loadVersion(versionHash: String, baseVersion: IVersion?): IVersion {
196-
return httpClient.prepareGet {
197-
url {
198-
takeFrom(baseUrl)
199-
appendPathSegments("versions", versionHash)
200-
if (baseVersion != null) {
201-
parameters["lastKnown"] = (baseVersion as CLVersion).getContentHash()
216+
val repositoryIdFromBaseVersion = (baseVersion as? CLVersion)?.let { getRepository(it.store) }
217+
if (repositoryIdFromBaseVersion != null) {
218+
return doLoadVersion(repositoryIdFromBaseVersion, versionHash, baseVersion)
219+
} else {
220+
// try finding the version in any repository
221+
for (repositoryId in listRepositories() + null) {
222+
try {
223+
return doLoadVersion(repositoryId, versionHash, baseVersion)
224+
} catch (ex: ClientRequestException) {
225+
when (ex.response.status) {
226+
HttpStatusCode.NotFound -> {}
227+
HttpStatusCode.Unauthorized -> {}
228+
HttpStatusCode.Forbidden -> {}
229+
else -> throw ex
230+
}
202231
}
203232
}
204-
useVersionStreamFormat()
205-
}.execute { response ->
206-
createVersion(baseVersion as CLVersion?, response.readVersionDelta())
233+
throw VersionNotFoundException(versionHash)
207234
}
208235
}
209236

210237
override suspend fun loadVersion(
211238
repositoryId: RepositoryId,
212239
versionHash: String,
213240
baseVersion: IVersion?,
241+
): IVersion {
242+
return doLoadVersion(repositoryId, versionHash, baseVersion)
243+
}
244+
245+
private suspend fun doLoadVersion(
246+
repositoryId: RepositoryId?,
247+
versionHash: String,
248+
baseVersion: IVersion?,
214249
): IVersion {
215250
return httpClient.prepareGet {
216251
url {
217252
takeFrom(baseUrl)
218-
appendPathSegments("repositories", repositoryId.id, "versions", versionHash)
253+
if (repositoryId == null) {
254+
appendPathSegments("versions", versionHash)
255+
} else {
256+
appendPathSegments("repositories", repositoryId.id, "versions", versionHash)
257+
}
219258
if (baseVersion != null) {
220259
parameters["lastKnown"] = (baseVersion as CLVersion).getContentHash()
221260
}
222261
}
223262
useVersionStreamFormat()
224263
}.execute { response ->
225-
createVersion(baseVersion as CLVersion?, response.readVersionDelta())
264+
createVersion(getStore(repositoryId), baseVersion as CLVersion?, response.readVersionDelta())
226265
}
227266
}
228267

@@ -249,7 +288,7 @@ class ModelClientV2(
249288
contentType(ContentType.Application.Json)
250289
setBody(delta)
251290
}.execute { response ->
252-
createVersion(version, response.readVersionDelta())
291+
createVersion(getStore(branch.repositoryId), version, response.readVersionDelta())
253292
}
254293
}
255294

@@ -285,7 +324,7 @@ class ModelClientV2(
285324
}
286325
useVersionStreamFormat()
287326
}.execute { response ->
288-
val receivedVersion = createVersion(lastKnownVersion, response.readVersionDelta())
327+
val receivedVersion = createVersion(getStore(branch.repositoryId), lastKnownVersion, response.readVersionDelta())
289328
LOG.debug { "${clientId.toString(16)}.pull($branch, $lastKnownVersion) -> $receivedVersion" }
290329
receivedVersion
291330
}
@@ -302,7 +341,7 @@ class ModelClientV2(
302341
}.execute { response ->
303342
val receivedVersion = when (response.status) {
304343
HttpStatusCode.NotFound -> null
305-
HttpStatusCode.OK -> createVersion(null, response.readVersionDelta())
344+
HttpStatusCode.OK -> createVersion(getStore(branch.repositoryId), null, response.readVersionDelta())
306345
else -> throw ResponseException(response, response.bodyAsText())
307346
}
308347
LOG.debug { "${clientId.toString(16)}.pullIfExists($branch) -> $receivedVersion" }
@@ -352,7 +391,7 @@ class ModelClientV2(
352391
}
353392
useVersionStreamFormat()
354393
}.execute { response ->
355-
val receivedVersion = createVersion(lastKnownVersion, response.readVersionDelta())
394+
val receivedVersion = createVersion(getStore(branch.repositoryId), lastKnownVersion, response.readVersionDelta())
356395
LOG.debug { "${clientId.toString(16)}.poll($branch, $lastKnownVersion) -> $receivedVersion" }
357396
receivedVersion
358397
}
@@ -378,7 +417,7 @@ class ModelClientV2(
378417
httpClient.close()
379418
}
380419

381-
private suspend fun createVersion(baseVersion: CLVersion?, delta: VersionDeltaStream): CLVersion {
420+
private suspend fun createVersion(store: IDeserializingKeyValueStore, baseVersion: CLVersion?, delta: VersionDeltaStream): CLVersion {
382421
delta.getObjectsAsFlow().collect {
383422
HashUtil.checkObjectHash(it.first, it.second)
384423
store.keyValueStore.put(it.first, it.second)
@@ -393,7 +432,7 @@ class ModelClientV2(
393432
}
394433
}
395434

396-
private suspend fun createVersion(baseVersion: CLVersion?, delta: Flow<String>): CLVersion {
435+
private suspend fun createVersion(store: IDeserializingKeyValueStore, baseVersion: CLVersion?, delta: Flow<String>): CLVersion {
397436
var firstHash: String? = null
398437
var isHash = true
399438
var lastHash: String? = null
@@ -586,7 +625,7 @@ suspend fun <T> IModelClientV2.runWriteOnBranch(branchRef: BranchReference, body
586625
.takeIf { it != branchRef }
587626
?.let { client.pullIfExists(it) } // master branch
588627
?: client.initRepository(branchRef.repositoryId)
589-
val branch = OTBranch(TreePointer(baseVersion.getTree(), client.getIdGenerator()), client.getIdGenerator(), (client as ModelClientV2).store)
628+
val branch = OTBranch(TreePointer(baseVersion.getTree(), client.getIdGenerator()), client.getIdGenerator(), (baseVersion as CLVersion).store)
590629
val result = branch.computeWrite {
591630
body(branch)
592631
}

model-datastructure/src/commonMain/kotlin/org/modelix/model/IKeyListener.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515

1616
package org.modelix.model
1717

18-
interface IKeyListener {
18+
interface IKeyListener : IGenericKeyListener<String>
19+
20+
interface IGenericKeyListener<KeyT> {
1921
companion object {
2022
const val NULL_VALUE = "Null-nULL-NulL-nULl"
2123
}
22-
fun changed(key: String, value: String?)
24+
fun changed(key: KeyT, value: String?)
2325
}

model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/CLVersion.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,11 @@ class CLVersion : IVersion {
253253
)
254254

255255
fun loadFromHash(hash: String, store: IDeserializingKeyValueStore): CLVersion {
256-
val data = store[hash, { CPVersion.deserialize(it) }]
257-
?: throw RuntimeException("Version with hash $hash not found")
256+
return tryLoadFromHash(hash, store) ?: throw RuntimeException("Version with hash $hash not found")
257+
}
258+
259+
fun tryLoadFromHash(hash: String, store: IDeserializingKeyValueStore): CLVersion? {
260+
val data = store[hash, { CPVersion.deserialize(it) }] ?: return null
258261
return CLVersion(data, store)
259262
}
260263
}

model-server-openapi/specifications/model-server-html.yaml

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ paths:
3737
examples:
3838
Example#1:
3939
value: "../repos/"
40-
/content/{repository}/{branch}/latest:
40+
/content/repositories/{repository}/branches/{branch}/latest:
4141
get:
4242
operationId: getContentRepositoryBranchLatest
4343
parameters:
@@ -54,26 +54,25 @@ paths:
5454
responses:
5555
"200":
5656
$ref: '#/components/responses/200'
57-
/content/{versionHash}:
57+
/content/repositories/{repository}/versions/{versionHash}:
58+
parameters:
59+
- name: repository
60+
in: "path"
61+
required: true
62+
schema:
63+
type: string
64+
- name: versionHash
65+
in: "path"
66+
required: true
67+
schema:
68+
type: string
5869
get:
5970
operationId: getVersionHash
60-
parameters:
61-
- name: versionHash
62-
in: "path"
63-
required: true
64-
schema:
65-
type: string
6671
responses:
6772
"400":
6873
$ref: '#/components/responses/400'
6974
post:
7075
operationId: postVersionHash
71-
parameters:
72-
- name: versionHash
73-
in: "path"
74-
required: true
75-
schema:
76-
type: string
7776
requestBody:
7877
content:
7978
'application/json':
@@ -85,10 +84,15 @@ paths:
8584
$ref: '#/components/responses/400'
8685
"200":
8786
$ref: '#/components/responses/200'
88-
/content/{versionHash}/{nodeId}:
87+
/content/repositories/{repository}/versions/{versionHash}/{nodeId}:
8988
get:
9089
operationId: getNodeIdForVersionHash
9190
parameters:
91+
- name: repository
92+
in: "path"
93+
required: true
94+
schema:
95+
type: string
9296
- name: nodeId
9397
in: "path"
9498
required: true

0 commit comments

Comments
 (0)