Skip to content

Commit fbe94d8

Browse files
committed
feat(mps-sync-plugin): detect when the user doesn't have permission to access a repository
1 parent c759b10 commit fbe94d8

File tree

9 files changed

+188
-9
lines changed

9 files changed

+188
-9
lines changed

authorization/src/main/kotlin/org/modelix/authorization/ModelixJWTUtil.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,9 @@ class ModelixJWTUtil {
328328
fun claim(name: String, value: String) {
329329
builder.claim(name, value)
330330
}
331+
fun claim(name: String, value: Long) {
332+
builder.claim(name, value)
333+
}
331334
}
332335

333336
private inner class PemFileJWKSet<C : SecurityContext>(pemFile: File) : FileJWKSet<C>(pemFile) {

model-client/src/jvmMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ actual class ModelixAuthClient {
162162
val realm = wwwAuthenticate.parameter("realm")
163163
val description = wwwAuthenticate.parameter("error_description")
164164
}
165+
if (currentAuthConfig.tokenUrl == null) {
166+
LOG.warn { "No token URL configured" }
167+
return@refreshTokens null
168+
}
169+
if (currentAuthConfig.authorizationUrl == null) {
170+
LOG.warn { "No authorization URL configured" }
171+
return@refreshTokens null
172+
}
173+
if (currentAuthConfig.clientId == null) {
174+
LOG.warn { "No client ID configured" }
175+
return@refreshTokens null
176+
}
165177
val tokens = refreshTokensOrReauthorize(currentAuthConfig)
166178
checkNotNull(tokens) { "No tokens received" }
167179

mps-sync-plugin3/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies {
3939
implementation(libs.kotlin.logging, excludeMPSLibraries)
4040
implementation(libs.kotlin.html, excludeMPSLibraries)
4141
implementation(libs.kotlin.datetime, excludeMPSLibraries)
42+
implementation(libs.ktor.client.core, excludeMPSLibraries)
4243

4344
compileOnly(
4445
fileTree(mpsHomeDir).matching {
@@ -51,6 +52,8 @@ dependencies {
5152
testImplementation(libs.kotlin.coroutines.test)
5253
testImplementation(libs.logback.classic)
5354
testImplementation(kotlin("test"))
55+
testImplementation(project(":authorization"), excludeMPSLibraries)
56+
testImplementation(project(":model-server"), excludeMPSLibraries)
5457
}
5558

5659
tasks {

mps-sync-plugin3/src/main/kotlin/org/modelix/mps/sync3/BindingWorker.kt

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package org.modelix.mps.sync3
22

33
import com.intellij.openapi.application.ApplicationManager
44
import com.intellij.openapi.application.ModalityState
5+
import io.ktor.client.plugins.ResponseException
6+
import io.ktor.http.HttpStatusCode
57
import io.ktor.utils.io.CancellationException
68
import jetbrains.mps.project.MPSProject
79
import kotlinx.coroutines.CoroutineScope
@@ -62,6 +64,7 @@ class BindingWorker(
6264
private var invalidatingListener: MyInvalidatingListener? = null
6365
private var activeSynchronizer: ModelSynchronizer? = null
6466
private var previousSyncStack: List<IReadableNode> = emptyList()
67+
private var status: IBinding.Status = IBinding.Status.Disabled
6568

6669
private val repository: SRepository get() = mpsProject.repository
6770
private suspend fun client() = serverConnection.getClient()
@@ -72,6 +75,7 @@ class BindingWorker(
7275

7376
fun activate() {
7477
if (activated.getAndSet(true)) return
78+
status = IBinding.Status.Initializing
7579
syncJob = coroutinesScope.launch {
7680
try {
7781
syncJob()
@@ -84,14 +88,16 @@ class BindingWorker(
8488

8589
fun deactivate() {
8690
if (!activated.getAndSet(false)) return
87-
91+
status = IBinding.Status.Disabled
8892
syncJob?.cancel()
8993
syncJob = null
9094
syncToServerTask = null
9195
invalidatingListener?.stop()
9296
invalidatingListener = null
9397
}
9498

99+
fun getStatus(): IBinding.Status = status
100+
95101
private fun ModelSynchronizer.synchronizeAndStoreInstance() {
96102
try {
97103
activeSynchronizer = this
@@ -153,6 +159,22 @@ class BindingWorker(
153159
}
154160
}
155161

162+
private suspend fun <R : IVersion?> runSync(body: suspend (IVersion?) -> R) = lastSyncedVersion.updateValue {
163+
try {
164+
status = IBinding.Status.Syncing(::getSyncProgress)
165+
body(it).also {
166+
status = IBinding.Status.Synced(it?.getContentHash() ?: "null")
167+
}
168+
} catch (ex: Throwable) {
169+
status = if (ex is ResponseException && ex.response.status == HttpStatusCode.Forbidden) {
170+
IBinding.Status.NoPermission(runCatching { client().getUserId() }.getOrNull())
171+
} else {
172+
IBinding.Status.Error(ex.message)
173+
}
174+
throw ex
175+
}
176+
}
177+
156178
private suspend fun CoroutineScope.syncJob() {
157179
// initial sync
158180
while (isActive()) {
@@ -189,7 +211,7 @@ class BindingWorker(
189211
}
190212

191213
private suspend fun initialSync() {
192-
lastSyncedVersion.updateValue { oldVersion ->
214+
runSync { oldVersion ->
193215
LOG.debug { "Running initial synchronization" }
194216

195217
val baseVersion = oldVersion
@@ -230,15 +252,15 @@ class BindingWorker(
230252
}
231253

232254
suspend fun syncToMPS(incremental: Boolean): IVersion {
233-
return lastSyncedVersion.updateValue { oldVersion ->
255+
return runSync { oldVersion ->
234256
client().pull(branchRef, oldVersion).also { newVersion ->
235257
doSyncToMPS(oldVersion, newVersion, incremental)
236258
}
237259
}
238260
}
239261

240262
suspend fun syncToServer(incremental: Boolean): IVersion? {
241-
return lastSyncedVersion.updateValue { oldVersion ->
263+
return runSync { oldVersion ->
242264
if (oldVersion == null) {
243265
// have to wait for initial sync
244266
oldVersion

mps-sync-plugin3/src/main/kotlin/org/modelix/mps/sync3/IModelSyncService.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,34 @@ interface IBinding : Closeable {
9999
fun getCurrentVersion(): IVersion?
100100

101101
fun getSyncProgress(): String?
102+
fun getStatus(): Status
103+
104+
sealed class Status {
105+
object Disabled : Status()
106+
107+
/**
108+
* Binding is enabled, but the initial synchronization hasn't started yet.
109+
*/
110+
object Initializing : Status()
111+
112+
/**
113+
* The last synchronization was successful.
114+
*/
115+
data class Synced(val versionHash: String) : Status()
116+
117+
/**
118+
* Synchronization is running.
119+
*/
120+
data class Syncing(val progress: () -> String?) : Status()
121+
122+
/**
123+
* The last synchronization failed.
124+
*/
125+
data class Error(val message: String?) : Status()
126+
127+
/**
128+
* The last synchronization failed because of a mission permission.
129+
*/
130+
data class NoPermission(val user: String?) : Status()
131+
}
102132
}

mps-sync-plugin3/src/main/kotlin/org/modelix/mps/sync3/ModelSyncService.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,10 @@ class ModelSyncService(val project: Project) :
382382
return workers[id]?.getSyncProgress()
383383
}
384384

385+
override fun getStatus(): IBinding.Status {
386+
return workers[id]?.getStatus() ?: IBinding.Status.Disabled
387+
}
388+
385389
private fun getService(): ModelSyncService = this@ModelSyncService
386390

387391
override fun equals(other: Any?): Boolean {

mps-sync-plugin3/src/main/kotlin/org/modelix/mps/sync3/ui/ModelSyncStatusWidget.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import kotlinx.html.td
2626
import kotlinx.html.tr
2727
import org.modelix.model.lazy.CLVersion
2828
import org.modelix.mps.api.ModelixMpsApi
29+
import org.modelix.mps.sync3.IBinding
2930
import org.modelix.mps.sync3.IModelSyncService
3031
import org.modelix.mps.sync3.IServerConnection
3132
import java.awt.Desktop
@@ -257,9 +258,14 @@ class ModelSyncStatusWidget(val project: Project) : CustomStatusBarWidget, Statu
257258
return "Click to log in"
258259
}
259260
}
260-
result = binding.getSyncProgress()?.let { "Synchronizing: $it" }
261-
?: binding.getCurrentVersion()?.getContentHash()?.let { "Synchronized: ${it.take(5)}" }
262-
?: result
261+
result = when (val status = binding.getStatus()) {
262+
IBinding.Status.Disabled -> "Disabled"
263+
IBinding.Status.Initializing -> "Initializing"
264+
is IBinding.Status.Synced -> "Synchronized: ${status.versionHash.take(5)}"
265+
is IBinding.Status.Syncing -> "Synchronizing: ${status.progress()}"
266+
is IBinding.Status.Error -> "Synchronization failed: ${status.message}"
267+
is IBinding.Status.NoPermission -> "${status.user} has no permission on ${binding.getBranchRef().repositoryId}"
268+
}
263269
}
264270
}
265271
}

mps-sync-plugin3/src/test/kotlin/org/modelix/mps/sync3/ProjectSyncTest.kt

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
package org.modelix.mps.sync3
22

33
import com.intellij.configurationStore.saveSettings
4+
import io.ktor.client.plugins.ResponseException
45
import jetbrains.mps.smodel.SNodeUtil
56
import jetbrains.mps.smodel.adapter.ids.SConceptId
67
import jetbrains.mps.smodel.adapter.ids.SContainmentLinkId
78
import jetbrains.mps.smodel.adapter.structure.concept.SConceptAdapterById
89
import jetbrains.mps.smodel.adapter.structure.link.SContainmentLinkAdapterById
10+
import kotlinx.coroutines.delay
911
import org.jetbrains.mps.openapi.model.SNode
1012
import org.jetbrains.mps.openapi.persistence.PersistenceFacade
1113
import org.junit.Assert
14+
import org.modelix.authorization.ModelixJWTUtil
1215
import org.modelix.datastructures.model.ModelChangeEvent
1316
import org.modelix.datastructures.model.MutationParameters
17+
import org.modelix.datastructures.model.PropertyChangedEvent
1418
import org.modelix.model.IVersion
1519
import org.modelix.model.api.BuiltinLanguages
1620
import org.modelix.model.api.INodeReference
@@ -30,7 +34,9 @@ import org.modelix.model.mpsadapters.MPSProjectReference
3034
import org.modelix.model.mpsadapters.MPSProperty
3135
import org.modelix.model.mpsadapters.toModelix
3236
import org.modelix.model.mutable.asModelSingleThreaded
37+
import org.modelix.model.oauth.IAuthConfig
3338
import org.modelix.model.operations.IOperation
39+
import org.modelix.model.server.ModelServerPermissionSchema
3440
import org.modelix.mps.multiplatform.model.MPSIdGenerator
3541
import org.modelix.streams.getBlocking
3642
import java.nio.file.Path
@@ -39,6 +45,7 @@ import kotlin.io.path.readText
3945
import kotlin.io.path.writeText
4046
import kotlin.test.assertFailsWith
4147
import kotlin.test.assertNotEquals
48+
import kotlin.time.Duration.Companion.seconds
4249

4350
class ProjectSyncTest : MPSTestBase() {
4451

@@ -259,7 +266,7 @@ class ProjectSyncTest : MPSTestBase() {
259266
it.getStreamExecutor().query { it.getChanges(version1.getModelTree(), false).toList() }
260267
}
261268
assertEquals(1, changes.size)
262-
val change = changes.single() as org.modelix.datastructures.model.PropertyChangedEvent<INodeReference>
269+
val change = changes.single() as PropertyChangedEvent<INodeReference>
263270
assertEquals(MPSProperty(nameProperty).getUID(), change.role.getUID())
264271
assertEquals("MyClass", version1.getModelTree().getProperty(change.nodeId, change.role).getBlocking(version1.getModelTree()))
265272
assertEquals("Changed", version2.getModelTree().getProperty(change.nodeId, change.role).getBlocking(version1.getModelTree()))
@@ -486,6 +493,92 @@ class ProjectSyncTest : MPSTestBase() {
486493
assertEquals(expectedSnapshot, project.captureSnapshot())
487494
}
488495

496+
fun `test missing permission on initial sync is detected`(): Unit = runWithModelServer(hmacKey = "abc") { port ->
497+
val branchRef = RepositoryId("sync-test").getBranchReference("branchA")
498+
AppLevelModelSyncService.getInstance().getOrCreateConnection(
499+
ModelServerConnectionProperties(
500+
url = "http://localhost:$port",
501+
repositoryId = branchRef.repositoryId,
502+
),
503+
).setAuthorizationConfig(
504+
IAuthConfig.fromTokenProvider {
505+
ModelixJWTUtil().also { it.setHmac512Key("abc") }
506+
.createAccessToken("[email protected]", listOf())
507+
},
508+
)
509+
510+
openTestProject("initial")
511+
val binding = IModelSyncService.getInstance(mpsProject)
512+
.addServer("http://localhost:$port")
513+
.bind(branchRef, null)
514+
assertFailsWith<ResponseException> {
515+
binding.flush()
516+
}
517+
518+
assertEquals(IBinding.Status.NoPermission(user = "[email protected]"), binding.getStatus())
519+
}
520+
521+
fun `test missing permission after initial sync is detected`(): Unit = runWithModelServer(hmacKey = "abc") { port ->
522+
fun now() = kotlinx.datetime.Instant.fromEpochMilliseconds(System.currentTimeMillis())
523+
val leeway = 60.seconds
524+
var grantPermission = true
525+
var tokenExpiration = now()
526+
val branchRef = RepositoryId("sync-test").getBranchReference("branchA")
527+
528+
fun initConnection(repositoryId: RepositoryId?) {
529+
AppLevelModelSyncService.getInstance().getOrCreateConnection(
530+
ModelServerConnectionProperties(
531+
url = "http://localhost:$port",
532+
repositoryId = repositoryId,
533+
),
534+
).setAuthorizationConfig(
535+
IAuthConfig.fromTokenProvider {
536+
val permissions = listOfNotNull(
537+
ModelServerPermissionSchema.repository(branchRef.repositoryId).write.fullId.takeIf { grantPermission },
538+
)
539+
ModelixJWTUtil()
540+
.also { it.setHmac512Key("abc") }
541+
.createAccessToken("[email protected]", permissions) {
542+
tokenExpiration = now() + 10.seconds
543+
it.claim("exp", (tokenExpiration - leeway).epochSeconds)
544+
}
545+
.also { println("Generated token: $it") }
546+
},
547+
)
548+
}
549+
initConnection(null)
550+
initConnection(branchRef.repositoryId)
551+
552+
openTestProject("initial")
553+
val binding = IModelSyncService.getInstance(mpsProject)
554+
.addServer("http://localhost:$port")
555+
.bind(branchRef, null)
556+
binding.flush()
557+
println("Initial sync done")
558+
grantPermission = false
559+
560+
delay(tokenExpiration - now())
561+
562+
val nameProperty = SNodeUtil.property_INamedConcept_name
563+
command {
564+
val node = mpsProject.projectModules
565+
.first { it.moduleName == "NewSolution" }
566+
.models
567+
.flatMap { it.rootNodes }
568+
.first { it.getProperty(nameProperty) == "MyClass" }
569+
println("will change property")
570+
node.setProperty(nameProperty, "Changed")
571+
println("property changed")
572+
}
573+
574+
println(AppLevelModelSyncService.getInstance().getConnections().map { it.properties })
575+
assertFailsWith<ResponseException> {
576+
binding.flush()
577+
}
578+
579+
assertEquals(IBinding.Status.NoPermission(user = "[email protected]"), binding.getStatus())
580+
}
581+
489582
fun `test loading enabled persisted binding`(): Unit = runPersistedBindingTest(true)
490583
fun `test loading disabled persisted binding`(): Unit = runPersistedBindingTest(false)
491584

mps-sync-plugin3/src/test/kotlin/org/modelix/mps/sync3/TestWithModelServer.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@ import kotlin.time.Duration.Companion.minutes
88
import kotlin.time.ExperimentalTime
99
import kotlin.time.toJavaDuration
1010

11-
fun runWithModelServer(body: suspend (port: Int) -> Unit) = runBlocking {
11+
fun runWithModelServer(hmacKey: String? = null, body: suspend (port: Int) -> Unit) = runBlocking {
1212
@OptIn(ExperimentalTime::class)
1313
withTimeout(5.minutes) {
1414
val modelServer: GenericContainer<*> = GenericContainer(System.getProperty("modelix.model.server.image"))
1515
.withExposedPorts(28101)
1616
.withCommand("-inmemory")
1717
.withEnv("MODELIX_VALIDATE_VERSIONS", "true")
1818
// .withEnv("MODELIX_REJECT_EXISTING_OBJECT", "true")
19+
.also {
20+
if (hmacKey != null) {
21+
it.withEnv("MODELIX_PERMISSION_CHECKS_ENABLED", "true")
22+
it.withEnv("MODELIX_JWT_SIGNATURE_HMAC512_KEY", hmacKey)
23+
}
24+
}
1925
.waitingFor(Wait.forListeningPort().withStartupTimeout(3.minutes.toJavaDuration()))
2026
.withLogConsumer {
2127
println(it.utf8StringWithoutLineEnding)

0 commit comments

Comments
 (0)