diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt index 0398ab8c80..5284c5da6a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt @@ -24,8 +24,8 @@ import kotlinx.coroutines.flow.flow * [createBrowserTab] to force the browser to navigate to an arbitrary URL, or [closeTab]/ * [selectTab] to manipulate what the user is looking at. * - * These guards protect this service only. SplitView and ProjectData still expose unguarded - * operations on the same server; BossConsole#53 tracks the remaining IPC surface. + * These guards protect this service only. BossConsole#53 tracks the remaining unguarded + * services on this server. */ // One method per RPC the generated service base class declares, plus small identity and audit helpers. @Suppress("TooManyFunctions") diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridge.kt index f065906f16..3888bbe6da 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridge.kt @@ -1,16 +1,38 @@ package ai.rever.boss.kernel.services import ai.rever.boss.components.plugin.panels.left_top.ProjectState +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.ProjectData import ai.rever.boss.plugin.api.ProjectDataProvider +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext +/** + * Kernel-side bridge for `ProjectDataService`. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505 and since applied to ActiveTabs, + * Download, Git, Log, PluginUI, RoleManagement and Supabase. Before this bridge checked + * identity at all, any process able to open a connection to the kernel IPC server - not only the + * plugins the host itself loaded - could [watchRecentProjects] to see every recently opened + * project's path (which routinely contains a username, per this repo's own AGENTS.md), and could + * [selectProject] to force an arbitrary window to switch its open project. That is not merely a + * UI surprise: a project switch publishes a [ai.rever.boss.plugin.api.ProjectChangeEvent] on the + * application-wide event bus every installed plugin can subscribe to (see AGENTS.md's "Browser + * telemetry" section), so an unauthenticated caller could trigger that broadcast on demand and + * hand every plugin a filesystem path it never asked to see. + * + * BossConsole#53 tracks the remaining unguarded services on this server. + */ class ProjectDataServiceBridge( private val provider: ProjectDataProvider, private val uiDispatcher: CoroutineDispatcher = Dispatchers.Main, @@ -21,10 +43,20 @@ class ProjectDataServiceBridge( * (`ProjectDataProviderImpl`) that stops updating once its owning window disposes it * (BossConsole#520); a KERNEL client watching it would freeze at whatever it last saw. This * stream has no such owner to outlive, so it keeps working across every window's lifecycle. + * Revocation is checked before each emission; idle streams are not proactively disconnected, + * and snapshots already delivered to a caller cannot be recalled. */ - override fun watchRecentProjects(request: Empty): Flow = - flow { + override fun watchRecentProjects(request: Empty): Flow { + // Capture the gRPC context before returning the asynchronously collected flow - the same + // shape ActiveTabsServiceBridge.watchActiveTabs uses, and for the same reason: the + // interceptor's Context is not carried into the coroutine that later collects this flow. + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = currentIdentity?.invoke() ?: refuseIdentity("watchRecentProjects") ProjectState.recentProjects.collect { projects -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchRecentProjects", "revocation") + } emit( ProjectListResponse .newBuilder() @@ -41,8 +73,11 @@ class ProjectDataServiceBridge( ) } } + } override suspend fun updateRecentProjects(request: ProjectProto): Empty { + val caller = authenticatedCallerOrRefuse("updateRecentProjects") + logMutation("updateRecentProjects", caller) provider.updateRecentProjects( ProjectData( name = request.name, @@ -54,11 +89,15 @@ class ProjectDataServiceBridge( } override suspend fun removeRecentProject(request: ProjectPathRequest): Empty { + val caller = authenticatedCallerOrRefuse("removeRecentProject") + logMutation("removeRecentProject", caller) provider.removeRecentProject(request.path) return Empty.getDefaultInstance() } override suspend fun selectProject(request: ProjectProto): Empty { + val caller = authenticatedCallerOrRefuse("selectProject") + logMutation("selectProject", caller) // The provider mutates per-window UI state and synchronously announces that change. // Confining the only non-UI entry point here keeps the state and its previous-path event // chain ordered without making third-party event handlers run under a host lock. @@ -73,4 +112,42 @@ class ProjectDataServiceBridge( } return Empty.getDefaultInstance() } + + /** + * Unary RPCs only: the verified identity, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun logMutation( + rpc: String, + caller: String, + ) { + logger.info( + LogCategory.AUTH, + "Authenticated project mutation requested", + mapOf("rpc" to rpc, "caller" to caller), + ) + } + + private fun refuseIdentity( + rpc: String, + stage: String = "bind", + ): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc, "stage" to stage), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + private companion object { + val logger = BossLogger.forComponent("ProjectDataServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridgeTest.kt index e1991b265a..c81e870437 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridgeTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridgeTest.kt @@ -1,28 +1,59 @@ package ai.rever.boss.kernel.services import ai.rever.boss.components.plugin.panels.left_top.ProjectState +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.ProjectDataServiceGrpcKt +import ai.rever.boss.ipc.proto.services.ProjectPathRequest import ai.rever.boss.ipc.proto.services.ProjectProto import ai.rever.boss.plugin.api.ProjectData import ai.rever.boss.plugin.api.ProjectDataProvider import ai.rever.boss.window.Project +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeout import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import kotlin.test.assertTrue /** - * Two routing guarantees on the kernel bridge: + * The project-data bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could watch every recently opened + * project's path with no credential at all, and could force an arbitrary window to switch its + * open project via `selectProject`, which also broadcasts a project-change event to every + * installed plugin. + * + * Two routing guarantees, unchanged by the identity gating and still exercised (now over an + * authenticated channel rather than a direct call): * * 1. `selectProject` confines provider mutation to the UI dispatcher, which lets * project-change announcement stay lock-free. @@ -34,19 +65,144 @@ import kotlin.test.assertTrue * whatever the window last saw. */ class ProjectDataServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: ProjectDataServiceGrpcKt.ProjectDataServiceCoroutineStub + private lateinit var anonymous: ProjectDataServiceGrpcKt.ProjectDataServiceCoroutineStub + + private fun startServer( + provider: ProjectDataProvider, + uiDispatcher: CoroutineDispatcher = Dispatchers.Main, + ) { + tokenRegistry = ProcessTokenRegistry() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(ProjectDataServiceBridge(provider, uiDispatcher)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = ProjectDataServiceGrpcKt.ProjectDataServiceCoroutineStub(authenticatedChannel) + anonymous = ProjectDataServiceGrpcKt.ProjectDataServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + if (::authenticatedChannel.isInitialized) { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + if (::anonymousChannel.isInitialized) { + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + if (::server.isInitialized) { + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + } + + @Test + fun `every RPC is refused with no credential, and never reaches the provider`() = + runBlocking { + val provider = RecordingProjectDataProvider() + startServer(provider) + + assertRefused { anonymous.watchRecentProjects(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { + anonymous.updateRecentProjects( + ProjectProto + .newBuilder() + .setName("n") + .setPath("/tmp/x") + .setLastOpened(1L) + .build(), + ) + } + assertRefused { + anonymous.removeRecentProject(ProjectPathRequest.newBuilder().setPath("/tmp/x").build()) + } + assertRefused { + anonymous.selectProject( + ProjectProto + .newBuilder() + .setName("n") + .setPath("/tmp/x") + .setLastOpened(1L) + .build(), + ) + } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the provider") + } + @Test - fun `selectProject switches from the grpc caller to the UI dispatcher`() { - val provider = RecordingProjectDataProvider() - val executor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, UI_THREAD_NAME) + fun `an authenticated caller can update, remove and select`() = + runBlocking { + val provider = RecordingProjectDataProvider() + startServer(provider) + + authenticated.updateRecentProjects( + ProjectProto + .newBuilder() + .setName("n") + .setPath("/tmp/x") + .setLastOpened(1L) + .build(), + ) + authenticated.removeRecentProject(ProjectPathRequest.newBuilder().setPath("/tmp/x").build()) + authenticated.selectProject( + ProjectProto + .newBuilder() + .setName("Selected") + .setPath("/tmp/boss-bridge-selected") + .setLastOpened(42L) + .build(), + ) + + assertEquals(listOf("updateRecentProjects", "removeRecentProject", "selectProject"), provider.calls) + assertEquals( + ProjectData(name = "Selected", path = "/tmp/boss-bridge-selected", lastOpened = 42L), + provider.selectedProject, + ) + } + + @Test + fun `revoked caller cannot invoke a unary RPC`() = + runBlocking { + val provider = RecordingProjectDataProvider() + startServer(provider) + + tokenRegistry.revoke(CALLER) + assertRefused { + authenticated.removeRecentProject(ProjectPathRequest.newBuilder().setPath("/tmp/x").build()) } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `selectProject switches from the grpc caller to the UI dispatcher`() = + runBlocking { + val provider = RecordingProjectDataProvider() + val executor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, UI_THREAD_NAME) + } + + executor.asCoroutineDispatcher().use { uiDispatcher -> + startServer(provider, uiDispatcher) - executor.asCoroutineDispatcher().use { uiDispatcher -> - val bridge = ProjectDataServiceBridge(provider, uiDispatcher) - val response = - runBlocking { - bridge.selectProject( + val response = + authenticated.selectProject( ProjectProto .newBuilder() .setName("Selected") @@ -54,20 +210,19 @@ class ProjectDataServiceBridgeTest { .setLastOpened(42L) .build(), ) - } - assertEquals(Empty.getDefaultInstance(), response) - } + assertEquals(Empty.getDefaultInstance(), response) + } - assertTrue( - provider.selectionThread?.startsWith(UI_THREAD_NAME) == true, - "selection ran on ${provider.selectionThread}", - ) - assertEquals( - ProjectData(name = "Selected", path = "/tmp/boss-bridge-selected", lastOpened = 42L), - provider.selectedProject, - ) - } + assertTrue( + provider.selectionThread?.startsWith(UI_THREAD_NAME) == true, + "selection ran on ${provider.selectionThread}", + ) + assertEquals( + ProjectData(name = "Selected", path = "/tmp/boss-bridge-selected", lastOpened = 42L), + provider.selectedProject, + ) + } @Test fun `watchRecentProjects reflects the process-wide ProjectState, not the per-window provider`() = @@ -88,33 +243,38 @@ class ProjectDataServiceBridgeTest { val second = Project(name = "boss-bridge-second", path = secondDir.absolutePath, lastOpened = 0L) try { val decoy = ProjectData(name = "decoy-window-only-project", path = "/nowhere/decoy", lastOpened = 0L) - val bridge = ProjectDataServiceBridge(FakeProjectDataProvider(MutableStateFlow(listOf(decoy)))) + startServer(FakeProjectDataProvider(MutableStateFlow(listOf(decoy)))) awaitInRecentProjects(seed) - val expected = ProjectState.recentProjects.value.map { it.path } - assertTrue(seed.path in expected, "seed disappeared before the first emission") - - // One collection, two emissions: the first pins the routing (the value - // equals the process-wide list, not the per-window decoy), the second - // pins liveness (a later mutation still reaches a client that keeps - // collecting). + // Observe a seeded snapshot and then a later mutation on one stream. Ignore + // the singleton's possible late startup-load emission and reseed while waiting; + // neither an empty stream nor the per-window decoy can satisfy these assertions. val emissions = mutableListOf>() val collector = launch { - bridge + authenticated .watchRecentProjects(Empty.getDefaultInstance()) - .take(2) + .filter { response -> + val awaitedPath = if (emissions.isEmpty()) seed.path else second.path + response.projectsList.any { it.path == awaitedPath } + }.take(2) .collect { emissions += it.projectsList.map { p -> p.path } } } withTimeout(5_000) { - while (emissions.size < 1) delay(10) + while (emissions.size < 1) { + ProjectState.updateRecentProjects(seed) + delay(10) + } } - assertEquals(expected, emissions.getOrNull(0), "first emission must be the process-wide list") + assertTrue(seed.path in emissions[0], "first observed snapshot must contain the global seed") assertNotEquals(listOf(decoy.path), emissions.getOrNull(0), "emission came from the per-window mirror") ProjectState.updateRecentProjects(second) withTimeout(5_000) { - while (emissions.size < 2) delay(10) + while (emissions.size < 2) { + ProjectState.updateRecentProjects(second) + delay(10) + } } collector.join() assertTrue( @@ -129,6 +289,61 @@ class ProjectDataServiceBridgeTest { } } + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + val seedDir = Files.createTempDirectory("boss-bridge-revoke-watch-seed").toFile() + val seed = Project(name = "boss-bridge-revoke-seed", path = seedDir.absolutePath, lastOpened = 0L) + try { + startServer(FakeProjectDataProvider(MutableStateFlow(emptyList()))) + awaitInRecentProjects(seed) + + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val emissions = mutableListOf>() + val watching = + async { + authenticated.watchRecentProjects(Empty.getDefaultInstance()).collect { + emissions += it.projectsList.map { p -> p.path } + received.send(Unit) + } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + val second = + Project( + name = "boss-bridge-revoke-second", + path = "/nowhere/after-revocation", + lastOpened = 0L, + ) + ProjectState.updateRecentProjects(second) + + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue( + emissions.none { second.path in it }, + "revocation must prevent a later snapshot from reaching the caller", + ) + } finally { + watching.cancel() + received.close() + } + } + } finally { + ProjectState.removeRecentProject(seed.path) + ProjectState.removeRecentProject("/nowhere/after-revocation") + seedDir.deleteRecursively() + } + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + /** * Seeds [project] into the real [ProjectState] and returns once it is present. Reseeds on * every poll, because the singleton's one-shot disk load can run once, late, and replace @@ -144,23 +359,32 @@ class ProjectDataServiceBridgeTest { } } + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ private class RecordingProjectDataProvider : ProjectDataProvider { + val calls = CopyOnWriteArrayList() override val recentProjects: StateFlow> = MutableStateFlow(emptyList()) var selectedProject: ProjectData? = null var selectionThread: String? = null - override fun updateRecentProjects(project: ProjectData) = Unit + override fun updateRecentProjects(project: ProjectData) { + calls += "updateRecentProjects" + } - override fun removeRecentProject(projectPath: String) = Unit + override fun removeRecentProject(projectPath: String) { + calls += "removeRecentProject" + } override fun selectProject(project: ProjectData) { + calls += "selectProject" selectedProject = project selectionThread = Thread.currentThread().name } } private companion object { + const val CALLER = "project-panel-plugin" const val UI_THREAD_NAME = "project-data-ui-test" + const val SHUTDOWN_TIMEOUT_MS = 5_000L } } diff --git a/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/BossIpcServer.kt b/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/BossIpcServer.kt index 9f5e953750..6d8aa6dcb0 100644 --- a/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/BossIpcServer.kt +++ b/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/BossIpcServer.kt @@ -34,12 +34,12 @@ class BossIpcServer( * instead of trusting a request field (BossConsole#53). Null (the default) installs no interceptor, * which is a no-op for a service that never checks identity - most `BossIpcServer` instances (every * child process's own `processServer`, every test server) have no use for one. It stops being a - * no-op for a server hosting a service that fails closed on a missing identity (currently - * [ai.rever.boss.kernel.services.SecretServiceBridge] and - * [ai.rever.boss.kernel.services.RoleManagementServiceBridge]): with no registry, every call to - * that service is refused, not merely unauthenticated as before - a silent total outage rather - * than the pre-BossConsole#53 status quo. Any kernel server hosting one of those bridges must - * be constructed with a real registry. + * no-op for a server hosting a service that fails closed on a missing identity - a growing list + * under `ai.rever.boss.kernel.services`, deliberately not enumerated here since the list has + * already drifted stale once: check each bridge's own KDoc for whether it requires identity. + * With no registry, every call to such a service is refused, not merely unauthenticated as + * before - a silent total outage rather than the pre-BossConsole#53 status quo. Any kernel + * server hosting one of those bridges must be constructed with a real registry. */ private val tokenRegistry: ProcessTokenRegistry? = null, ) {